Skip to content

Azure Service Bus — Integration Architecture

Overview

CustomsHive connects to external systems via two Azure Service Bus queues:

Queue Direction Purpose
Canonical ingestion queue → CustomsHive External systems push declarations and client masterdata (JSON or XML)
Outbound Descartes queue CustomsHive → Generated SMF XML ready for SFTP upload to customs

Both queues authenticate via ClientSecretCredential using the shared Azure:* credentials block.


Inbound — canonical ingestion queue

Configured under ServiceBus:Ingestion:*. CanonicalServiceBusReceiverService (in CustomsHive.Module.Ingestion) listens on the queue and routes each message by its MessageType application property.

Message types

MessageType Body Handled by
Declaration (or absent) CanonicalDeclaration, or a sender-native format the receiver detects ICanonicalIngestionService.IngestAsync() → a dossier
Client CanonicalClientBatch — Business Central masterdata IClientMasterdataService.ImportAsync() → client rows
Document the file itself (PDF, …) — a source document for a dossier that already exists ICanonicalIngestionService.AttachDocumentAsync() → a stored document on the dossier

Absent means Declaration, so nothing already publishing has to change. An unrecognised value is rejected, not defaulted: treating Customer as a declaration would parse masterdata as goods and dead-letter with an error describing the wrong problem.

One message carries one declaration. declaration_type is a single required field in both schemas. (The receiver returns a list internally, purely because Ziegler's PRE-DECL fans out to EX and IM from its own declCreate* flags — that is Ziegler telling other parties what to create. It is an implementation detail of that converter, not part of this contract.)

Configuration

"ServiceBus": {
  "Ingestion": {
    "ActiveEnvironment": "prd",
    "prd": {
      "FullyQualifiedNamespace": "your-servicebus.servicebus.windows.net",
      "QueueName": "customshive-inbound"
    }
  }
}

Multiple named environments can be defined (dv, stg, prd). Only ActiveEnvironment is connected at runtime.

Message format

The body is either a canonical CanonicalDeclaration — see schemas/canonical/declaration.schema.json (JSON) or schemas/canonical/declaration.xsd (XML) — or one of the sender-native XML formats the receiver detects automatically:

  • Ziegler/Z-CUSTOMS PRE-DECL (root <Header> with a <Declaration> child) — see below.
  • Delta Light pro-forma invoice (root <MT_ProFormaInvoice>, SAP PI/PO) — one EX declaration per message. Goods items come from TariffLines (placeholder codes like "DUMMY COMMODITY C" are dropped for the declarant to fill); SalesInvoiceLines are commercial detail and are not mapped. The embedded base64 <PDF> is stored as the dossier's invoice document via the canonical attachments mechanism.

JSON body example (T2 groupage):

{
  "declaration_type": "T2",
  "parties": {
    "consignee": {
      "name": "ACME SA",
      "eori": "BE0123456789",
      "city": "Brussels",
      "country": "BE"
    }
  },
  "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" }
    }
  ],
  "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" }],
      "undg_numbers": ["1263"]
    }
  ]
}

XML body example: use Content-Type: application/xml or the ContentType message property.

Ziegler PRE-DECL bodies

A PRE-DECL is a combined message: declCreateExport / declCreateImport flags with per-direction Export/Import sub-blocks. The flags describe everything the customer wants created somewhere — historically the sending system created its own side and passed the counterpart's data to a foreign agent, so the import side of e.g. a BE→GB shipment is usually not ours to declare. Send the DeclarationType property (EX or IM). On this queue it is required whenever the message flags more than one side, and a message without it is dead-lettered rather than processed.

That is not a preference. Checked against eight real Vandemoortele PRE-DECLs: declCreateExport and declCreateImport are both Y in every one, there is no Declarant or Representative block at all, and the routing fields the spec defines (custApplic, custBranch, declUserCreate) are never sent. A country heuristic does not rescue it either — the export country is BE five times, FR twice and DE once, so "the Belgian side is ours" is wrong on three of eight. The spec itself calls PRE-DECL a "pre-declaration from customer": it is the customer's view of the shipment and carries both halves because it used to feed both channels, while Z-Customs decided which side to file from its own configuration. Creating both would file a declaration that is not ours to file.

A PRE-DECL that flags only one side needs no property. Conversion notes:

  • yyyyMMdd dates become ISO 8601; decimal package counts (22.000) are rounded to whole numbers.
  • Export items get the 8-digit CN code (taric1); import items the full 10-digit TARIC code (taric1+taric2).
  • Per-item regimeAsked/regimeFormer map to requested_procedure/previous_procedure per direction.
  • declOrigReg (export) and declDestReg (import) become region_of_dispatch; per-item itemCurr becomes the item's currency.
  • Per-item itemUndg is a comma-separated list of UN dangerous-goods numbers and becomes undg_numbers. Export and transit only — AES and NCTS carry DangerousGoods inside Commodity; IDMS has no such element, so the value is kept on an import dossier but never filed.
  • docCat is translated rather than passed through: SD (and AI/TC) become supporting_documents, TD becomes transport_documents, RD and AR both become additional_references, and AU becomes authorisations. AU is not in the Z-Customs vocabulary at all — docCat is an unconstrained two-character string in their XSD — but five of eight real messages send it with C601.
  • declCreateTransit is ignored (no sender uses it; the format carries no guarantees/route) — the declarant creates the T-dossier manually.
  • Fields the format cannot express (coded goods-location on the import side, exchange rate, …) stay empty and are completed by the declarant in the review UI — every ingested dossier has RequiresReview = true.
  • Incoming PRE-DECL bodies are validated against the official "Z-Customs Interface XML" schema (schemas/z-customs/, embedded in CustomsHive.Core). Deviations are warnings, not rejections: they are logged (and shown in the UI for manual uploads) because they usually mean a sender-side bug whose data would otherwise be dropped silently, but a convertible message still creates its dossiers.

Message properties

Application property Type Required Description
Partner string yes Identifies the sending system (e.g. "Qargo", "Navision"). Stored as Dossier.IngestionPartner, and recorded as the UpdatedBy of imported clients. A message without it is dead-lettered.
MessageType string no Declaration, Client or Document. Absent → Declaration. Any other value is rejected.
ContentEncoding string no gzip when the body is gzip-compressed — the same convention as the outbound Descartes queue, and worth using above ~200 KB on a Standard-tier queue (256 KB per message). The receiver decompresses on the gzip signature (1F 8B) whether or not the property is set, then reads the body as JSON or XML as usual. Base64 is also unwrapped, including base64-of-gzip: send the bytes if you can, but a body your platform has base64-encoded on the way out still processes.
Ucr (alias DossierNumber) string no Dossier reference from the sender (e.g. Navision dossier number). Used as the dossier UCR; when one message yields multiple dossiers it is suffixed with the declaration type ({ucr}-EX, {ucr}-IM). Never overrides a UCR set in the body. Absent → auto-generated.
ClientCode string no Navision client code. Authoritative: overrides any client code in the body. Unknown codes auto-create a customs client; without it, client resolution falls back to the relevant party's EORI.
AutoDispatch bool no File this transit declaration without a declarant reviewing it — see below. For sender-native formats, which have nowhere in the body to say it. The body's auto_dispatch wins where both are present.
DeclarationType string yes, for a multi-sided PRE-DECL Which side to create (EX or IM). Requesting a type the message does not flag fails the message. Absent on a message flagging both → rejected and dead-lettered, because nothing in a PRE-DECL says which side is ours. Not needed when only one side is flagged, or for any other body format.

| FileName | string | yes, for a Document | The name the file arrived under. Decides the stored extension and identifies a redelivery of the same document. Optional only when the body is a PDF, which announces itself. | | DocumentRole (alias Kind) | string | no | On a Document: invoice, packing_list, transit, ead, goods or other. Absent behaves exactly like an unlabelled canonical attachment — role Other, mirrored into the invoice column if that is free. |

Partner is required because it is the only thing that says who sent a message, and per-sender behaviour is unavoidable now the queue carries three message types.

Your message is kept

Every declaration that creates a dossier is stored with it, verbatim, exactly as it arrived on the queue — under the name your FileName property gives it, or {ucr}.xml when it gives none. It appears on the dossier's Details page as Declaration as received, beside the invoice and the goods sheet.

It is the record of what you sent, not our reading of it: the converted result lives in the dossier's own data and that is ours, while this is yours. It is also what a converter fix is replayed against, so a message that was read wrongly can be re-read once the fix is in.

No extraction is ever run on it, and it is deliberately kept out of the three document slots the extraction pipeline reads.

Documents as their own message

A declaration can carry its documents inside it (attachments), and that is the better way: one message, one transaction, no ordering to get wrong. MessageType: Document exists for senders whose own pipeline produces the files separately — Transuniverse's prc-customstuf-order is invoked once per file, so a Vandemoortele consignment is a PRE-DECL message and an invoice-PDF message sharing a Ucr.

The body is the file, and Ucr is what links it to its dossier. It is stored beside a canonical attachment, in ingested/, with a DossierDocument row and the same mirrored path column, and it appears on the dossier for a declarant to check the declaration against.

A document is never an input to a declaration. No extraction runs on it and nothing about the dossier's data changes — an ingested dossier's goods data arrived structured, and this file is evidence. A Document message therefore cannot delay, alter or block the declaration it belongs to.

Two consequences worth knowing:

  • Order does not have to be guaranteed. If the dossier is not there yet, the message is left unsettled and retried as its lock lapses, giving the declaration time to land. After five deliveries it is dead-lettered saying so — so a document really is safe to send in parallel with its declaration, but not instead of first creating one.
  • Redelivery is safe. A document whose FileName the dossier already holds is recorded as a replay and stored once.
  • The dossier has to be yours. A document for a dossier that another sender's message created is rejected and dead-lettered. A document for a dossier a declarant made by hand, or for one that is already locked or filed, is kept and shown on the dossier as evidence, and takes none of the document slots.

A complete message, end to end

Everything above in one place. A Service Bus message is application properties plus a body, and both matter — the properties carry what the body cannot say.

Properties

property value
Partner Navision
Ucr TUF/2026/00412
ClientCode VDM001
ContentType application/json

Body

{
  "declaration_type": "EX",
  "shipment": {
    "invoice_number": "0013244740",
    "invoice_date": "2026-06-19",
    "currency": "EUR",
    "total_invoice_amount": 26840.79,
    "incoterm": "DDP",
    "incoterm_location": "BRADFORD",
    "country_of_dispatch": "BE",
    "country_of_destination": "GB",
    "nature_of_transaction": 11,
    "region_of_dispatch": "1"
  },
  "parties": {
    "exporter":  { "eori": "BE0721494116", "name": "VDM Europe", "street_and_number": "Ottergemsesteenweg-Zuid 816", "postcode": "9000", "city": "Ghent", "country": "BE" },
    "consignee": { "eori": "GB343475355000", "name": "Farmers Boy", "street_and_number": "Greenside Park", "postcode": "BD8 9RU", "city": "Bradford", "country": "GB" }
  },
  "transport": { "mode_at_border": 3, "border_means_id": "42BFG4 FT2268", "border_means_nationality": "BE" },
  "customs_offices": { "export": "BE343000", "exit": "BE343000" },
  "authorised_location_of_goods": { "location_code": "BECUIZG000015" },
  "totals": { "gross_mass": 21690.262, "net_mass": 21120.0, "packages": 22 },
  "supporting_documents": [
    { "type": "N935", "reference": "2341-6054-00", "date": "2026-06-19" }
  ],
  "transport_documents": [
    { "type": "N730", "reference": "T260776211", "date": "2026-06-16" }
  ],
  "additional_references": [
    { "type": "Y923" }
  ],
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Margarine, baker's pastry",
      "commodity_code": "15171090",
      "country_of_origin": "EU",
      "requested_procedure": "31",
      "previous_procedure": "51",
      "gross_mass": 11831.052,
      "net_mass": 11520.0,
      "statistical_value": 16524.77,
      "total_price": 14588.07,
      "packages": [{ "type": "CT", "quantity": 11, "shipping_marks": "VDM" }],
      "currency": "EUR",
      "undg_numbers": ["1263"]
    }
  ]
}

What this produces: one EX dossier with RequiresReview = true, its declaration prefilled from the payload, awaiting a declarant. Nothing is filed automatically unless auto_dispatch says so — see below.

Notes on the shape

  • declaration_type is the only field with no sensible default. EX, IM, T1 or T2.
  • Documents go in the list named for what they are — supporting_documents (CL213 and the Belgian national codes), additional_references (CL380 Y-codes), transport_documents (CL754), authorisations. There is no category field: the list is the category, and it is named after the element the declaration ends up with, using the same words as the AES and NCTS schemas.
  • Send what you have and nothing more. Absent is not the same as empty: a field you omit is one the declarant completes, and a field you send as "" is a value they have to notice and clear.
  • Codes are the published ones. country_* are ISO2, commodity_code is 8 digits on export and 10 on import, procedures are the two-digit UCC codes, mode_at_border is the one-digit CL018 code (3 road, 4 air, 1 sea) rather than the two-digit 30/40 some systems use.
  • region_of_dispatch is the region within the country of dispatch — Belgium numbers its three 1, 2, 3; France uses the department. Filed as originRegionCode on every export goods item, so a sender that states it saves the declarant retyping it.
  • A goods item's currency is only for a line priced differently from the shipment. Omit it and the shipment's currency is used, which is what every sender means today.
  • authorised_location_of_goods takes either location_code (a national customs location code such as BECUIZG000015) or un_locode. The code wins where both are sent. Sending neither means our own company default is declared, which is rarely what a sender wants.
  • Money and mass are numbers, not strings — 26840.79, never "26840,79". A decimal comma in a JSON string is how a shipment came to be declared at 100x its weight on another path.
  • A large body may be gzipped — set ContentEncoding: gzip and compress the bytes. A Delta Light pro-forma with its PDF inside is 100 KB for two items; a Standard-tier queue holds 256 KB. The receiver decompresses before it looks at the content type.
  • XML bodies work identically — set ContentType: application/xml and send the same structure with PascalCase element names against schemas/canonical/declaration.xsd. List elements are named in the singular inside a plural wrapper (<GoodsItems><GoodsItem>, <UndgNumbers><UndgNumber>); getting that wrong deserialises to an empty list rather than an error.

An import message, end to end

The third regime. Shaped like the import dossiers that are actually filed — a commercial invoice, per-item supplementary units, and the Y-codes and reference numbers that two thirds of real lines carry.

{
  "declaration_type": "IM",
  "ucr": "TUF/2026/00873",
  "client_code": "ACME01",
  "shipment": {
    "invoice_number": "INV-2026-0417",
    "invoice_date": "2026-08-14",
    "currency": "USD",
    "exchange_rate_to_eur": 1.0842,
    "total_invoice_amount": 84210.5,
    "incoterm": "FOB",
    "incoterm_location": "Shanghai",
    "country_of_dispatch": "CN",
    "country_of_destination": "BE"
  },
  "parties": {
    "seller":   { "name": "Shanghai Footwear Co Ltd", "country": "CN" },
    "buyer":    { "name": "ACME Retail NV", "country": "BE" },
    "importer": { "eori": "BE0123456789", "name": "ACME Retail NV", "country": "BE" }
  },
  "customs_offices": { "import": "BE343000" },
  "authorised_location_of_goods": { "location_code": "BECUIZG000015" },
  "totals": { "gross_mass": 8420.0, "net_mass": 7960.0, "packages": 640 },
  "costs": [
    { "code": "AK", "amount": 1240.13, "currency": "EUR" },
    { "code": "CA", "amount": 1437.0, "currency": "EUR" }
  ],
  "supporting_documents": [
    { "type": "N935", "reference": "INV-2026-0417", "date": "2026-08-14" }
  ],
  "transport_documents": [
    { "type": "N705", "reference": "MSCU7712334", "date": "2026-08-16" }
  ],
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Mens footwear, leather uppers",
      "commodity_code": "6403991100",
      "country_of_origin": "CN",
      "preferential": false,
      "gross_mass": 4210.0,
      "net_mass": 3980.0,
      "total_price": 42105.25,
      "quantity": 1296,
      "unit": "PRS",
      "supplementary_unit": "NPR",
      "supplementary_quantity": 1296,
      "packages": [{ "type": "CT", "quantity": 108 }],
      "additional_references": [
        { "type": "Y923" },
        { "type": "Y128", "reference": "CBAM-BE-000123" }
      ],
      "supporting_documents": [
        { "type": "4025", "reference": "2018/C/9" }
      ]
    }
  ]
}

Notes on the import shape

  • packages is the package count, not the invoiced quantity. A real line reads 1296 pairs against 108 cartons: two different numbers with two different units. Send the cartons here, the pairs in quantity with unit (PRS), and supplementary_quantity where the tariff asks for one. Leave unit out where your quantity column can only mean pieces — the app then reads it as a count of articles and prefills a NAR supplementary quantity from it.
  • additional_references on a goods item are the Y-codes, with a reference where the code needs a number — a CBAM account for Y128, say. Two thirds of real import lines carry one.
  • supporting_documents on a goods item are the Belgian national codes (4025, 3001, 3040…). The EU certificate space is derived from the tariff per commodity and country, so it is not sent: a code picked by hand there would compete with what the tariff already selected.
  • previous_document_item is the item of the declaration's previous document that the line writes off (IDMS goodsItemIdentifier, 1–99999). The document itself is one per declaration and is entered on the Declare page. When the lines carry it, every article is written off against its own item with its own gross weight. Send it on every line or on none: leaving it out keeps the usual write-off, article 1 plus 1VDP. Not declaration_goods_item_number, which is your own item number.
  • costs are the header-level coded costs — AK air and insurance, CA transport — which the declaration apportions over the items. Where the customer supplies the figures rather than us moving the goods, these are what gets declared.
  • exchange_rate_to_eur is the rate for the whole shipment. There is one, so an invoice currency and a transport-cost currency that differ cannot both be converted; send costs in EUR where you can.

A transit message, end to end

The T1/T2 equivalent, shaped like the real Navision NCTS traffic. Everything in it is something those messages already carry.

{
  "declaration_type": "T2",
  "ucr": "TUF/2026/00412",
  "security_code": 0,
  "binding_itinerary": false,
  "limit_date": "2026-09-20",
  "parties": {
    "transit_principal": {
      "eori": "BE0412127994",
      "name": "TRANSUNIVERSE FORWARDING NV",
      "street_and_number": "Skaldenstraat 60",
      "postcode": "9042",
      "city": "Gent",
      "country": "BE",
      "contact_name": "Griet",
      "contact_phone": "+32 9 255 55 55",
      "contact_email": "griet@transuniverse.be"
    },
    "consignee": {
      "name": "Empfaenger AG", "street_and_number": "Rheinweg 4",
      "postcode": "4057", "city": "Basel", "country": "CH",
      "contact_name": "Ueli", "contact_email": "ueli@empfaenger.ch"
    }
  },
  "shipment": { "country_of_dispatch": "BE", "country_of_destination": "CH" },
  "customs_offices": { "departure": "BE312000", "destination": "CH001234", "transit": "FR002400" },
  "authorised_location_of_goods": { "location_code": "BECUIZG000015" },
  "loading_location": { "loading_place": "9032", "loading_place_code": "BEWDG", "loading_place_additional_code": "BE" },
  "authorisations": [
    { "type": "C521", "reference": "BEACRG00041" }
  ],
  "guarantees": [
    { "sequence_number": 1, "type": "1", "grn": "26BE0000000123456", "access_code": "1234", "amount": 10000, "currency": "EUR", "pot_customs_debt": "2" }
  ],
  "transport": {
    "departure_means_id": "1-ABC-123", "departure_means_type_code": "30", "departure_means_nationality": "BE",
    "route": ["BE", "FR", "CH"]
  },
  "totals": { "gross_mass": 1500.25 },
  "goods_items": [
    {
      "sequence_number": 1,
      "description": "Machine parts",
      "commodity_code": "870829",
      "gross_mass": 1500.25,
      "net_mass": 1420.0,
      "packages": [{ "type": "CT", "quantity": 15, "shipping_marks": "MARK-001" }]
    }
  ]
}

Notes on the transit shape

  • declaration_type decides the previous document. A goods item that names none is declared against N821 on a T1 and N822 on a T2 — external and internal transit respectively. Send previous_documents on the item where the movement discharges something specific.
  • parties.transit_principal is the holder of the transit procedure, the party liable for the movement and the one whose guarantee covers it. Omit it and our own declarant details are filed, which is right only when we are the holder.
  • customs_offices.departure — omitted, the dossier's customs team decides, then a global default. Send it and it is filed as sent.
  • loading_location is where the goods were loaded. Omitted, our own quay is declared, which is wrong for a movement that started anywhere else.
  • authorisations are filed as sent, with one exception: the C521 authorised-consignor reference comes from our own location register for the place declared, because that authorisation covers specific approved places. Send it anyway — a disagreement is logged and worth knowing about.
  • guarantees[].access_code and pot_customs_debt are filed with the guarantee they belong to. Where our own GRN is configured, our own guarantee and its access code are filed instead: an access code opens one guarantee and no other, so the two always travel together.
  • security_code is 0 none, 1 ENS and EXS, 2 ENS, 3 EXS — required by the IE015 and IE515 mappings, and honoured on export too. Absent keeps our configured value, which is why it is nullable: 0 is a statement and absent is not.
  • binding_itinerary is required by the same mapping, which also gives the default absent keeps: 0.
  • limit_date (yyyy-MM-dd) is the deadline for presenting the goods at destination. Absent means eight days out — the same rule the sending system already applies, so sending it and omitting it produce the same declaration. A value that is not a date is ignored rather than filed.
  • A limit date is only read from a message. A dossier drafted from a scanned TAD stores one too, and there it is the deadline on the movement being discharged — already partly elapsed. The two are told apart by where the dossier came from, never by the field alone.

Rejected vs. retried

A message that can never succeed as sent — no Partner, an unknown MessageType, a body that is neither XML nor JSON, malformed XML or JSON, auto_dispatch on an import, a masterdata row that does not fit — is dead-lettered immediately with the reason in DeadLetterErrorDescription. Only transient failures are abandoned for redelivery. Retrying something structurally wrong just burns MaxDeliveryCount and buries the actual complaint.

ContentType: application/json (default) or application/xml. Set it as the message's own content-type or as an application property — the receiver reads both, so use whichever your platform exposes. When neither is set it auto-detects by checking whether the body starts with <.

Automatic dispatch

A declaration can ask CustomsHive to file it without a declarant reviewing it first, either as the body field auto_dispatch or as the AutoDispatch application property.

{
  "declaration_type": "T1",
  "auto_dispatch": true,
  "...": "..."
}

Transit only (T1/T2). Sending it on any other declaration type is rejected, not ignored: a sender who set the field believes their imports will be filed automatically, and dropping it silently leaves them believing it. An automatically filed import is not something anyone wants to discover afterwards.

Absent means no automatic dispatch. When it is set:

  1. The dossier is created as normal, then approved on the message's authority and filed.
  2. It goes to ServiceBus:Descartes:AutoDispatchEnvironment (default Pr). If that environment is not configured, nothing is filed — a filing that guessed its target is worse than one that did not happen.
  3. Schema validation is never forced past. A declaration our SMF check rejects goes back to a declarant instead, left Approved-and-unsent. "Send it anyway" is a judgement a person makes.
  4. A dossier that has already been queued once is never sent again, so a redelivered message cannot declare the same shipment twice.
  5. It files only what the message delivered. A message whose UCR already exists is a replay: the existing dossier is returned and nothing in it changes. Such a message has a dossier filed only when that dossier was created from a message of the same Partner, and only when this message is that same message, byte for byte (a redelivery). A dossier a declarant made by hand is never filed because a message named its number, and a corrected message under the same UCR is refused with the reason: send a correction under a new UCR, or ask a declarant to correct the dossier.
  6. The audit trail records AutoDispatched — why it went out automatically, which is a different fact from that it went out, and the first question anyone asks after a bad automatic filing. A refusal is recorded as AutoDispatchRefused, with the reason.

Over REST, the response body carries an autoDispatch field (dispatched, not requested, or refused: …) so a caller learns a refusal rather than assuming the shipment is filed.


Flow

flowchart TD
    SB["ServiceBus\ncanonical queue"]
    RCV["CanonicalServiceBusReceiverService\npeak-lock / complete"]
    IS["ICanonicalIngestionService"]
    DB[(Dossier\nSource=ServiceBusIngestion)]

    SB -->|JSON or XML body| RCV
    RCV -->|CanonicalDeclaration + Partner| IS
    IS --> DB

Inbound — client masterdata

Business Central owns the client list; CustomsHive owns what it takes to declare for one. A masterdata push is a message on the same queue with MessageType: Client.

JSON body:

{
  "clients": [
    {
      "code": "ACME",
      "name": "Acme NV",
      "identification_number": "BE0123456789",
      "street_and_number": "Havenlaan 1",
      "postcode": "2000",
      "city": "Antwerpen",
      "country": "BE",
      "blocked": " "
    }
  ]
}

Schemas: client.schema.json (JSON) and client.xsd (XML, root <CanonicalClientBatch>). A single client without the batch envelope is accepted too. The same body can be POSTed to /api/v1/clients.

Blocked, and why there is no delete

Business Central does not delete customers — it blocks them, often for long stretches — so blocked carries the whole retirement story and there is no DEL message type.

Send BC's value verbatim. Blank, absent, or the single space BC actually sends means not blocked; Ship, Invoice, All — and anything else — mean blocked. An unrecognised status reads as blocked rather than as active on purpose: a new BC status must not quietly leave retired customers selectable. The raw value is stored beside the flag, so Invoice and All can be told apart later without another full push.

A blocked client leaves the dossier picker. It also drops off the Clients screen — unless it is a customs client, where it stays listed and marked inactive: somebody who files declarations for that client needs to see it was retired, not find it silently gone.

Blocking is reversible. Send the client again without a block status and it is active once more.

Who owns which field

Business Central owns CustomsHive owns
code, name Email
street_and_number, postcode, city, country, blocked Default procedure, C503 auth ref, FR1 (BTW importeur), Ref 4007, ET14000
CBAM account (Y128), air transport rate (€/kg) and minimum (€)
EORI (identification_number) — see below

identification_number is accepted but is not a field Business Central actually holds. This table listed it as BC-owned until 2026-09-30; a sending system that has EORIs may still send it, and it will be applied. But where BC holds an enterprise or VAT number rather than an EORI, send nothing: an absent field leaves the declarant's value alone, and that is the intended outcome. Do not derive an EORI from an enterprise or VAT number to fill it — the conventions differ per country and a derived identifier on a filed declaration is worse than an empty one.

The customs fields are not on the wire at all, so a nightly re-import cannot undo what a declarant configured.

A field you do not send is left alone — it is not cleared. Absent means "no opinion", not "make it empty", and an empty string counts as absent. Only send a column when you mean to set it.

That rule exists because the alternative is a wrong declaration rather than a cosmetic loss: an import declaration files the client's EORI as both importer and declarant, falling back to the company EORI when the client has none. A push that blanked the EORI would silently move a client's declarations onto Trans Universe's own — with nothing to validate against and nothing to notice.

The one exception is blocked, and it has to be: BC spells "not blocked" as a space, which trims to nothing, so absent and not-blocked are the same message. Reading absent as "leave it" there would make a blocked client impossible to un-block.

Customs clients

An imported client is not a customs client: BC's list is everyone the business invoices, and only a subset ever clears customs. /Clients therefore has two tabs — Customs clients (the set the dossier client picker offers) and All clients, where a client is promoted onto the first. Promotion is one-way, and a re-import never demotes.

A client auto-created by an incoming declaration is a customs client immediately: a declaration quoting a client is proof they clear customs, and Qargo and Navision bill the same masterdata set.

Rules

  • Upsert on code. Rows that differ from what is stored are updated; identical rows are left alone, so a nightly full push does not stamp every client with today's date.
  • All or nothing, per message. A batch containing an unusable row is rejected whole — a half-applied masterdata push leaves nobody able to say which half landed. Which is why a full export is chunked: see below.
  • Send a full export in chunks of about 1,000 clients, gzipped. A Standard-tier queue holds 256 KB per message and a full customer list is several megabytes; 16,000 clients is roughly sixteen messages. Set ContentEncoding: gzip — the receiver decompresses on the 1F 8B signature either way. Because validation is per message, a rejected chunk is resent on its own rather than restarting the whole export.
  • Overlong values are rejected, not truncated, and the response names the field, the row and both lengths. A truncated EORI is a valid-looking wrong answer that reaches a customs authority.
  • Every complaint is returned at once. One error per round trip does not scale to 4,000 rows.
  • An import that changed something writes one ClientMasterdataImported audit row; one that changed nothing writes none.

Notes

  • Peek-lock is used on the inbound queue so a crash before Complete() automatically re-queues the message.
  • Auto-dispatch is a property of the message, not a setting — see the section above.
  • The canonical queue accepts any sender that has the Partner application property set — Qargo, VDM, or any future integration simply publishes to the same queue.
  • Connectivity probe: /Admin/IngestionSettings → Run peek probe checks reachability of the inbound queue without consuming messages.