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.
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
createPostNordShipmentin 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









