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.
A key does only what its scopes allow, and every key expires (at most 366 days after it is minted):
| Scope | Allows | Default |
|---|---|---|
declarations:write |
POST /api/v1/declarations |
on |
declarations:read |
GET /api/v1/declarations/{uuid} |
on |
declarations:dispatch |
auto_dispatch: true on a transit declaration — files with customs without review |
off |
clients:write |
POST /api/v1/clients |
off |
A key may also be narrowed. A partner on the key is what its dossiers are filed as, and the only dossiers it can look up; a client on the key is the only client (or a sending entity filing under it) it may create declarations for or look up. Both blank means the key reaches every dossier — right for Transuniverse's own integrations, wrong for a partner's.
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 without the Ingestion.Write role; a valid key without declarations:write; auto_dispatch: true from a key without declarations:dispatch; or a key bound to one client sending a declaration that resolves to another (or to no existing client) |
409 |
The UCR already exists and was filed by another partner. A replay by the same partner returns 200 with the existing dossier. A replay changes nothing and never files other content: with auto_dispatch it files only a dossier your own message created, and only when the body is that same message. A corrected body under the same UCR answers auto_dispatch: refused: …; send a correction under a new UCR, or ask a declarant to correct the dossier |
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) |
GET /api/v1/declarations/{uuid}
Returns uuid, ucr, regime, status, created_at, updated_at and auto_dispatch for a dossier the key created. auto_dispatch (from release 4.1.20) is dispatched, not requested, or refused: … with the reason, the same value the POST answered with; a sender on the queue, which gets no answer to its message, reads it here. Needs declarations:read; X-Api-Key only. A dossier outside the key's reach (another partner's, or another client's for a client-bound key) answers 404, the same as one that does not exist, so a narrowed key cannot probe which identifiers exist.
Canonical declaration format
Full schema: schemas/canonical/declaration.schema.json
A filled-in example from a real dossier
Any dossier in CustomsHive can be downloaded as the canonical message that would create it: the
"Canonical JSON" button on the dossier page, for signed-in users. Ask us for one made from a
declaration like yours. It holds the goods lines, parties, documents and header values as a
declarant reviewed them, under the names of the schema above, and nothing the ingestion does not
read: sent back in under another ucr, it gives the same dossier.
It is an example of the message, not a copy of the declaration that was filed. Values a declarant adds afterwards and that this contract has no field for (a tariff quota, a transport-cost division, the previous document of an import) are not in it.
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, and the key must hold clients:write — off by default, because in production this data arrives from Business Central over the queue and the HTTP route exists for local testing. The key's partner 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"), an expiry date (required, at most 366 days out, defaults to one year), the scopes, and optionally the partner and the client the key is narrowed to → 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 cannot be edited. To widen or narrow one, mint a new key and revoke the old one — the partner rotates once, and the old key's dossiers stay attributed to it
- Keys minted before scopes existed (3.19 and earlier) were migrated to
declarations:read declarations:write, narrowed to their own name as partner, with an expiry one year from the upgrade. A partner that relied onauto_dispatchover the API needs a new key withdeclarations:dispatch
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.