PostNord EDI to API Migration Without Duplicate Labels
Step-by-step guide to moving PostNord bookings from EDI to the OAuth2 Booking API, with an idempotency layer to stop duplicate labels mid-cutover.
If you still have a file drop running against PostNord's EDI gateway, the clock has already run out in two of your four Nordic markets. nShift's migration notes confirm that nShift Ship customers must migrate to the API solution before March 1st, 2026 for Sweden, while PostNord Denmark has set a final deadline of February 24th, 2026, for nShift Ship customers to switch to the API. Finland is still moving: starting in 2025, Finnish customers will also transition from the legacy solution to the new PostNord API, with the migration happening as soon as possible. This is a PostNord API migration guide for the teams who haven't finished yet, written for the people who build and operate the integration, not just the shipper clicking "go live."
Why this migration can't wait
PostNord has made its intent explicit on its own campaign page: in the future, PostNord will put all focus into APIs instead of developing both APIs and file-based EDIs. That's not a vague roadmap statement. If you run multi-tenant carrier middleware for shippers across Sweden, Denmark, Finland and Norway, you're not managing one PostNord deprecation, you're managing four, on four different clocks, each with its own penalty structure and contact email.
Sweden has already added a cost penalty for stragglers on nShift Ship, separate from the hard cutoff. Denmark's deadline is tighter than Sweden's by five days. Finland has no fixed date yet, just a directive to move as soon as possible. If your platform serves shippers as a product, across these markets, that asymmetry is a tenancy and configuration problem as much as it is an API migration.
What you need before you start
Before touching code, get these in place. PostNord's developer portal documentation is explicit that PostNord OAuth2 implementation supports the standard client credential grant type, and each secured API has a set of scopes that defines the permissions to access their resources.
- A PostNord Developer Portal account, sandbox first. Register at the sandbox portal; a production account is separate and comes later.
- OAuth2 client credentials scoped per API. As the portal notes, the client must be registered to perform an OAuth2 login, and a client can choose to request all scopes or a subset of those defined.
- The correct token endpoints for each environment. Per PostNord's own how-to guide, the test authorization server URL is https://stage.account.postnord.com/oauth2/auth, and the production authorization server URL is https://account.postnord.com/oauth2/auth. Mixing these up is a classic cutover bug: your sandbox tests pass, production 401s on day one.
- Country-specific identifiers. For Denmark specifically, you will need to enter your 'MISKOS/Customer Production Number' from PostNord DK to set up the carrier. Sweden instead asks for a plain customer number. These aren't interchangeable, and a generic "PostNord adapter" that assumes one identifier format will break the moment you add a second country.
- Clarity on what "Booking API" actually covers. PostNord's own portal lists the scope: the intent with the Booking APIs is to support all different kinds of transport bookings that PostNord offers, including Book EDI Instruction, Book EDI Instruction and get labels (PDF, ZPL), Book Digital Returns and get labels, Book Pickups, Book Customs. Note that "Book EDI Instruction" here is an API call, not the old file-drop mechanism, it's the new API replicating the old EDI payload shape. The naming is confusing on purpose, apparently, but don't let it trick you into thinking there's still a file-based fallback.
Step-by-step: from file drop to Booking API call
This is the sequence we'd run for a shipper or a middleware tenant migrating one PostNord country connection, assuming sandbox credentials are already issued.
- Register your application in the PostNord Developer Portal and request scopes. Scopes live in the Swagger definition per API, not in a central list, so pull them from the Booking API's own OAS spec before you file the request, since approval from PostNord's scope owner takes time.
- Request a client-credentials token and cache it. PostNord doesn't publish a universal token TTL in its public docs, so treat the response's own expiry field as the source of truth and cache accordingly rather than hardcoding a lifetime. This is the same operational discipline carriers like DPD demand of their integrators, where an auth token has to be fetched once and reused, not regenerated on every call, a pattern familiar if you've built against Cargoson, nShift, EasyPost or ShipEngine before.
- Build the shipment payload against the Booking API schema. Consignor, consignee, service code and parcel dimensions map differently per country product, so don't assume Sweden's field names carry over to Norway or Finland without checking the Swagger for each.
- Call the booking endpoint on the production host. PostNord's guide confirms the production URL Host is: https://api2.postnord.com. Critically, check the SECURED flag per endpoint variant: PostNord's own API listing for one Booking API variant notes the API do not require a PostNord Oauth2 login, meaning some booking calls are unsecured by default and rely entirely on the API key for access control. Don't assume OAuth2 is enforced everywhere just because you set it up for one scope; audit each endpoint variant you use.
- Parse the response for shipment ID, tracking number and label reference. Store the raw response alongside your internal record; you'll need it for the diff step later.
- Retrieve the label (PDF or ZPL) via the labels sub-resource. Confirm both formats render correctly in your warehouse print pipeline before cutover, not after.
- Store the PostNord shipment ID against your own internal order record. This is the join key you'll use for reconciliation and for the idempotency layer below.
- Run EDI and API in parallel on a sample of shipments and diff the label output. Don't flip the switch on 100% of volume until you've matched a meaningful sample, country by country, since Sweden, Denmark, Finland and Norway don't share identical field mappings.
The idempotency gap you must build yourself
Here's the part most migration checklists skip. Unlike payment APIs you've probably already integrated, PostNord's Booking API documentation shows no native Idempotency-Key header. There's no built-in equivalent to what Stripe does with its idempotency header, or what PayPal does with PayPal-Request-Id. You get an OAuth2 bearer token and a POST endpoint. That's it.
This matters because a POST with no natural idempotency means the server can't tell your retry apart from a genuine second order, so it creates both. One recent write-up on the pattern puts it plainly: the root error is thinking in two states. A key is not merely present or absent. It has three states, and the correct handling differs for each:
| State | Meaning | Correct response |
|---|---|---|
| Absent | Never seen before | Claim it, then call PostNord |
| In flight | Another request already owns this key | Reject with 409, do not call PostNord again |
| Complete | PostNord already returned a shipment ID | Replay the stored response, skip the call entirely |
The fix is to build this layer yourself, in front of the PostNord call, using your own database. The claim has to be atomic. As one architecture write-up on this exact race condition puts it: the fix is to make claiming the key an atomic operation, and to do it before doing any work: claim atomically. Either you inserted the row, or somebody else owns it. In Postgres, that's a single statement:
INSERT INTO idempotency_keys
(key, tenant_id, request_fingerprint, state, created_at)
VALUES ($1, $2, $3, 'in_flight', now())
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING id;If the insert returns a row, you own the key and can proceed to call PostNord. If it returns nothing, somebody else is already processing this exact request, and your job is to wait or reject, not to call the Booking API a second time.
Scope the key per PostNord account plus your own order reference, not globally across tenants. The general contract to enforce: a new key with a valid payload creates the order and saves the response, the same key with the same payload replays the saved response, and the same key with a different payload returns a 409 Conflict rather than silently proceeding. This is the same pattern Amazon applies with a ClientToken on order creation and the pattern most mobile checkout flows rely on to stop a double-tap from booking two shipments, as one recent breakdown of the pattern across vendors notes: Amazon uses a ClientToken on operations like order creation, so the same token can never produce two orders. PostNord gives you none of this natively. You build the table, the unique constraint, and the lookup logic yourself, in front of every booking call.
Failure mode: the timeout that books twice
Here's the concrete scenario. Your client sends a booking POST to PostNord's Booking API. The request reaches PostNord, a shipment is created, a label is generated server-side. But the response never makes it back to your client, a network blip, a load balancer timeout, whatever. Your client sees a timeout, not a confirmed success. If your retry logic blindly re-POSTs the same payload, you now have two shipment records, two tracking numbers, two labels, and a courier showing up for two pickups on one order.
This isn't a hypothetical, it's the standard shape of the problem with any POST-based booking API that lacks native idempotency. As one engineer who'd been burned by exactly this put it: the first call had actually gone through on the processor's side; the client's retry was a second, fully independent charge. Same card, same amount, no error on either end, just substitute "charge" for "shipment booking" and the failure is identical.
The fix is the idempotency table from the section above, used correctly. Before retrying a PostNord booking call after a timeout, check your own idempotency_keys table first. If the key is marked in_flight, don't re-POST, wait and poll, or surface a conflict to the caller. If PostNord's sandbox or API exposes a lookup by your own client reference, use that as a secondary confirmation before you retry blind. What you must never do is treat a timeout as a failure signal and fire the same booking payload again without checking state first.
Multi-country quirks that break naive single-adapter designs
Each PostNord country has its own deprecation clock and its own field requirements, which is exactly why a single "PostNord" adapter in your routing layer tends to fall apart under this migration. Norway adds a wrinkle unrelated to the EDI cutover but relevant if you're touching PostNord Norway configuration at the same time: effective January 8, 2026, PostNord Norway will deprecate the Home Delivery service, affecting all nShift Ship customers using PostNord Norway (CarrierConceptID: 5) and the service Home Delivery (ProdConceptID: 544).
If you're designing this as reusable multi-tenant carrier middleware rather than a one-off migration, treat PostNord as four country-level adapters, not one carrier. Each needs its own scope set, its own deadline tracking, and its own field mapping table. Several platforms already carry this complexity so individual shippers don't have to track it themselves. Cargoson, for instance, publishes separate API documentation for PostNord SE, alongside equivalent pages for PostNord FI and PostNord NO, each following the same booking and freight-price query pattern. nShift maintains its own per-country migration notes, and Omnium splits its PostNord integration into two named providers with a naming inversion worth flagging explicitly: there are two PostNord provider implementations: PostNordV2 (Recommended) - Uses EDI-based booking API, while PostNord (Legacy) - Uses OAuth2-based transport booking API. In other words, Omnium's "recommended" V2 provider calls itself EDI-based even though it talks to PostNord's API, and the "legacy" one is the OAuth2 path you're trying to adopt. If you're cross-referencing vendor docs during this migration, check which naming convention you're reading before you copy a configuration field across platforms.
How you know it worked, and rollback plan
Verification isn't optional here, and it isn't just "did the API call return 200." Check all of the following before you consider a country cutover complete:
- The shipment ID returned by the Booking API matches the format your reconciliation logic expects.
- The label PDF or ZPL renders correctly and scans at your warehouse or the carrier's depot.
- The tracking number resolves on PostNord's public tracking page.
- Your parallel-run diff against the old EDI output shows zero discrepancies across a meaningful sample, not just a handful of test shipments.
Keep the EDI path dormant but deployable for one full release cycle after cutover, in case a country-specific edge case surfaces under real volume that your parallel run missed. Cut over country by country, not globally, given that Sweden, Denmark and Finland are on genuinely different deadlines and different field requirements. A big-bang cutover across all four PostNord markets at once is exactly the kind of decision that turns a planned migration into an incident.
The claim-row-before-call idempotency pattern you build here isn't PostNord-specific. Any carrier API that accepts a booking POST without a native idempotency header, and there are more of them than you'd expect, needs the same table, the same atomic insert, and the same three-state handling. Build it once, scope it per carrier account and reference, and reuse it the next time a carrier tells you EDI is going away.