Docs /Technical Onboarding

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.

FieldDescription
Net Spend AmountMonetary value of the transaction or payment, excluding taxes
Currency / Currency CodeCurrency the transaction is recorded in, for example GBP, USD, EUR
Supplier NameSupplier associated with the transaction
Payment DateDate the supplier payment was made (i.e., the clearing date)
Payment TermsWhen payment is due to the supplier, for example Net 30
Article DescriptionDescription of the material, product or service purchased
Article ID / Material CodeIdentifier of the material, product or service in the source system, plus any further article attributes
Purchasing Business UnitBusiness unit or division responsible for the spend
Buying EntityLegal entity purchasing the goods or services
SiteLocation, facility or operating site associated with the spend
Unit of MeasureStandard quantity measure, for example kg, liters, units, cases
Unit CostCost per unit
QuantityQuantity 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

FormatExtensionNotes
Parquet.parquetPreferred. Best performance and fidelity for large extracts
Excel.xlsxAccepted
CSV.csvAccepted
ZIP.zipArchive of any of the above, for sending a batch at once
PDF.pdfContracts 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:
PeriodFormatExample
MonthYYYYMM202608
QuarterFYyyQnFY25Q3
Fiscal yearFYyyFY25
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 accountDeducta platform
Best forScheduled, automated feedsLarge files, backfills and rolling uploadsOne-off, ad hoc uploads
Formats.xlsx, .parquet, .csv, .zip.xlsx, .parquet, .csv, .zip.xlsx only
CadenceDaily, weekly or monthlyManual or automatedManual
Max size50 MB per request (compressed size for a .zip); higher limits on requestNo Deducta limit; 5,000 MiB per single REST request, larger via AzCopy or the SDKNot specified
Data scopeNet-new data onlyNo restrictionNet-new data only
You needAPI key, business unit IDClient ID and secret, container URLA platform login
Works fromAnywhere that can make an HTTPS requestAny environment, including Google Cloud, AWS and on-premisesBrowser

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

ItemDetail
API keySent by your Deducta representative. Passed in the X-API-Key header
Business unit IDGUID that identifies where the data belongs
Base URLhttps://api-data-ingestion.deducta.ai/m2m

Request

EndpointPUT /api/file/upload
Content typemultipart/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, .csv or .zip.
  • Size: 50 MB by default. For a .zip the 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": "..." }:

StatusMeaning and fix
400 Bad RequestA 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 UnauthorizedThe API key is invalid or missing.
500 Internal Server ErrorThe 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 .zip instead of many single-file requests when you have several files for the same business unit.
  • Retry 5xx responses with exponential backoff. 4xx responses 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

ItemDetail
Tenant IDIdentifies the Microsoft Entra tenant to authenticate against
Client IDIdentifies your credential
Client secretSent separately through a one-time link
Storage account and containerThe upload URL: https://<storage-account>.blob.core.windows.net/<container>/
Token scopehttps://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

ErrorMeaning and fix
AADSTS7000215 Invalid client secretThe secret value is wrong. Check for missing characters or extra whitespace.
AADSTS7000222 Client secret expiredAsk your Deducta representative for a new secret.
401 InvalidAuthenticationInfoThe 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 AuthorizationPermissionMismatchYou are writing outside your container. Check the URL.
403 AuthorizationFailureThe request came from an IP address we don't allow. Send us the public IP you upload from.
409 BlobAlreadyExistsA 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.

EndpointPUT /api/contract/upload
Content typemultipart/form-data
file (form data, required)One .pdf contract
Max size256 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.

We use cookies to analyse site traffic and support our marketing. See our cookie policy for details.