On Pikkuna, a Stripe webhook doesn't do the order-processing work itself. The moment payment confirms, it enqueues the order into a BullMQ (Redis-backed) job queue. A worker picks the job up and runs through it: create a CRM deal, write a backup record, book a shipment, log the accounting entry, generate a PDF invoice, email it, and fire a couple of analytics events (left out of the snippet below for scope, not because they don't matter). The steps run sequentially, one after another inside the same worker function. The accounting call is one of them. It goes to Netvisor, the Finnish bookkeeping platform this business's accountant works from:
// src/app/api/stripe-webhook/route.ts
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(body, sig, secret);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
// Enqueue for sequential processing โ the worker handles
// Zoho, Airtable, PostNord, Netvisor, PDF, and email in order
await orderQueue.add("process-order", { sessionId: session.id });
}
return new Response("Queued", { status: 200 });
}
// 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 โ the webhook half published verbatim in the Pikkuna case study, the worker steps reconstructed from its description โ simplified for this article's scope. What it doesn't show is what sendToNetvisor has to guarantee to be safe to call from inside a job that BullMQ will retry automatically on failure, with backoff, for however many attempts it's configured for. That question isn't specific to Netvisor. It applies to any accounting or bookkeeping API you wire into a payment flow: Netvisor, Fortnox, Xero, QuickBooks, a custom ledger. I don't have the Netvisor API's specific endpoints or error codes to hand you. What I have is the pattern that makes this kind of call safe whichever vendor is on the other end, and the reasoning for why it needs that treatment specifically.
Why This Call Is Different from the Other Three
createZohoDeal, createAirtableRecord, and createPostNordShipment run in the same worker function, one step after another, and at first glance the accounting call looks like a fourth instance of the same thing: call an API, move on. It isn't, for one reason. A duplicate CRM deal is an annoyance a salesperson merges in five seconds; a duplicate accounting entry is money booked twice in a system an accountant reconciles against a bank statement. If VAT gets filed off numbers that include a phantom entry, correcting it means going back to the tax authority. The blast radius of getting this one wrong is far larger than for the other three calls in that worker, even though all four sit in the same function and get retried by the same mechanism.
That asymmetry is what should drive every decision below. The question isn't "is this API slow" or "is this API popular." It's whether a duplicate or a silent failure costs someone money and paperwork to unwind.
Idempotency: The Retry Has to Land on the Same Entry, Not a New One
BullMQ retrying a failed job automatically is the whole appeal of putting a queue in front of the worker in the first place. It turns "the third-party API was briefly unreachable" into "handled" instead of "someone has to notice and re-run it by hand." But automatic retry cuts both ways. If the job already booked the Netvisor entry and then failed two lines later on the PostNord call, BullMQ hands the same job back to a worker again, and a naive sendToNetvisor(session) will happily create a second entry for the same order on that second run.
The fix is the same one I wrote up in Idempotency Keys: Building Retries That Don't Double-Charge, applied to an outbound call instead of an inbound one: give every accounting write a deterministic key derived from something that can't repeat for two different orders โ the Stripe session.id or payment_intent.id โ and make the accounting client refuse to create a second entry for a key it's already seen.
// generic pattern โ illustrative, not any specific vendor's API
async function sendToAccountingSystem(session: CheckoutSession) {
const dedupeKey = `netvisor:${session.id}`; // stable across retries
const alreadySent = await db.query.accountingSyncLog.findFirst({
where: eq(accountingSyncLog.dedupeKey, dedupeKey),
});
if (alreadySent) return; // this order has already been booked
// Claim the key atomically. If another call already claimed it between
// the findFirst above and this insert, onConflictDoNothing returns no
// row โ that's the signal this call lost the race, not a thing to ignore.
const claim = await db
.insert(accountingSyncLog)
.values({ dedupeKey, status: "pending" })
.onConflictDoNothing()
.returning({ dedupeKey: accountingSyncLog.dedupeKey });
if (claim.length === 0) return; // lost the race โ the other call owns this entry
try {
const entryId = await accountingClient.createEntry(session);
await db
.update(accountingSyncLog)
.set({ status: "confirmed", externalId: entryId })
.where(eq(accountingSyncLog.dedupeKey, dedupeKey));
} catch (err) {
await db
.update(accountingSyncLog)
.set({ status: "failed", lastError: String(err) })
.where(eq(accountingSyncLog.dedupeKey, dedupeKey));
throw err;
}
}The guarantee comes from two details, not from the shape of the function:
- The insert's result decides whether this call proceeds. Two overlapping retries can both pass the "not sent yet" check before either has inserted. That's a normal read-then-write gap, not a rare edge case. What prevents a double entry is that only one of those two inserts can win the
UNIQUEconstraint ondedupeKey; the other getsonConflictDoNothing's empty result and returns immediately, before callingaccountingClient.createEntryat all. In-memory state can't do this: it doesn't survive a serverless cold start, a process restart, or a BullMQ job landing on a different worker process on the next attempt, which is the case that actually matters here. AUNIQUEconstraint checked at insert time can, because the database itself is the single arbiter of who claimed the key first. - The row carries three states.
pending/confirmed/failed. A row stuck inpendingafter a crash is a different situation from one markedfailed: the first means "we don't actually know if the accounting system got this," the second means "we know it didn't." Collapsing those into a boolean throws away the distinction that decides whether it's safe to retry automatically or someone needs to look at it first.
This is a generic sketch of the pattern, not the Netvisor client. I don't have Netvisor's actual write endpoint, auth mechanism, or error payloads to show you, and reproducing them here would be a claim I can't back. Every accounting API's specific shape differs; the dedupe-key-and-status-row discipline around the call doesn't.
"Unreachable" and "Rejected" Are Not the Same Failure
A catch block that logs an error and lets BullMQ retry treats every failure the same way. That's wrong for an accounting call, because two very different things can happen when accountingClient.createEntry() throws, and they call for opposite responses:
- The service is unreachable โ a timeout, a 502, a DNS blip. Nothing is wrong with the data; the accounting system just didn't answer. This is safe to retry automatically, with backoff, because the entry either never arrived or arrived and the response was lost, and the idempotency key above makes a retry safe either way. This is what a job's default
attempts+backoffsettings are for. - The service rejected the request โ a validation error, an unknown VAT rate, a customer record it can't reconcile. Retrying the same payload will fail the same way every time. Automatic retry here wastes a fixed number of attempts that delay the one failure that actually needs a person to look at it, and it buries that signal under an equal number of ordinary, retriable network blips.
// generic pattern โ categorize before deciding whether to retry
async function classifyFailure(err: unknown): Promise<"retriable" | "rejected"> {
if (err instanceof AccountingApiError) {
return err.statusCode >= 500 || err.code === "TIMEOUT" ? "retriable" : "rejected";
}
return "retriable"; // unknown errors default to retriable, not silently dropped
}In a BullMQ worker specifically, this classification is what decides which error to throw. A retriable failure throws normally and lets the job's configured attempts and backoff run their course. A rejected failure should throw BullMQ's own UnrecoverableError instead. It moves the job straight to the failed set and skips whatever attempts are left, regardless of how the job was configured. Without that distinction, a rejected payload gets retried exactly like a timeout would: the same number of identical, doomed attempts, on a delay, before anyone finds out the accounting system never had any intention of accepting it. rejected failures need a human; retriable ones don't.
One Failing Step, One Failed Job โ What Retries the Rest?
By the time sendToNetvisor runs, Stripe has already confirmed the charge and the webhook handler has already returned 200. The customer paid, and from Stripe's side the order is handled. Everything downstream โ including the accounting call โ is the worker's problem now, not something the customer waits on or ever sees fail.
That's the appeal of enqueueing instead of doing this work inline in the webhook handler. But it comes with a trade-off that's easy to miss if you're used to reasoning about retries one call at a time: BullMQ retries the job, not the step. A worker function that runs createZohoDeal, createAirtableRecord, createPostNordShipment, and sendToNetvisor in sequence and then throws on the fourth one fails the whole job. On the next attempt, by default, BullMQ runs the entire function again from the top, not just the step that threw.
For the accounting call, that's what the idempotency pattern above is for: a retried sendToNetvisor recognizes the dedupeKey it already claimed and returns immediately instead of writing a second entry. But the same retry also re-runs createZohoDeal, createAirtableRecord, and createPostNordShipment. Unless each of those is written to recognize its own prior success too, a job that fails on step 4 after steps 1โ3 already succeeded creates a second CRM deal, a second Airtable row, and a second PostNord shipment label on every retry, not just a second Netvisor entry.
There are two honest ways to handle that, and neither is free:
- Make every step idempotent. A dedupe key and a guard clause in
createZohoDeal,createAirtableRecord, andcreatePostNordShipmenttoo, the same shape as the Netvisor pattern above. That's more code duplicated across every integration, but it means the job can fail and retry from the top as many times as it needs to without creating a duplicate anywhere. - Checkpoint progress within the job. Record which steps already succeeded โ in the job's own data via
job.updateData, or in a status row keyed by the order โ and skip them on the next attempt, so a retry resumes at step 4 instead of restarting at step 1. Less duplicated code, but it adds a second piece of state that has to stay in sync with what actually happened; a bug in the checkpoint logic reintroduces the duplicate-write risk it was meant to prevent.
Skipping both isn't an option once the step most likely to be rejected outright (an accounting API returns 4xx far more often than a CRM does) sits downstream of three steps that look cheap enough to just redo โ right up until the fourth retry leaves three duplicate CRM deals sitting in Zoho for a single order.
Where This Pays Off and Where It's Overkill
The dedupe key, the three-state status row, the retriable/rejected split, the idempotent-or-checkpointed steps โ that's real engineering weight. Here's when it earns its cost and when it doesn't.
It pays off once an accounting sync failure means someone has to manually reconcile numbers against a bank statement, or once a job queue genuinely retries often enough that a repeat call is a when, not an if. For a BullMQ job configured with more than one attempt, that's true from day one. At that point, the alternative to this design isn't "simpler code," it's a human periodically diffing the accounting system against the order database to find what silently didn't sync. That's a worse trade in both engineering time and error rate.
It's overkill for a low-volume internal tool where a missed accounting entry gets caught the same afternoon by someone who already checks the ledger daily, or for a first version where "log the error and let a human re-run it" is an honest, cheap fallback while the business validates whether it needs this integration at all. Don't build the idempotency layer and the failure taxonomy before you've shipped the happy path and confirmed the volume justifies it โ that's the kind of complexity-for-its-own-sake this site argues against elsewhere.
Duplicate writes, silent drops, one accounting call stalling an entire job that also owes the customer their invoice โ these are design decisions, not bugs you patch after the first bad reconciliation. If that's the stage your integration is at, get in touch and let's design the failure modes out before they cost you an afternoon.
Further reading:
- Stripe Webhooks: Idempotency, Retries, and Queue Setup โ how the inbound side of this same handler deduplicates Stripe events before the job is ever enqueued
- Idempotency Keys: Building Retries That Don't Double-Charge โ the general server-side contract this article's dedupe key is built on
- Pikkuna โ E-commerce for Vinyl Curtains & PVC Products โ the project this pattern is drawn from









