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

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 or Client. 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.
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.

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 two message types.

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 aresupporting_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 strings26840.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,
      "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 and the pairs as supplementary_quantity where the tariff asks for one; the commercial quantity itself has no field yet and the declarant supplies 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.
  • 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": "[email protected]"
    },
    "consignee": {
      "name": "Empfaenger AG", "street_and_number": "Rheinweg 4",
      "postcode": "4057", "city": "Basel", "country": "CH",
      "contact_name": "Ueli", "contact_email": "[email protected]"
    }
  },
  "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, 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 message property: application/json (default) or application/xml. When absent, the receiver auto-detects by checking if 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. The audit trail records AutoDispatchedwhy 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"
    }
  ]
}

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.

Who owns which field

On re-import, Business Central's columns are overwritten and CustomsHive's are never touched:

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

EORI lives in Business Central and follows it. Email does not. The customs fields are not on the wire at all, so a nightly re-import cannot undo what a declarant configured.

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. A batch containing an unusable row is rejected whole — a half-applied masterdata push leaves nobody able to say which half landed.
  • 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.

Outbound — Descartes queue

Configured under ServiceBus:Descartes:*. Submission (SubmitDeclarationService) writes an OutboxMessages row in the same transaction as the dossier's Submitted transition; OutboxDispatcher (in CustomsHive.Module.Dispatch) polls every 5 s and sends each pending row through DescartesServiceBusSenderService, retrying with capped backoff and never dropping. A row that has failed five times turns /health's descartes-outbox check Unhealthy.

Exactly once, and what guarantees it

Three things, in order of how much they carry:

  1. The row is written in the submission transaction. A dossier cannot reach Submitted without a pending outbox row, and a row cannot exist for a submission that rolled back.
  2. A delivered message is marked before anything else is touched. The send is followed immediately by a single-row ExecuteUpdate in its own context; the dossier's delivery timestamps are mirrored afterwards, in a write that can only log on failure. Before this, all of it committed together — and a rowversion conflict on the dossier put a message the broker already had back into the retry loop.
  3. The queue's duplicate detection is the backstop, for the one case left: the send succeeds and the mark cannot be written. Every send carries the outbox row's MessageId as the broker MessageId, so a resend inside the detection window is discarded by the broker rather than delivered twice.

Confirm on the queue: customstuf-to-descartes must have RequiresDuplicateDetection enabled, with a window comfortably longer than the 5-minute maximum retry backoff. Without it, point 3 does not exist and the only protection against a double declaration is point 2 — which covers the failure that has actually happened, but not a process kill between the two writes.

Configuration

Every child of ServiceBus:Descartes that carries a namespace is an environment; there is no ActiveEnvironment on the outbound side — the declarant picks Dev or Pr when submitting (Dev is restricted to administrators), and AutoDispatchEnvironment names the target for auto_dispatch messages.

"ServiceBus": {
  "Descartes": {
    "Dev": { "FullyQualifiedNamespace": "tuf-sb-dev.servicebus.windows.net",  "QueueName": "customstuf-to-descartes" },
    "Pr":  { "FullyQualifiedNamespace": "tuf-sb-prod.servicebus.windows.net", "QueueName": "customstuf-to-descartes" },
    "AutoDispatchEnvironment": "Pr",
    "CompressionEnabled": true,
    "MessageProperties": {
      "FlowType": "Transuniverse.Descartes.CustomsTUF.OutboundXML",
      "Partner": "Descartes-CustomsTUF"
    }
  }
}

Message format

The body is the SMF envelope itself as application/xml — not a JSON wrapper. Over 200 KB it is gzip-compressed and ContentEncoding=gzip is set. Everything else travels as application properties:

Property Value
MessageId {Ucr}-{yyyyMMddHHmmss} — one per submission attempt; the outbox row's MessageId, unique in the database
FlowType from MessageProperties:FlowType
MsgType OutboundXML
Partner from MessageProperties:Partner
FileName e.g. SMF_CC015C_TUF2026001_20260518120000.xml
TransactionId same value as MessageId
ContentEncoding gzip, only when compressed

Whether the queue has duplicate detection enabled on MessageId, and with what window, is not recorded here yet — backlog item B-03 verifies and documents it.

Downstream: Azure Logic App (prc-descartes-xml)

A Logic App in the Azure tenant triggers on this queue and handles delivery. Steps:

  1. ReceiveCustomsFileForDescartes — queue trigger, reads the message
  2. ContextProps — extracts message properties (filename, message type, etc.)
  3. Try Process XML — decompresses / validates the payload (if applicable)
  4. HTTP LogRcv — logs receipt to an internal endpoint
  5. ArchiveFile — saves a copy to archive storage
  6. UploadFile — uploads the XML file via SFTP to the customs authority

The file is delivered as { "$content-type": "application/xml", "$content": "<base64>" } using the filename from the message payload.


Full flow diagram

flowchart TD
    subgraph External["External systems (e.g. Qargo, VDM)"]
        Q["Logistics platform\n(per trip / shipment)"]
    end

    subgraph ASB_IN["Azure Service Bus — inbound"]
        MSG_IN["CanonicalDeclaration\n(JSON or XML)"]
    end

    subgraph CSHIVE["CustomsHive"]
        RCV["CanonicalServiceBusReceiverService"]
        IS["ICanonicalIngestionService\nclient resolution + adapter routing"]
        DB[(Database\ndossiers, correctedData)]
        GEN["XML Generators\nCC015C / IE415B / CC515C"]
    end

    subgraph ASB_OUT["Azure Service Bus — outbound"]
        MSG_OUT["SMF XML\n(base64-encoded)"]
    end

    subgraph DESC["Azure Logic App — prc-descartes-xml"]
        LA_RCV["ReceiveCustomsFileForDescartes\n(queue trigger)"]
        LA_CTX["ContextProps"]
        LA_XML["Try Process XML\n(decompress / validate)"]
        LA_LOG["HTTP LogRcv"]
        LA_ARC["ArchiveFile"]
        LA_UPL["UploadFile\n(SFTP)"]
    end

    Q -->|"publish CanonicalDeclaration\n+ Partner property"| MSG_IN
    MSG_IN -->|peek-lock / complete| RCV
    RCV --> IS
    IS -->|create dossier\nSource=ServiceBusIngestion| DB
    DB -->|user triggers or auto-dispatch| GEN
    GEN -->|SMF XML| MSG_OUT
    MSG_OUT --> LA_RCV
    LA_RCV --> LA_CTX --> LA_XML --> LA_LOG --> LA_ARC --> LA_UPL

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/IngestionSettingsRun peek probe checks reachability of the inbound queue without consuming messages.