Technical Onboarding
How Deducta and your IT systems can work together.
Last updated on
Overview
This document provides the technical requirements and guidance for onboarding data to Deducta. It outlines the data required, available delivery methods, submission cadence, security requirements, and key responsibilities to support successful implementation and ongoing data refreshes.
What to send
The ingestion pipeline is designed to accept a variety of file structures, formats and field combinations as they come out of your source systems. We support Parquet, Excel workbooks and CSV files. Multiple files can also be zipped together into a single upload.
Datasets
Typical datasets are:
- Invoice transactions
- Purchase orders
- Supplier master
- Payment terms
- Business hierarchies
- Contracts
- Taxonomy and other procurement reference data, where available
Data schema
While we don't require a fixed ingestion schema, we do require the fields below to be represented accurately in the dataset. Providing these fields is the minimum that allows you to see the benefits of the Deducta platform. However, there is no limit on the amount of contextual spend information that we can ingest. The more we have, the better the platform performs.
| Field | Description |
|---|---|
| Net Spend Amount | Monetary value of the transaction or payment, excluding taxes |
| Currency / Currency Code | Currency the transaction is recorded in, for example GBP, USD, EUR |
| Supplier Name | Supplier associated with the transaction |
| Payment Date | Date the supplier payment was made (i.e., the clearing date) |
| Payment Terms | When payment is due to the supplier, for example Net 30 |
| Article Description | Description of the material, product or service purchased |
| Article ID / Material Code | Identifier of the material, product or service in the source system, plus any further article attributes |
| Purchasing Business Unit | Business unit or division responsible for the spend |
| Buying Entity | Legal entity purchasing the goods or services |
| Site | Location, facility or operating site associated with the spend |
| Unit of Measure | Standard quantity measure, for example kg, liters, units, cases |
| Unit Cost | Cost per unit |
| Quantity | Quantity purchased |
How Deducta defines spend
Spend is dated by Payment Date, the date the payment to the supplier is cleared. Invoice date, purchase order date and goods receipt date are not used as the spend date. If your extract has several dates, include them all and make sure Payment Date is present.
Formats
| Format | Extension | Notes |
|---|---|---|
| Parquet | .parquet | Preferred. Best performance and fidelity for large extracts |
| Excel | .xlsx | Accepted |
| CSV | .csv | Accepted |
| ZIP | .zip | Archive of any of the above, for sending a batch at once |
.pdf | Contracts only, up to 256 MB per file. Sent one file at a time through the API, and not part of a .zip |
File naming convention
To help Deducta identify and process each file, we recommend you name files using the following format:
{CompanyName}_{BusinessUnitName}_{DataType}_{TimeFrame}.{ext}
For example, ACME_fireworks_spend_FY25.xlsx or ACME_fireworks_contracts_202608.csv.
- Separate segments with underscores. Do not use spaces inside a segment.
- Use hyphens only inside a
{TimeFrame}range. - Use the company name or abbreviation agreed with Deducta for
{CompanyName}. - Write
{DataType}in lowercase (e.g.,spend,contracts). - For
{TimeFrame}, use the smallest unit that covers the data:
| Period | Format | Example |
|---|---|---|
| Month | YYYYMM | 202608 |
| Quarter | FYyyQn | FY25Q3 |
| Fiscal year | FYyy | FY25 |
| Range | {start}-{end} | 202601-202608 |
Fiscal year refers to your fiscal year, not the calendar year. Where the two differ and it matters, use the month or range format instead.
Choose a delivery method
| HTTP API Upload (recommended) | Shared storage account | Deducta platform | |
|---|---|---|---|
| Best for | Scheduled, automated feeds | Large files, backfills and rolling uploads | One-off, ad hoc uploads |
| Formats | .xlsx, .parquet, .csv, .zip | .xlsx, .parquet, .csv, .zip | .xlsx only |
| Cadence | Daily, weekly or monthly | Manual or automated | Manual |
| Max size | 50 MB per request (compressed size for a .zip); higher limits on request | No Deducta limit; 5,000 MiB per single REST request, larger via AzCopy or the SDK | Not specified |
| Data scope | Net-new data only | No restriction | Net-new data only |
| You need | API key, business unit ID | Client ID and secret, container URL | A platform login |
| Works from | Anywhere that can make an HTTPS request | Any environment, including Google Cloud, AWS and on-premises | Browser |
Option 1: HTTP API Upload
A scheduled job can export data from your source systems and call an HTTP PUT endpoint to upload the file to our system.
What we send you
| Item | Detail |
|---|---|
| API key | Sent by your Deducta representative. Passed in the X-API-Key header |
| Business unit ID | GUID that identifies where the data belongs |
| Base URL | https://api-data-ingestion.deducta.ai/m2m |
Request
| Endpoint | PUT /api/file/upload |
| Content type | multipart/form-data |
file (form data, required) | The data file or ZIP archive to upload |
businessUnitId (query string, required) | Your business unit GUID. It can't be empty or the all-zero GUID |
Limits
- Formats:
.xlsx,.parquet,.csvor.zip. - Size: 50 MB by default. For a
.zipthe limit applies to the compressed archive, not its contents. Higher limits can be arranged on request. - The file must not be empty.
- Net-new data only. Don't resend data you have already submitted, because it will not be de-duplicated and will be ingested as new data.
A .zip can hold one or more .xlsx, .parquet or .csv files. Deducta expands it on receipt, and the file names inside identify the data, so name them as described in File naming convention.
Response
Files are ingested asynchronously, but once a file has been accepted you will receive a 200 OK. The stored file name has a processing timestamp appended, so you don't need to add one yourself.
{
"fileId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"fileName": "your_file_uploaded_20260119T143025.xlsx",
"businessUnitId": "7d9e4b2a-8c3f-4e1d-9a6b-5f8c2d1e3a4b"
}
Errors return a JSON body such as { "error": "..." }:
| Status | Meaning and fix |
|---|---|
400 Bad Request | A validation failure. The error message says which: missing or empty businessUnitId, no file or an empty file, size over the limit, or an unsupported file type. |
401 Unauthorized | The API key is invalid or missing. |
500 Internal Server Error | The file couldn't be processed. The body includes a details message. Retry with backoff, and contact your Deducta representative if it persists. |
cURL
#!/bin/bash
API_KEY="your-api-key-here"
BUSINESS_UNIT_ID="your-business-unit-id-here"
FILE_PATH="/path/to/your/file.xlsx"
API_URL="https://api-data-ingestion.deducta.ai/m2m/api/file/upload"
curl -X PUT "${API_URL}?businessUnitId=${BUSINESS_UNIT_ID}" \
-H "X-API-Key: ${API_KEY}" \
-F "file=@${FILE_PATH}"
Python
import mimetypes
import requests
from pathlib import Path
# Configuration
API_KEY = "your-api-key-here"
BUSINESS_UNIT_ID = "your-business-unit-id-here"
FILE_PATH = "/path/to/your/file.xlsx"
API_URL = "https://api-data-ingestion.deducta.ai/m2m/api/file/upload"
ACCEPTED_EXTENSIONS = {".xlsx", ".parquet", ".csv", ".zip"}
MAX_FILE_SIZE_MB = 50
def upload_file(file_path, business_unit_id, api_key):
"""
Upload a data file to the Deducta Data Ingestion API.
Accepts .xlsx, .parquet, .csv, and .zip files; Excel and Parquet are
preferred. Use a .zip archive to upload a batch of files in one request.
Args:
file_path (str): Path to the file to upload
business_unit_id (str): GUID of the business unit
api_key (str): API key for authentication
Returns:
dict: Response containing fileId, fileName, and businessUnitId
Raises:
requests.exceptions.HTTPError: If the request fails
"""
# Validate file exists
file = Path(file_path)
if not file.exists():
raise FileNotFoundError(f"File not found: {file_path}")
# Validate file format
if file.suffix.lower() not in ACCEPTED_EXTENSIONS:
raise ValueError(f"Unsupported file format: {file.suffix}")
# Check file size — for a .zip this is the size of the compressed archive
file_size_mb = file.stat().st_size / (1024 * 1024)
if file_size_mb > MAX_FILE_SIZE_MB:
raise ValueError(
f"File size ({file_size_mb:.2f}MB) exceeds maximum allowed size of {MAX_FILE_SIZE_MB}MB"
)
# Prepare the request
headers = {
"X-API-Key": api_key
}
params = {
"businessUnitId": business_unit_id
}
# Open and upload the file (content type is inferred from the file name)
content_type = mimetypes.guess_type(file.name)[0] or 'application/octet-stream'
with open(file_path, 'rb') as f:
files = {
'file': (file.name, f, content_type)
}
response = requests.put(
API_URL,
headers=headers,
params=params,
files=files
)
# Raise exception for HTTP errors
response.raise_for_status()
return response.json()
# Example usage
if __name__ == "__main__":
try:
result = upload_file(FILE_PATH, BUSINESS_UNIT_ID, API_KEY)
print("Upload successful!")
print(f"File ID: {result['fileId']}")
print(f"File Name: {result['fileName']}")
print(f"Business Unit ID: {result['businessUnitId']}")
except FileNotFoundError as e:
print(f"Error: {e}")
except ValueError as e:
print(f"Validation Error: {e}")
except requests.exceptions.HTTPError as e:
print(f"HTTP Error: {e}")
if e.response.status_code == 400:
print(f"Validation failed: {e.response.json()}")
elif e.response.status_code == 401:
print("Authentication failed - check your API key")
elif e.response.status_code == 500:
print(f"Server error: {e.response.json()}")
except Exception as e:
print(f"Unexpected error: {e}")
C#
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Text.Json;
public class FileUploadClient
{
private readonly HttpClient _httpClient;
private readonly string _apiKey;
private const long MaxFileSizeBytes = 50 * 1024 * 1024; // 50MB
private static readonly HashSet<string> AcceptedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".xlsx", ".parquet", ".csv", ".zip"
};
public FileUploadClient(string baseUrl, string apiKey)
{
_httpClient = new HttpClient { BaseAddress = new Uri(baseUrl) };
_apiKey = apiKey;
}
public async Task<FileUploadResponse> UploadFileAsync(
string filePath,
Guid businessUnitId)
{
// Validate file exists
if (!File.Exists(filePath))
{
throw new FileNotFoundException("File not found", filePath);
}
// Validate file format
var extension = Path.GetExtension(filePath);
if (!AcceptedExtensions.Contains(extension))
{
throw new ArgumentException($"Unsupported file format: {extension}");
}
// Validate file size — for a .zip this is the size of the compressed archive
var fileInfo = new FileInfo(filePath);
if (fileInfo.Length > MaxFileSizeBytes)
{
throw new ArgumentException(
$"File size ({fileInfo.Length / 1024 / 1024}MB) exceeds maximum allowed size of 50MB");
}
// Validate businessUnitId
if (businessUnitId == Guid.Empty)
{
throw new ArgumentException("businessUnitId cannot be empty");
}
// Prepare the multipart form data
using var content = new MultipartFormDataContent();
using var fileStream = File.OpenRead(filePath);
using var streamContent = new StreamContent(fileStream);
streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
content.Add(streamContent, "file", Path.GetFileName(filePath));
// Prepare the request
var requestUri = $"/api/file/upload?businessUnitId={businessUnitId}";
var request = new HttpRequestMessage(HttpMethod.Put, requestUri)
{
Content = content
};
request.Headers.Add("X-API-Key", _apiKey);
// Send the request
var response = await _httpClient.SendAsync(request);
// Parse response
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Upload failed with status {response.StatusCode}: {responseContent}");
}
return JsonSerializer.Deserialize<FileUploadResponse>(responseContent);
}
}
public class FileUploadResponse
{
public Guid FileId { get; set; }
public string FileName { get; set; }
public Guid BusinessUnitId { get; set; }
}
// Example usage
public class Program
{
public static async Task Main(string[] args)
{
var apiKey = "your-api-key-here";
var businessUnitId = Guid.Parse("your-business-unit-id-here");
var filePath = @"C:\path\to\your\file.xlsx";
var apiUrl = "https://api-data-ingestion.deducta.ai/m2m";
var client = new FileUploadClient(apiUrl, apiKey);
try
{
var result = await client.UploadFileAsync(filePath, businessUnitId);
Console.WriteLine("Upload successful!");
Console.WriteLine($"File ID: {result.FileId}");
Console.WriteLine($"File Name: {result.FileName}");
Console.WriteLine($"Business Unit ID: {result.BusinessUnitId}");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
catch (ArgumentException ex)
{
Console.WriteLine($"Validation Error: {ex.Message}");
}
catch (HttpRequestException ex)
{
Console.WriteLine($"HTTP Error: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
}
}
}
Best practices
- Keep the API key out of source code. Load it from an environment variable or a secrets manager.
- Validate the format and size, and check the file is not empty, before uploading.
- Send a
.zipinstead of many single-file requests when you have several files for the same business unit. - Retry
5xxresponses with exponential backoff.4xxresponses are validation or authentication problems that retrying won't fix. - Set timeouts for large uploads.
Option 2: Shared storage account
Deducta provisions a container in an Azure Blob Storage account for your organization. You authenticate with a client ID and secret, request a short-lived token, and upload over HTTPS.
What we send you
| Item | Detail |
|---|---|
| Tenant ID | Identifies the Microsoft Entra tenant to authenticate against |
| Client ID | Identifies your credential |
| Client secret | Sent separately through a one-time link |
| Storage account and container | The upload URL: https://<storage-account>.blob.core.windows.net/<container>/ |
| Token scope | https://storage.azure.com/.default |
Your credentials can read, write and delete files in your isolated container only.
What we need from you
- Name and email of your technical contact for uploads.
- If we have agreed to restrict access by IP address, the static public IP address or addresses your uploads come from.
- A message once your first upload is in, so we can confirm we can read it.
Handle the secret like any other production credential. Store it in a secrets manager such as Google Secret Manager, AWS Secrets Manager or Azure Key Vault, and load it at runtime. Never put it in code, configuration files or version control. The examples below read it from an environment variable named DEDUCTA_CLIENT_SECRET.
Uploads never overwrite an existing file, so give each file a new name, for example by including the date.
AzCopy (recommended)
AzCopy is Microsoft's command-line tool for Azure Storage. It handles large files, retries and parallel uploads, so it suits backfills and scheduled jobs. It signs in from the environment variables below, so there is no separate login step.
export AZCOPY_AUTO_LOGIN_TYPE=SPN
export AZCOPY_TENANT_ID="<tenant-id>"
export AZCOPY_SPA_APPLICATION_ID="<client-id>"
export AZCOPY_SPA_CLIENT_SECRET="$DEDUCTA_CLIENT_SECRET"
# Upload a file without overwriting an existing one
azcopy copy "acme_spend_2026-09.parquet" \
"https://<storage-account>.blob.core.windows.net/<container>/" \
--overwrite=false
# List what is in the container
azcopy list "https://<storage-account>.blob.core.windows.net/<container>"
Python
The SDK requests and refreshes tokens for you and splits large files into blocks automatically.
pip install azure-identity azure-storage-blob
import os
from azure.identity import ClientSecretCredential
from azure.storage.blob import ContainerClient
credential = ClientSecretCredential(
tenant_id="<tenant-id>",
client_id="<client-id>",
client_secret=os.environ["DEDUCTA_CLIENT_SECRET"],
)
container = ContainerClient(
account_url="https://<storage-account>.blob.core.windows.net",
container_name="<container>",
credential=credential,
)
local_path = "acme_spend_2026-09.parquet"
with open(local_path, "rb") as data:
container.upload_blob(name=os.path.basename(local_path), data=data, overwrite=False)
for blob in container.list_blobs():
print(blob.name, blob.size)
REST (curl)
Use this if you can't install AzCopy or the SDK. A single request uploads up to 5,000 MiB; use AzCopy or the SDK for anything larger.
Step 1: request an access token, valid for about an hour.
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token" \
-d "grant_type=client_credentials" \
-d "client_id=<client-id>" \
--data-urlencode "client_secret=$DEDUCTA_CLIENT_SECRET" \
-d "scope=https://storage.azure.com/.default" | jq -r .access_token)
Step 2: upload the file. 201 means success; 409 means a file with that name already exists, which is what the If-None-Match header enforces.
curl -X PUT \
"https://<storage-account>.blob.core.windows.net/<container>/acme_spend_2026-09.parquet" \
-H "Authorization: Bearer $TOKEN" \
-H "x-ms-version: 2023-11-03" \
-H "x-ms-blob-type: BlockBlob" \
-H "If-None-Match: *" \
--data-binary @acme_spend_2026-09.parquet \
-w "%{http_code}\n"
Troubleshooting
| Error | Meaning and fix |
|---|---|
AADSTS7000215 Invalid client secret | The secret value is wrong. Check for missing characters or extra whitespace. |
AADSTS7000222 Client secret expired | Ask your Deducta representative for a new secret. |
401 InvalidAuthenticationInfo | The token expired (they last about an hour) or was requested for the wrong scope. Request a new one with https://storage.azure.com/.default. |
403 AuthorizationPermissionMismatch | You are writing outside your container. Check the URL. |
403 AuthorizationFailure | The request came from an IP address we don't allow. Send us the public IP you upload from. |
409 BlobAlreadyExists | A file with that name was already uploaded. Use a different name. |
Option 3: Deducta platform
For ad hoc events, sign in to the Deducta platform and upload an .xlsx file manually. Use this for one-off files; for anything recurring, prefer the API or the storage account.
Uploading contracts
Contracts are sent as PDF documents through the same HTTP API as your data files, using the same API key and base URL. Unlike data files, a contract belongs to your organization, so no business unit ID is needed.
| Endpoint | PUT /api/contract/upload |
| Content type | multipart/form-data |
file (form data, required) | One .pdf contract |
| Max size | 256 MB per file |
- Send one contract per request. Contracts can't be zipped.
- Scanned PDFs are fine.
- Encrypted or password-protected PDFs are rejected, as are PDFs that contain embedded JavaScript, file attachments, launch actions, rich media or form submit actions.
- The file name you send is kept as the contract's name, so use a descriptive one.
A successful upload returns 200 OK with the fileId and fileName, and the contract is processed asynchronously. A 400 Bad Request means the file was empty, wasn't a .pdf, was over the size limit or failed the checks above, and the error message says which. 401 Unauthorized and 500 Internal Server Error behave as they do for data file uploads.
curl -X PUT "https://api-data-ingestion.deducta.ai/m2m/api/contract/upload" \
-H "X-API-Key: ${API_KEY}" \
-F "file=@/path/to/contract.pdf"
To remove a contract, call DELETE /api/contract/{fileId} with the fileId from the upload response. It returns 204 No Content, or 404 Not Found if there is no such contract.
Cadence and reporting period
Deducta supports your preferred submission frequency: daily, weekly, monthly or quarterly. Monthly is the most common cadence. The schedule is agreed during onboarding.
Security and privacy
Standard controls:
- Role-based access control (RBAC)
- Encryption of data in transit and at rest
- Audit logging and monitoring
- Data retention and lifecycle management controls
- Compliance with shared policies and regulatory requirements
Please reach out to your Deducta representative with any specific questions.
FAQ
Data
Do we need to transform or clean our data before sending it?
No. Send it as it comes out of your source system, in any of the accepted formats. The ingestion pipeline handles a variety of structures and field combinations. The main thing to check is that the fields in the data schema are present.
We can't supply every field in the data schema. Can we still start?
Yes. We work closely with every organization to understand what data they have available and come up with a solution that tries to maximize the utility of the platform.
Which date should we use for spend?
We use the payment (clearing) date as the date for all spend. Deducta does not use invoice date, PO date or goods receipt date as the spend date. They can still be provided, but they are not actively used in any analysis on the platform.
Which file format should we use?
Parquet is preferred, especially for large extracts. Excel and CSV are accepted. Use .zip to send several files in one go. The platform upload accepts .xlsx only. Contracts are the exception: send them as .pdf, as described in Uploading contracts.
Delivery
How do we know an upload worked?
API: a 200 response with a fileId, fileName and businessUnitId. Storage account: 201 from the REST call, or the file shows up in azcopy list or list_blobs(). For your first upload, also message your Deducta representative so we can confirm the file is readable.
What happens if we upload a file with the same name twice?
Storage account: the upload is rejected with 409 BlobAlreadyExists, and nothing is overwritten. Use a new name, for example with the date. API: the stored file name gets a processing timestamp appended, so you don't need to add one.
Security and access
Can we restrict uploads to specific IP addresses?
For the storage account, yes. If we agree an IP restriction, send us the static public IP addresses your uploads come from. Requests from other addresses fail with 403 AuthorizationFailure.
Do credentials expire?
Storage client secrets have an expiry date. We send a replacement before then, and both work during the changeover. Access tokens last about an hour; AzCopy and the SDK refresh them automatically. API keys are issued by your Deducta representative.
What if a key or secret is exposed?
Tell your Deducta representative immediately and we will revoke it and issue a new one.
How is data protected?
Data is encrypted in transit (HTTPS) and at rest, access is role-based and limited to authorized users, and activity is audit logged. See Security and privacy and our security page.
Support
For API keys, storage credentials, or any question on this page, contact your Deducta representative.
