Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iurii ships.

Iurii Rogulia, senior full-stack software engineer. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland 🇫🇮

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. Shipping API Integration: Booking Shipments From an Order Worker

Iurii Books: Shipping API Integration: Booking Shipments From an Order Worker

Shipment booking is the one step in an order-processing worker that can be slow, can queue instead of answer, and can succeed without you finding out. Here’s how to place it so it doesn’t take the invoice down with it.

September 9, 2026· 9 min read

How to call a logistics provider’s API from an order-processing worker: why some booking APIs answer immediately and others don’t, whether a shipping outage should block the rest of the order, and how to avoid a duplicate label when a booking succeeds but the response never arrives.

Stack

TypeScriptNode.js

Libraries

BullMQ

Databases

PostgreSQL

Services

PostNord

Topics

WebhooksAPIIdempotencyArchitectureLogistics
Shipping API Integration: Booking Shipments From an Order Worker

On Pikkuna, a Stripe webhook enqueues a confirmed order into a BullMQ (Redis-backed) job queue, and a worker runs through it sequentially – CRM deal, backup record, shipment, accounting entry, PDF invoice, confirmation email:

// workers/order-processor.ts
const worker = new Worker("orders", async (job) => {
  const { sessionId } = job.data;
  const session = await fetchSession(sessionId);
 
  await createZohoDeal(session); // CRM
  await createAirtableRecord(session); // Backup DB
  await createPostNordShipment(session); // Shipping label
  await sendToNetvisor(session); // Accounting
  const invoice = await generateInvoicePDF(session);
  await sendEmailWithInvoice(session, invoice);
});

That’s the real shape, reconstructed from the Pikkuna case study – the same worker I used for the accounting call in Wiring an Accounting System into a Payment Webhook Without Losing Money. createPostNordShipment(session) is one line in it. I don’t have PostNord’s actual booking endpoint, payload shape, auth mechanism, or error codes to hand you, and I’m not going to invent them – this article isn’t about PostNord’s API surface. It’s about the questions that line raises for any logistics-provider call sitting in a sequential order-processing worker, whichever carrier is on the other end: PostNord, DHL, a regional courier, a shipping aggregator. Three of them come up every time this pattern gets built.

Does the Booking API Answer Now, or Later?

Shipping-provider APIs split roughly into two families, and the split changes how the worker step has to be written.

Synchronous booking returns a tracking number and a label – usually a PDF or ZPL payload – in the response to the call that created the shipment. The worker step is a single await: call, get the label back, store it, move on. This is the easy case, and it’s the one the Pikkuna snippet’s single await createPostNordShipment(session) line implicitly assumes.

Asynchronous booking is different, and it’s common enough with carrier and freight APIs that it can’t be treated as an edge case. The call you make doesn’t create the shipment – it submits a booking request that gets accepted, and the carrier confirms the label and tracking number later: a webhook callback, or a status you have to poll for. In that shape, createPostNordShipment(session) can’t be one await that returns a finished shipment. It has to do two things instead – submit the booking, and record that this order has a shipment pending confirmation – because the worker function returns, moves the job on to the accounting step, and generates an invoice, all before the label exists:

// generic pattern — illustrative, not any specific carrier's API
async function bookShipment(session: CheckoutSession) {
  const bookingRef = await carrierClient.submitBooking(session);
 
  // No label yet. Record the booking as pending so the callback
  // or a poller has something to reconcile against later.
  await db.insert(shipmentBookings).values({
    orderId: session.id,
    bookingRef,
    status: "pending",
  });
 
  // The worker moves on — invoice generation doesn't wait on a label
  // that might not exist for another few minutes.
}

The invoice and confirmation email steps further down the same worker then can’t assume a tracking number exists yet. Either they render without one and a separate step (the webhook callback, or a follow-up job scheduled to poll) fills it in and re-sends, or the invoice is generated with a ‘shipping label pending’ placeholder that’s genuinely true rather than a bug. Which one is right is a product decision, not a technical one. But it has to be made deliberately: ‘assume the label exists by the time the invoice renders’ silently breaks the moment the booking API turns out to be asynchronous.

Related service

API & Integrations

Wiring a carrier’s booking API – synchronous or webhook-confirmed – into an order pipeline that also has to keep the invoice and the accounting entry correct? Getting the sequencing and the retries right the first time is the integration work I do.

More about this service →

Should a Slow Carrier API Stall the Customer’s Invoice?

The worker in the snippet above runs shipment booking third, before accounting and before the PDF invoice. That ordering has a consequence: if createPostNordShipment throws or hangs, the two steps after it – the accounting entry and the invoice the customer is waiting on – don’t run either, because a BullMQ job that throws partway through fails the whole job. Stripe already confirmed the charge, the webhook already returned 200, the customer paid. And now they’re waiting on their invoice because a shipping API is having a bad afternoon.

The honest answer isn’t ‘always decouple it’. It depends on which of two things is true for the business:

  • If the invoice is expected before the shipment ships (typical for made-to-order or B2B goods with a lead time), there’s no real coupling problem. The customer isn’t blocked on the label; they’re blocked on the invoice, and the invoice doesn’t need the tracking number to be correct. In that case the fix isn’t decoupling the shipment call at all – it’s reordering the worker so accounting and invoice generation run before shipment booking, and a failed booking retries on its own without holding up either.
  • If the invoice or the confirmation email is supposed to carry the tracking number – common in DTC e-commerce, which is Pikkuna’s case – the two steps are genuinely coupled by the data, not just by position in the function. Reordering doesn’t fix it; the invoice needs a label that doesn’t exist yet. Here the real fix is upstream of retry logic. Decide, as a product decision, whether the invoice can ship without a tracking number (email it now, follow up with tracking once the label exists, which is the asynchronous-booking pattern above applied by choice even when the carrier itself is synchronous) or whether the business genuinely wants every invoice blocked until a label is confirmed. Only the second choice justifies leaving createPostNordShipment in the synchronous critical path ahead of the invoice.

Either way, ‘shipment booking runs third in the function’ is not itself the design decision – it’s the default a sequential worker falls into when nobody made one. The actual decision is which downstream steps genuinely depend on the label existing, and that’s a question about the data, not about where the await happens to sit in the file.

A Booking That Succeeded but the Response Got Lost

The same failure shape that motivated the dedupe-key pattern for the accounting call applies here, for the same underlying reason: a timeout doesn’t tell you whether the carrier received the request and failed to answer, or received it, created the shipment, and the response got lost on the way back. BullMQ retries the job either way. A naive bookShipment(session) called a second time submits a second booking – a second label, a second tracking number, a second cost on the account, for one order.

The fix follows the same shape covered in Idempotency Keys: Building Retries That Don’t Double-Charge: a deterministic key derived from the order, claimed atomically before the carrier call happens, so a retry recognizes its own prior attempt instead of repeating it.

// generic pattern — illustrative, not any specific carrier's API
async function bookShipment(session: CheckoutSession) {
  const dedupeKey = `shipment:${session.id}`; // stable across retries
 
  const existing = await db.query.shipmentBookings.findFirst({
    where: eq(shipmentBookings.dedupeKey, dedupeKey),
  });
  if (existing?.status === "confirmed") return existing; // already booked
 
  // Claim the key atomically before calling the carrier. If a prior
  // attempt already claimed it and is still pending, this insert loses
  // the race — that's the signal to check status, not to book again.
  const claim = await db
    .insert(shipmentBookings)
    .values({ dedupeKey, orderId: session.id, status: "pending" })
    .onConflictDoNothing()
    .returning();
 
  if (claim.length === 0) {
    // Another attempt owns this key. If the carrier's API accepts an
    // idempotency key or a lookup-by-reference call, that's the honest
    // way to find out whether the earlier attempt actually landed —
    // don't just assume it did or didn't.
    return resolveExistingBooking(dedupeKey);
  }
 
  try {
    const { trackingNumber, labelUrl } = await carrierClient.createShipment(session);
    await db
      .update(shipmentBookings)
      .set({ status: "confirmed", trackingNumber, labelUrl })
      .where(eq(shipmentBookings.dedupeKey, dedupeKey));
  } catch (err) {
    // The carrier may have created the shipment before the timeout —
    // this record stays "pending", not "failed", until something checks.
    throw err;
  }
}

Same mechanism as the accounting pattern: the row’s claim, not the code’s control flow, decides which attempt is allowed to call the carrier. A UNIQUE constraint on dedupeKey is checked by the database at insert time, so it holds even when the retry lands on a different worker process – which, on a BullMQ job re-queued after a timeout, it may well do. In-memory dedup logic doesn’t survive that.

One thing this pattern deliberately doesn’t paper over: unlike the accounting call, a pending row here can’t always be resolved by just calling createShipment again and trusting the dedupe key alone, because the failure happened on the outbound leg to a stateful side effect at a third party – a real label may already exist, with a real cost attached, sitting in the carrier’s system under a reference this worker doesn’t have yet if it never got the response. Whether the carrier API exposes a way to look up a booking by your own reference number, or accepts its own idempotency key so a resubmission is safe by design, determines whether resolveExistingBooking above is ‘call the carrier and check’ or ‘wait for a human, because there’s no safe way to find out.’ That’s a fact about the specific carrier’s API, not something this pattern can guarantee in general. Confirm it before the first pending row shows up stuck in production, not after.


Sequencing a carrier call, an accounting entry, and an invoice generation step in the same worker function is a small amount of code that hides a real design decision: what’s allowed to fail without stalling the rest of the order. If that’s the stage your order pipeline is at, get in touch and let’s work out which steps actually need to block each other before a slow shipping API becomes a stalled invoice.


Further reading:

  • Wiring an Accounting System into a Payment Webhook Without Losing Money – the same Pikkuna worker, same sequential-processing trade-offs, applied to the accounting call one step further down
  • PostNord API and Zoho Desk Automation: Production Gotchas – PostNord’s actual endpoint behavior and error-handling quirks, for the specifics this article deliberately leaves out
  • Idempotency Keys: Building Retries That Don’t Double-Charge – the general dedupe-key contract this article’s shipment-booking pattern is built on
  • Stripe Webhooks: Idempotency, Retries, and Queue Setup – how the inbound side of this same handler deduplicates events before the job is ever enqueued
  • Pikkuna – E-commerce for Vinyl Curtains & PVC Products – the project this pattern is drawn from
Iurii RoguliaAvailable

API & Integrations

Booking shipments, syncing a CRM, or wiring any third-party API into an order pipeline – and want the failure modes designed in before they cost you a duplicate label or a stalled invoice? That’s the integration work I do.

More about this service

Relevant client work

View all projects
vatnode.dev – EU VAT Validation API
vatnode.dev – EU VAT Validation API
January 19, 2026
vatnode.dev – EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products
October 12, 2024
pikkuna.fi – E-commerce for Vinyl Curtains & PVC Products

International e-commerce platform with 30 locales, product configurators, AI chatbot, and fully automated order flow: Stripe → Zoho CRM → Airtable → Mailgun →

HTPBE.TECH – Has This PDF Been Edited?
HTPBE.TECH – Has This PDF Been Edited?
September 25, 2024
HTPBE.TECH – Has This PDF Been Edited?

SaaS platform for PDF authenticity verification with a public REST API.

What clients say

“

I’d built most of our MVP with Cursor and it looked finished – it compiled, the tests were green, the demo worked. It just wouldn’t survive real users.

Sebastian Falk 🇸🇪

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
“

Our sales team was copying leads from the website into HubSpot by hand and things were falling through the cracks.

Ingrid Solberg 🇳🇴

Operations Manager

Services

HubSpot

Topics

APIWebhooksCRMIntegration
“

Before we closed on a seed-stage SaaS we asked Iurii to look under the hood. He gave us a written report in five days: what was solid, what was held together with tape, and roughly what it would cost

Florian Berg 🇨🇭

Investment Principal

Databases

PostgreSQL

Topics

Due DiligenceArchitectureScalabilityCode Review

Related articles

Wiring an Accounting System into a Payment Webhook Without Losing Money
September 4, 2026· 11 min
Wiring an Accounting System into a Payment Webhook Without Losing Money

How to wire an external accounting or bookkeeping API into a payment flow: why the call belongs in the queued worker rather than the webhook handler, how to

Stack

TypeScriptNode.js

Databases

PostgreSQL

Services

StripeNetvisor

Topics

WebhooksAPIAccountingIdempotencyArchitecture
Stripe Webhooks: Idempotency, Retries, and Queue Setup
January 2, 2026· 11 min
Stripe Webhooks: Idempotency, Retries, and Queue Setup

Stripe webhook production architecture: idempotency keys in PostgreSQL and Redis, BullMQ queue, signature verification in Next.js App Router – with full

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQDrizzle ORMioredis

Databases

PostgreSQLRedis

Services

Stripe

Topics

SaaSWebhooksArchitecturePayments
Health Check Endpoint in Node.js: Liveness vs Readiness
May 25, 2026· 18 min
Health Check Endpoint in Node.js: Liveness vs Readiness

Production healthcheck endpoints: liveness vs readiness probes, dependency checks with timeouts, 200 vs 503 logic, Docker and Kubernetes config, and security.

Stack

Node.jsTypeScript

Libraries

HonoBullMQ

Databases

PostgreSQLRedis

Topics

ArchitectureDevOpsInfrastructure
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared
May 18, 2026· 15 min
BullMQ vs pg-boss vs Cron: Node.js Background Jobs Compared

BullMQ vs pg-boss vs node-cron for Node.js background jobs. Trade-offs between Redis and Postgres queues, retries, deduplication, and production monitoring.

Stack

Node.jsTypeScript

Libraries

BullMQpg-boss

Databases

PostgreSQLRedis

Topics

ArchitectureSaaSAutomation
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow
February 22, 2026· 10 min
E-commerce Order Automation: Stripe + Invoice + Shipping Workflow

E-commerce order automation that cut 160+ hours/month of manual work to zero: Stripe webhook to VAT invoice and tracking in under 2 minutes.

Stack

Next.jsTypeScriptNode.js

Libraries

BullMQ

Databases

Redis

Services

StripeZohoAirtablePostNordNetvisorMailgun

Topics

E-commerceAutomationWebhooksPayments