API Reference
This reference is for integrators. All endpoints live under
https://api.businesscentral.dynamics.com/v2.0/{tenant}/{environment}/api/altenbrand/mcIntegration/v1.0/companies({companyId})/
| Endpoint | Method | Purpose |
|---|---|---|
labelRequests |
POST, GET | Request a label, read the outcome |
shipmentLabels |
GET | Read labels (no documents, no addresses) |
shipmentLabels({id})/Microsoft.NAV.cancel |
POST | Cancel the shipment at the carrier |
shipmentLabels({id})/Microsoft.NAV.refreshTracking |
POST | Ask the carrier for the delivery status |
shipmentLabelDocuments({id}) |
GET | Label, return label, customs document, COD label as Base64 |
shipmentTrackingEvents |
GET | Read the tracking history: every scan the carrier reported |
shipmentPackages |
GET | The parcels of a multi-parcel shipment, one tracking number per carton |
shipmentProviders |
GET | Read the shipment providers and what they support |
shipmentProviders({id})/Microsoft.NAV.getProducts |
POST | Read the products of a provider |
shipmentProviders({id})/Microsoft.NAV.getLabelArchive |
POST | Labels and customs documents of many orders as one ZIP |
shipmentProviders({id})/Microsoft.NAV.closeOutDay |
POST | Close the day at the carrier, with its document |
shipmentProviders({id})/Microsoft.NAV.getManifestDocument |
POST | The document of an earlier close-out |
shipmentProviders({id})/Microsoft.NAV.validateSalesOrder |
POST | Check a document up front, without a label and without calling the carrier |
Request instead of direct write
Labels are never inserted or modified through the API. The client creates a request; the app validates it, creates the label through the hub and writes the outcome back to the request. This keeps licence check, shipping rules and tracking out of reach of any client.
Requesting a label
POST …/labelRequests
Content-Type: application/json
{
"externalReference": "WMS-2026-000481",
"source": "WMS",
"salesOrderNo": "SO-2026-01187",
"providerCode": "DHL",
"weightGrams": 1850,
"mode": "Sync",
"servicesJson": "[{\"code\":\"AGE_CHECK\",\"parameter1\":\"A18\"},{\"code\":\"INSURANCE\",\"parameter1\":\"2500.00\",\"parameter2\":\"EUR\"}]"
}
{
"id": "5f0c…",
"entryNo": 42,
"status": "Completed",
"shipmentLabelId": "b71e…",
"shipmentLabelEntryNo": 10342,
"trackingNo": "00340434161094015902",
"errorMessage": ""
}
Request fields
| Field | Required | Meaning |
|---|---|---|
externalReference |
yes | Reference of the calling system, unique together with source |
source |
recommended | Identifier of the system, e.g. WMS or SHOP |
salesOrderNo / salesShipmentNo |
exactly one | Sales order or posted sales shipment |
providerCode |
no | Shipment provider; empty = shipping agent of the document, default provider or the only enabled one |
labelType |
no | Outbound (default) or Return for a return label |
weightGrams |
no | Parcel weight; 0 = calculated document weight |
productCode |
no | Shipping agent service code that selects the carrier product |
mode |
no | Sync or Async; empty = default mode of the setup |
servicesJson |
no | JSON array of additional services, see below |
packagesJson |
no | JSON array of the parcels of a multi-parcel shipment, see below. Send only; read the parcels back through shipmentPackages |
customsJson |
no | Customs declaration, see below. Send only |
shipToName … shipToPhone |
no | Replace the ship-to address of the document |
lengthCm, widthCm, heightCm |
no | Parcel dimensions |
Return label
POST .../labelRequests
{
"externalReference": "SHOP-RET-4711",
"source": "SHOP",
"salesOrderNo": "SO-2026-01187",
"providerCode": "DHL",
"labelType": "Return"
}
A return label is built from the document alone. Weight, product code, services, dimensions and a
ship-to override are not applied but rejected with an error: a silently ignored field would be
worse than a clear refusal. The guard "one open label per document" does not apply, because a
return label belongs to a document that already carries its outbound label. The provider must
support returns, which supportsReturnLabels of the shipmentProviders endpoint tells you. The
return shipment number comes back in trackingNo, the document as returnLabelBase64.
Additional services
servicesJson is a JSON array of objects with code, parameter1 and parameter2. The codes
are carrier-specific and come from the respective connector. A connector evaluates the field only
if it subscribes to the hub event OnApplyLabelServiceSelections. If it does not,
servicesJson is silently ignored: the request ends with Completed, but the label does not
carry the service.
| Carrier | providerCode |
Evaluates servicesJson |
Codes |
|---|---|---|---|
| DHL Paket | DHL |
yes | 17 codes |
| DPD (Webservice) | DPD |
yes | 6 codes |
| DPD Austria | DPDAT |
yes | 2 codes |
| Rhenus | RHENUS |
yes | 2 codes: fixed date, Avise |
| GLS | GLS |
yes | 8 codes |
| UPS | UPS |
yes | 5 codes |
| Hermes | HERMES |
no | no services per label |
| DHL Express | DHLX |
no | no services per label |
Parameters are limited to 50 characters each. A code the connector does not know, a missing mandatory parameter or a parameter in the wrong format end the request with an error; no label is created with a service silently missing.
Amounts: DHL, GLS and UPS expect the point as decimal separator (2500.00). DPD and DPD Austria read the
amount in the number format of the session, that is the language of the Microsoft Entra
application. Create the application with language English and send 2500.00; a German session
would read 2500.00 as 250000.
Multi-parcel shipments
When a shipment travels in several cartons, packagesJson describes them. The carrier then issues
one tracking number per carton.
POST .../labelRequests
{
"externalReference": "WMS-2026-000481",
"source": "WMS",
"salesOrderNo": "SO-2026-01187",
"providerCode": "DHL",
"packagesJson": "[{\"weightGrams\":2500,\"lengthCm\":40,\"widthCm\":30,\"heightCm\":20,\"packageContent\":\"Books\",\"referenceNo\":\"WMS-4711-1\",\"lines\":[{\"salesLineNo\":10000,\"itemNo\":\"1000\",\"quantity\":2}]},{\"weightGrams\":1800}]"
}
| Field per parcel | Required | Meaning |
|---|---|---|
weightGrams |
yes | Weight of this carton |
lengthCm, widthCm, heightCm |
no | Dimensions of this carton |
packagingMaterialCode |
no | Packaging material |
packageContent |
no | Description of the content |
referenceNo |
no | Reference of the calling system for this carton |
lines |
no | What is in this carton: salesLineNo, itemNo, quantity |
The order in the array is the parcel number: the first entry becomes parcel 1.
packagesJson is a write field. Reading a request returns it empty; the outcome lives in
shipmentPackages, with the tracking number of every carton.
Weight and dimensions belong to the parcels
For a multi-parcel request the hub derives the weight and the dimensions of the shipment from
the parcels. weightGrams, lengthCm, widthCm and heightCm on the request itself are
therefore rejected rather than silently overruled. A return label takes no parcels.
The provider must support multi-parcel shipments, which supportsMultiColli of the
shipmentProviders endpoint tells you. Otherwise the request ends with status = Error and a
readable reason.
Reading the parcels
GET .../shipmentPackages?$filter=shipmentLabelEntryNo eq 801
Fields: id, entryNo, shipmentLabelEntryNo, packageNo, providerCode, salesOrderNo,
salesShipmentNo, trackingNo, trackingUrl, shipmentNo, weightGrams, lengthCm, widthCm,
heightCm, packagingMaterialCode, packageContent, referenceNo, isReturn,
createdDateTime.
shipmentLabels shows the shipment as a whole; the tracking number per carton lives here only.
The tracking history also keeps its events per carton, through trackingNo.
Customs data
For shipments outside the customs union merchantCENTRAL builds the customs declaration from the source document. When the calling system keeps its own customs data, or the document holds none, it brings the declaration along:
POST .../labelRequests
{
"externalReference": "SHOP-2026-0815",
"salesOrderNo": "SO-2026-01187",
"providerCode": "DHL",
"shipToCountryCode": "CH",
"customsJson": "{\"exportType\":\"Commercial Goods\",\"exportDescription\":\"Books\",\"postalChargesAmount\":12.5,\"postalChargesCurrency\":\"EUR\",\"items\":[{\"itemNo\":\"1000\",\"description\":\"Book\",\"countryOfOriginCode\":\"DE\",\"hsCode\":\"49019900\",\"quantity\":2,\"unitPrice\":19.99,\"currencyCode\":\"EUR\",\"netWeightGrams\":500,\"grossWeightGrams\":550}]}"
}
| Header field | Meaning |
|---|---|
exportType |
Other, Present, Commercial Sample, Document, Return of Goods, Commercial Goods |
exportDescription |
Description of the export |
postalChargesAmount, postalChargesCurrency |
Postage for customs clearance |
permitNo, attestationNo, mrn |
Permit, attestation, movement reference number |
shipperCustomsRef, consigneeCustomsRef |
Customs references of shipper and consignee |
electronicExportNotification |
Electronic export notification |
| Field per item | Required | Meaning |
|---|---|---|
description |
yes | Description of the goods |
quantity |
yes | Quantity |
itemNo |
no | Item number |
countryOfOriginCode |
no | Country of origin |
hsCode |
no | Commodity code |
unitPrice, currencyCode |
no | Unit price and currency; the line amount is calculated |
netWeightGrams, grossWeightGrams |
no | Net and gross weight |
Replace, do not mix
Items you send replace the ones derived from the document. A mixture of both would be a declaration nobody vouches for. Header fields you leave empty keep the value from the document: an empty field means "unchanged", not "clear it".
A return label takes no customs declaration. An item without a description or without a quantity is refused rather than handed to the carrier as an incomplete declaration.
Behaviour
- Sync answers with the final outcome, typically one to three seconds.
- Async answers immediately with
status = Pending; the client reads the request later viaGET …/labelRequests({id})or?$filter=externalReference eq '…'. - Input errors (missing reference, unknown document, invalid JSON, duplicate reference) return HTTP 400. The request is not stored.
- Guards (module not active, provider not resolvable, label exists, hourly limit) store the
request with
status = Errorand a readableerrorMessage. - Carrier errors store the request with
status = Error;errorMessagecarries the core carrier message,shipmentLabelEntryNopoints to the failed label.
Status values
| Status | Meaning |
|---|---|
Pending |
Waiting for the job queue |
Processing |
Being processed |
Completed |
Label created, trackingNo filled |
Error |
Rejected or failed at the carrier, see errorMessage |
Cancelled |
Cancelled before processing |
Reading labels
GET …/shipmentLabels?$filter=salesOrderNo eq 'SO-2026-01187'
GET …/shipmentLabels({id})
Fields: id, entryNo, providerCode, salesOrderNo, salesShipmentNo, status,
shipmentNo, trackingNo, trackingUrl, labelFormat, weightGrams, productCode,
referenceNo, deliveryStatus, lastTrackingUpdate, createdDateTime, cancelledDateTime,
errorMessage.
Documents
GET …/shipmentLabelDocuments({id})
Returns labelBase64, returnLabelBase64, customsDocBase64 and codLabelBase64. The format
is in labelFormat (PDF or ZPL). Only documents stored in Business Central are returned; a label
that was never stored comes back empty.
Actions
POST …/shipmentLabels({id})/Microsoft.NAV.cancel
POST …/shipmentLabels({id})/Microsoft.NAV.refreshTracking
cancel cancels at the carrier and answers true. refreshTracking asks the carrier and answers
with the new delivery status as text. Both actions check that the module is active and licensed.
Do not poll tracking in a tight loop
The carriers grant only a limited number of tracking calls per day and key, DHL for example 250.
The scheduled tracking of the connector draws on the same budget. refreshTracking is therefore
rate limited: Max Tracking Refreshes per Hour in the setup, 60 by default, 0 disables the
guard. Once the window is exhausted the action fails without asking the carrier.
For a running status feed the business event DeliveryStatusChanged is the right way. It
reports every change by itself and costs no tracking call at all.
Providers, products and pre-flight validation
GET .../shipmentProviders
GET .../shipmentProviders?$filter=enabled eq true
Fields: id, code, name, enabled, status, shippingAgentCode, defaultServiceCode,
defaultLabelFormat, supportsReturnLabels, supportsCancellation, supportsTracking,
supportsCustoms, supportsMultiColli, supportsManifesting, moduleCode, connectorVersion.
An external system can thus answer for itself which providers it may address and what each of them can do, instead of hard-coding codes.
Products
POST .../shipmentProviders({id})/Microsoft.NAV.getProducts
Returns [{"code":"V01PAK","name":"DHL Paket"}, ...]. A code from there fits the productCode
field of a request, so an external system does not have to hard-code product codes.
The codes of the value added services for servicesJson still come from the documentation of
the respective connector, see the table under Additional services.
Bulk archive
When several orders ship at once, one call fetches all their documents as a single ZIP file:
POST .../shipmentProviders({id})/Microsoft.NAV.getLabelArchive
{
"salesOrderNosJson": "[\"SO-2026-01187\",\"SO-2026-01188\"]",
"onlyNotExported": true,
"markAsExported": true
}
{
"documentCount": 5,
"skippedCount": 0,
"markedCount": 4,
"fileName": "ShipmentLabels_20260909_144311.zip",
"archiveBase64": "UEsDBBQ…"
}
The archive holds the shipping label, return label, COD label, customs document and QR code of every shipment, and one shipping label per carton for multi-parcel shipments. File names carry the tracking number.
| Field | Meaning |
|---|---|
documentCount |
Number of documents in the archive |
skippedCount |
Shipments that held no document; counted, not an error |
markedCount |
Shipments stamped as fetched |
fileName |
Suggested file name with a timestamp |
archiveBase64 |
The ZIP file, Base64 encoded; empty when nothing was collected |
Only shipments with status Created or Manifested are collected, and only those of the provider being addressed. A sales order named twice counts once.
Collecting in batches
| Switch | Effect |
|---|---|
onlyNotExported |
Takes only shipments whose documents were never handed out |
markAsExported |
Stamps the shipments of the finished archive as fetched |
Both together give the usual routine: every run collects what has appeared since the last one. A second call with the same list of orders then comes back empty.
The stamp is set only once the archive exists. A run that hands out nothing does not claim its shipments were fetched.
The stamp is a filter, not a lock: without onlyNotExported documents that were already fetched
come again. When a shipment was last handed out is shown as lastExportedDateTime on the
shipmentLabels endpoint.
At most a hundred orders per call
Base64 grows the answer by a third. Past a hundred orders the call fails with a readable message instead of quietly shortening the list. Ask for larger runs in batches.
Pre-flight validation
POST .../shipmentProviders({id})/Microsoft.NAV.validateSalesOrder
{ "salesOrderNo": "SO-2026-01187" }
POST .../shipmentProviders({id})/Microsoft.NAV.validateSalesShipment
{ "salesShipmentNo": "PS-2026-00912" }
Response:
{ "valid": false, "errors": ["Ship-to postal code is missing.", "The parcel weight exceeds 31.5 kg."] }
The check creates nothing and never calls the carrier. Use it to check a document up front, for example while packing: the calling system reports what is missing right away instead of leaving a failed label behind.
End of day
At the end of the shipping day the created shipments are handed over to the carrier. One call does that and brings the accompanying document with it:
POST .../shipmentProviders({id})/Microsoft.NAV.closeOutDay
{}
{
"manifestedCount": 29,
"manifestedAt": "2026-09-09T15:14:04Z",
"documentAvailable": true,
"documentBase64": "JVBERi0…"
}
| Field | Meaning |
|---|---|
manifestedCount |
Shipments handed over |
manifestedAt |
When the close-out happened; empty when nothing was handed over |
documentAvailable |
Whether a document came with it |
documentBase64 |
The document, Base64 encoded |
Every shipment of the provider with status Created is handed over; afterwards they read Manifested.
A quiet day is not an error
With nothing open the call answers manifestedCount = 0. A system that closes out on a
schedule has to survive a day without shipments rather than handle an error.
No document is not an error either
Some carriers hand out no accompanying document, and test environments often lack it. The
close-out itself has happened all the same: documentAvailable is false and the shipments
are handed over.
The document of an earlier day comes from
POST .../shipmentProviders({id})/Microsoft.NAV.getManifestDocument
{ "manifestDate": "2026-09-09" }
The provider must support closing out, which supportsManifesting of the shipmentProviders
endpoint tells you.
Tracking history
GET .../shipmentTrackingEvents?$filter=shipmentLabelEntryNo eq 801
GET .../shipmentTrackingEvents?$filter=trackingNo eq '00340434161094015902'
Where shipmentLabels tells you where a parcel stands, this endpoint tells you how it got
there. Sorted by time, newest event first.
Fields: id, entryNo, shipmentLabelEntryNo, providerCode, trackingNo, eventDateTime,
deliveryStatus, eventCode, description, location, countryCode, signedBy,
recordedDateTime.
{
"entryNo": 4711,
"shipmentLabelEntryNo": 801,
"providerCode": "DHL",
"trackingNo": "00340434161094015902",
"eventDateTime": "2026-09-09T07:12:00Z",
"deliveryStatus": "Delivered",
"eventCode": "DELIVERY",
"description": "The shipment has been delivered",
"location": "Bonn",
"countryCode": "DE",
"signedBy": "M. Musterfrau"
}
deliveryStatus is carrier-neutral and uses the same scale as shipmentLabels. eventCode is the
carrier's own code, for support cases with the carrier. It is stored in upper case, so compare
it case-insensitively.
Poll this endpoint, not refreshTracking
The history is a by-product of the tracking that the carrier module runs anyway. Reading it
costs no carrier call and is not rate limited. refreshTracking asks the carrier and is.
Only what the carrier reports
A history is as complete as the carrier's answer. If tracking is not set up for a provider the
list stays empty, which is not an error. Whether a provider supports tracking is shown in
supportsTracking of the shipmentProviders endpoint.
The history is written by the carrier modules only. Through the API it is read-only.
Business events
For Power Automate and Dataverse the app provides three business events in the category merchantCENTRAL Shipment. The payload carries identifiers, numbers and states only; whoever needs addresses reads them through the API with the rights of their own connection.
| Event | Payload |
|---|---|
ShipmentLabelCreated |
labelId, labelEntryNo, providerCode, salesOrderNo, salesShipmentNo, trackingNo |
ShipmentLabelFailed |
labelId, labelEntryNo, providerCode, salesOrderNo, salesShipmentNo, errorMessage |
DeliveryStatusChanged |
labelId, labelEntryNo, providerCode, trackingNo, oldStatus, newStatus |
The events are raised only while the module is active and licensed.
Permissions
| Permission set | For whom |
|---|---|
| Integration API - Client | The Entra application of the external system. Create and read requests, read labels and documents, execute the API pages. Add D365 BASIC, D365 READ, merchantCENTRAL User and the user set of the respective carrier, see Setup. |
| Integration API - User | Shipping staff: monitor, retry and cancel requests. |
| Integration API - Admin | Setup, wizard, job queue. |