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.
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:
- 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, andIngestion.Writealready is one. - On the calling app registration — API permissions → Add a permission → My APIs →
CustomsHive → Application permissions (not Delegated) →
Ingestion.Write. - 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
rolesclaim, 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. - Request the token with client credentials and
scope=api://71c380b4-96db-490c-9380-9f265b3978f4/.default. Decode it before calling: expectaud= that id androles: ["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
rolesto its WS-Federation URI while the role check looked forroles, and a token carryingIngestion.Writewas answered 403. Fixed in 3.1.7 by turning mapping off, and pinned byBearerRoleClaimTests.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-Keyfunctioned. 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_sequenceon each goods item links it to the matchinghouse_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:
client_code— direct lookup byClient.Code(fastest; use when the external system knows the internal code)- EORI match — looks up
Client.IdentificationNumberagainst the relevant party EORI: IM→parties.importer.eoriEX→parties.exporter.eoriT1/T2→parties.consignee.eori- Auto-create — if no match is found, a new
Clientis created from the party details (name,eori, address) and markedIsCustomsClient = true. A declaration quoting a client is proof they clear customs, so the client is selectable for manual dossiers straight away.
IsCustomsClientwas calledIsVerifieduntil WP62, was setfalsehere, 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:
| 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/declarationsrequest
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.