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. Wiring an Accounting System into a Payment Webhook Without Losing Money

Iuriiย Wires: Wiring an Accounting System into a Payment Webhook Without Losing Money

The accounting call in a payment webhook is the one that turns a bug into a bookkeeping problem. Here's how to place it, retry it, and fail it safely.

September 4, 2026ยท 11 min read

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 make it idempotent, and how to tell 'unreachable' from 'rejected' when it fails.

Stack

TypeScriptNode.js

Databases

PostgreSQL

Services

StripeNetvisor

Topics

WebhooksAPIAccountingIdempotencyArchitecture
Wiring an Accounting System into a Payment Webhook Without Losing Money

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.

Related service

API & Integrations

Wiring Stripe, or any payment flow, into an accounting system your bookkeeper actually relies on? Getting the retries, the dedupe keys, and the failure handling right the first time is the integration work I do.

More about this service โ†’

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 UNIQUE constraint on dedupeKey; the other gets onConflictDoNothing's empty result and returns immediately, before calling accountingClient.createEntry at 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. A UNIQUE constraint 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 in pending after a crash is a different situation from one marked failed: 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 + backoff settings 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, and createPostNordShipment too, 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
Iurii RoguliaAvailable

API & Integrations

Wiring a payment flow into an accounting system, a CRM, or both โ€” and want the failure modes handled before they cost you a reconciliation afternoon? 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 โ€” Internal Admin Dashboard
HTPBE.TECH โ€” Internal Admin Dashboard
March 15, 2026
HTPBE.TECH โ€” Internal Admin Dashboard

Role-gated admin dashboard for the HTPBE? SaaS platform โ€” real-time KPIs, per-user quota tracking, and a zero-dependency bar chart, all server-rendered via

What clients say

โ€œ

We picked vatnode for our B2B billing flow and asked Iurii to help us integrate it properly.

Mฤrtiล†ลก Liepa ๐Ÿ‡ฑ๐Ÿ‡ป

CTO

Services

Stripe

Topics

Tax/VATB2BAPIWebhooks
โ€œ

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

Related articles

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
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
API Key Management for a Public SaaS API
August 5, 2026ยท 13 min
API Key Management for a Public SaaS API

API key management for a public SaaS: hashing keys at rest, prefix + last-4 display, fail-closed validation, and revocation โ€” plus the scoping, rotation, and

Stack

TypeScriptNode.jsHono

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSAPIAuthSecurity
Preventing Overselling: Inventory Locks Under Concurrent Checkouts
July 31, 2026ยท 13 min
Preventing Overselling: Inventory Locks Under Concurrent Checkouts

Prevent overselling under concurrent checkouts: reservations vs hard decrements, SELECT FOR UPDATE, deadlock-safe multi-line carts, and the payment window.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQLRedis

Topics

E-commercePaymentsArchitectureSaaS
Subscription Billing: The Edge Cases Stripe Docs Skip
July 22, 2026ยท 17 min
Subscription Billing: The Edge Cases Stripe Docs Skip

Subscription billing edge cases Stripe glosses over: proration, dunning, the state machine, cancellation timing, refunds, VAT, and webhook ordering.

Stack

Next.jsTypeScriptNode.js

Databases

PostgreSQL

Services

Stripe

Topics

SaaSPaymentsE-commerceBilling