Skip to content

Ingestion API

External systems can push customs declarations directly into CustomsHive using the canonical ingestion contract. Two entry points share the same ingestion pipeline:

Entry point Auth Format
POST /api/declarations X-Api-Key header or Azure AD Bearer (Ingestion.Write role) JSON or XML body
Azure Service Bus canonical queue Service Bus RBAC / SAS JSON or XML message body

Both paths call ICanonicalIngestionService, which resolves the client, routes to the correct adapter, and creates a dossier. See servicebus-integration.md for the ServiceBus-specific setup.


REST API — POST /api/declarations

Authentication

Two methods are supported; either is sufficient:

1. API Key (recommended for machine-to-machine)

Add an X-Api-Key header with a key generated in /Admin/ApiKeys. Keys are hashed (SHA-256) in the database — the raw key is shown only once at creation.

POST /api/declarations
X-Api-Key: ck_live_abc123...
Content-Type: application/json

2. Azure AD Bearer token (Ingestion.Write role)

Assign the Ingestion.Write app role to the calling app registration, then send an app-only token for this API:

POST /api/v1/declarations
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciO...
Content-Type: application/json

Request the token with the client-credentials flow, scope api://<CustomsHive-ClientId>/.default. The audience must be this application; a token issued for Graph or any other resource is rejected.

IngestionPartner is recorded from the token's appid — the calling application's id. A machine caller has no user name, and Entra sends no name either: app_displayname is not emitted for a custom API. That was checked twice on 2026-08-07, the second time after adding the claim to this app registration's optional claims, which Entra accepted and then ignored. The remaining route is a claims-mapping policy, which needs a directory admin and a custom signing key — not worth it for a display name.

The id is what gets stored, because it is the record of who filed the declaration. On screen it reads as API (IngestionPartnerDisplay); the id itself stays on the dossier row and in the per-dossier history detail. When a second machine caller appears, replace that label with a real app-id → name mapping — "API" is honest for one caller and useless for several.

Responses distinguish the two failure modes deliberately, because they have different fixes:

401 No API key and no valid token — the caller's credentials are the problem
403 Token is valid but the caller does not hold Ingestion.Write — assign the role in Entra

Setting up a machine caller

One-time, per calling application:

  1. On the CustomsHive app registration — Expose an API → set the Application ID URI. Already done: api://71c380b4-96db-490c-9380-9f265b3978f4. No scope is needed. Scopes are delegated permissions, meaning "on behalf of a signed-in user"; a partner posting declarations has no user. App roles assignable to Applications are the app-only equivalent, and Ingestion.Write already is one.
  2. On the calling app registration — API permissions → Add a permission → My APIs → CustomsHive → Application permissions (not Delegated) → Ingestion.Write.
  3. Grant admin consent. Easy to miss and needs a directory admin. Without it the permission is inert, and the symptom is misleading: the token issues fine but carries no roles claim, so the endpoint answers 403, not 401. That is what the two statuses are for — 401 means the credentials failed, 403 means they were fine and the role is missing.
  4. Request the token with client credentials and scope=api://71c380b4-96db-490c-9380-9f265b3978f4/.default. Decode it before calling: expect aud = that id and roles: ["Ingestion.Write"]. The bare client-id GUID works as a scope too — both forms are accepted as audiences.

This flow did not work before 3.1.7. Registering the handler (2026-08-06) was only half of it: inbound claim mapping was left on, so the handler rewrote roles to its WS-Federation URI while the role check looked for roles, and a token carrying Ingestion.Write was answered 403. Fixed in 3.1.7 by turning mapping off, and pinned by BearerRoleClaimTests.

Before that, the role existed and was assignable, and this endpoint had always checked for it, but no JWT bearer handler was registered — so a token was validated by nothing and the check could never pass. Only X-Api-Key functioned. If you are running an older build, use the API key.

Request body

A gzip-compressed body is accepted with Content-Encoding: gzip, on this endpoint and on /api/v1/clients; the 5 MB limit applies to the decompressed body.

Content-Type: application/json or Content-Type: application/xml.

The body must conform to the canonical declaration schema: - JSON: schemas/canonical/declaration.schema.json - XML: schemas/canonical/declaration.xsd

Property names are case-insensitive. The recommended format is snake_case (matching the JSON Schema).

Only declaration_type is required. All other fields are optional — the ingestion service fills in AppSettings-sourced company defaults before routing to the adapter.

auto_dispatch

Setting "auto_dispatch": true asks CustomsHive to file the declaration itself, without a declarant reviewing it. Transit only (T1/T2) — sending it on any other declaration type returns 422 rather than being ignored, so a sender never ends up believing their imports are filed automatically when they are not. Absent means no automatic dispatch.

The full behaviour — the target environment, why schema validation is never forced past, and what is written to the audit trail — is in servicebus-integration.md.

Response

201 Created on success:

{
  "uuid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "ucr": "TUF-20260613123456-abc123",
  "regime": "T2",
  "status": "Review",
  "autoDispatch": "not requested"
}

autoDispatch is not requested, dispatched, or refused: <reason> — reported back rather than only logged, because a caller that asked for automatic dispatch has no other way to learn it was refused, and would otherwise assume the shipment is filed.

4xx on failure:

Status Reason
400 Body empty or could not be parsed
401 No valid X-Api-Key and no valid Bearer token
403 Valid token, but the caller lacks the Ingestion.Write role
422 declaration_type is missing, the payload fails structural validation, or the message is rejected (e.g. auto_dispatch on a non-transit declaration)
429 Rate limit exceeded (60 req/min)

Canonical declaration format

Full schema: schemas/canonical/declaration.schema.json

Minimal examples by regime

IM — import:

{
  "declaration_type": "IM",
  "client_code": "SKCH",
  "parties": {
    "exporter": { "name": "Supplier Co", "country": "TR", "eori": "TR1234567890" }
  },
  "shipment": {
    "invoice_number": "INV-2026-001",
    "invoice_date": "2026-06-01",
    "currency": "EUR",
    "total_invoice_amount": 15000.00,
    "incoterm": "DAP"
  },
  "totals": { "gross_mass": 850.0 },
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Textile goods",
      "commodity_code": "6203420090",
      "country_of_origin": "TR",
      "gross_mass": 850.0,
      "net_mass": 800.0,
      "packages": [{ "type": "CT", "quantity": 10 }]
    }
  ]
}

EX — export:

{
  "declaration_type": "EX",
  "parties": {
    "exporter": { "eori": "BE0123456789", "name": "Exporter NV" },
    "consignee": { "name": "US Importer LLC", "country": "US" }
  },
  "customs_offices": {
    "export": "BE000100",
    "exit": "BE000200"
  },
  "transport": { "mode_at_border": 1, "border_means_id": "1-XYZ-789" },
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Machine parts",
      "commodity_code": "8479899700",
      "gross_mass": 500.0,
      "net_mass": 480.0,
      "packages": [{ "type": "BX", "quantity": 2 }]
    }
  ]
}

T2 — transit groupage (Qargo pattern):

{
  "declaration_type": "T2",
  "transport": { "departure_means_id": "1-ABC-123" },
  "totals": { "gross_mass": 2450.5 },
  "house_consignments": [
    {
      "sequence_number": 23,
      "gross_mass": 1200.0,
      "reference_number_ucr": "UCR-2026-001",
      "consignee": { "name": "Buyer NL BV", "country": "NL", "city": "Amsterdam" }
    },
    {
      "sequence_number": 24,
      "gross_mass": 1250.5,
      "reference_number_ucr": "UCR-2026-002",
      "consignee": { "name": "Buyer CH AG", "country": "CH", "city": "Zurich" }
    }
  ],
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Machine parts",
      "gross_mass": 1200.0,
      "net_mass": 1100.0,
      "house_consignment_sequence": 23,
      "packages": [{ "type": "CT", "quantity": 5, "shipping_marks": "MARK-001" }]
    },
    {
      "sequence_number": 2,
      "description": "Electronics",
      "gross_mass": 1250.5,
      "net_mass": 1200.0,
      "house_consignment_sequence": 24,
      "packages": [{ "type": "CT", "quantity": 8, "shipping_marks": "MARK-002" }]
    }
  ]
}

T2 groupage note: house_consignment_sequence on each goods item links it to the matching house_consignments[].sequence_number. Sequence numbers are Navision/Qargo shipment line IDs — they are preserved as-is and do not need to be sequential from 1. AppSettings-sourced fields (Consignor, TransitPrincipal, guarantee GRN, authorisation C521, departure office, location of goods) are merged by the ingestion service — the external system only needs to send the shipment-variable fields.


Client resolution

The ingestion service resolves the client in this order:

  1. client_code — direct lookup by Client.Code (fastest; use when the external system knows the internal code)
  2. EORI match — looks up Client.IdentificationNumber against the relevant party EORI:
  3. IMparties.importer.eori
  4. EXparties.exporter.eori
  5. T1/T2parties.consignee.eori
  6. Auto-create — if no match is found, a new Client is created from the party details (name, eori, address) and marked IsCustomsClient = true. A declaration quoting a client is proof they clear customs, so the client is selectable for manual dossiers straight away.

IsCustomsClient was called IsVerified until WP62, was set false here, and was read nowhere. Its meaning is now "appears in the dossier client picker" — see servicebus-integration.md.


POST /api/v1/clients — client masterdata

The Business Central masterdata push, over HTTP instead of the queue. Same body and same behaviour as a MessageType: Client queue message — the ownership rules, the all-or-nothing batch and the promotion model are all documented in servicebus-integration.md.

Authenticated with X-Api-Key only; the key's name is recorded as the clients' UpdatedBy.

curl -X POST https://customshive.example/api/v1/clients \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"clients":[{"code":"ACME","name":"Acme NV","identification_number":"BE0123456789"}]}'

200 OK:

{ "created": 1, "updated": 0, "unchanged": 0, "total": 1 }
Status Reason
400 Body could not be parsed
401 No valid X-Api-Key
422 The batch was rejected. errors lists every complaint, each naming the row and field
429 Rate limit exceeded (60 req/min)

API key management

Keys are managed via /Admin/ApiKeys (Admins role required):

  • Generate: enter a name (e.g. "Qargo Production") and optional expiry date → the raw key is displayed once and never stored (only the SHA-256 hash is kept)
  • Revoke: deactivates the key immediately; existing in-flight requests using the key will fail
  • Keys are scoped globally — any active key authenticates any POST /api/declarations request

Swagger UI (development)

Available at /swagger in Development and dv environments. Both authentication methods are pre-configured:

  • Bearer — paste an app-only access token for api://<CustomsHive-ClientId>/.default.
  • ApiKey — paste a raw API key from /Admin/ApiKeys

In development, your existing web app browser session (cookie) also authenticates GET endpoints automatically — no token needed for read-only endpoints.