DEV Community

Benjamin Touchard
Benjamin Touchard

Posted on Originally published at govuekit.dev

Idempotent billing webhooks: exactly-once fulfilment with Stripe

Payment providers deliver webhooks at least once. That is not a bug in their
systems, it is the only guarantee a network can offer: if your endpoint is
slow, times out, or answers 500 after committing, the event comes back. Twice.
Sometimes days later.

Everything that follows exists to make the second delivery boring.

The three ways this goes wrong

Double fulfilment. The event arrives twice, you grant credits twice. The
customer notices only when it goes the other way.

Lost fulfilment. You verify the signature, start work, and time out. The
provider retries, your handler crashes on a half-written state, and the
subscription never activates. The customer paid; your database disagrees.

Trusting the payload. The webhook body says the subscription is active.
Between the event being generated and your handler running, the payment was
disputed and the subscription cancelled. A valid signature proves who sent the
message, not that the world still looks like that.

The pattern

Four rules, in order.

1. Read the raw body and verify the signature

The signature is computed over the exact bytes sent. Any middleware that
parses and re-serialises JSON before you check it will break verification, so
the webhook route must read the body before anything touches it.

Stripe's tolerance window (300 seconds by default in GoVueKit) also matters:
it is what stops an attacker from replaying a captured, still-valid request
next month.

2. Answer fast, work after

Providers treat a slow endpoint as a failed one. The handler acknowledges as
soon as the event is durably recorded, and the actual work happens in the
background worker — which, because the queue is a database table, survives the
redeploy that happens three seconds later.

3. Deduplicate with a primary key, in the same transaction as the write

This is the part that people get almost right. The failure mode of "check if
processed, then process, then mark processed" is a crash between steps two and
three — or two deliveries racing.

The fix is not a lock. It is a single transaction:

BEGIN
  INSERT INTO processed_events (id, ...) VALUES ($1, ...)   -- primary key
  -- unique violation → this event is already done, ROLLBACK and answer 200
  UPDATE subscriptions SET ... WHERE organization_id = $2
COMMIT
Enter fullscreen mode Exit fullscreen mode

Either both happen or neither does. A duplicate delivery hits the primary key
and becomes a no-op. A crash mid-way rolls back and the provider's retry
replays it cleanly. There is no window in which the event is marked done but
the business write is missing.

4. Re-fetch the object by id; never trust the payload

The handler takes exactly one thing from the payload: the object id. Then it
asks the provider's API what that object looks like now, and writes that.

event → id → provider.Resolve(ctx, ev) → authoritative state → write
Enter fullscreen mode Exit fullscreen mode

This costs one API call per event and removes a whole class of race
conditions. It also means a replayed six-day-old event cannot resurrect a
cancelled subscription: the re-fetch returns the current state.

It has a second benefit that only shows up in production: because nothing is
read from the payload beyond the id, the API release train stamped on the
envelope stops mattering. Verification checks the signature and the timestamp,
deliberately not the version, and every re-fetch sends the SDK's own
Stripe-Version — so a dashboard that upgrades under you does not turn every
delivery into a 400.

The same rule forbids fetching URLs found in a payload — a webhook body is
attacker-influenced input, and following its links is how SSRF starts. Any
outbound call in GoVueKit that is not to a hardcoded host goes through the
hardened safehttp client, which validates the IP at dial time and refuses
private ranges.

One provider, behind a seam

GoVueKit sells through Stripe. A store has one merchant of record, so a second
provider is not a feature the kit switches between — but it is a package
away rather than a rewrite, because the billing page, the admin metrics and
the subscription row talk to an interface, not to Stripe:

type Provider interface {
    Name() string
    Checkout(ctx context.Context, org sqlcgen.Organization, actorEmail string, plan Plan) (string, error)
    Portal(ctx context.Context, org sqlcgen.Organization, actorEmail string) (string, error)
    VerifyWebhook(body []byte, header http.Header) (Event, error)
    Resolve(ctx context.Context, ev Event) (Change, error)
}
Enter fullscreen mode Exit fullscreen mode

Five methods. If you need Paddle, Lemon Squeezy or a local PSP, that is what
you implement, and nothing above the seam changes. Be honest with yourself
about the size of that job: it is a real integration, not a config switch, and
the kit does not ship it.

What the seam does buy you is that the guarantee travels. The shape of the
dedup — one row, one primary key, one transaction — does not depend on what
the key is made of. Stripe hands you a unique event id; providers that do not
force you to build the key from the pair (event name, object id) instead. The
pattern survives the substitution, which is the whole point of writing it
against an interface.

Money leaving the account counts too

Eight event types cover money arriving. Two more cover it leaving, and they
are the ones that get forgotten:

  • charge.refunded stamps the purchase rather than deleting it. The row stays because it is what reconciles with the provider's own records.
  • charge.dispute.created is logged at Error level, so it reaches your error reporter rather than a log file nobody greps. A chargeback has a deadline; it should behave like an incident.

The three checkout.session.* events matter together, not individually. A
card pays inside the checkout, so completed already reports
payment_status=paid. A deferred method — SEPA direct debit, which Stripe
enables readily on a EUR checkout — completes unpaid and settles days
later as async_payment_succeeded, or bounces as async_payment_failed.
Subscribe to completed alone and those sales are never fulfilled, silently.

What to test

Three tests, and they are quick:

  1. Bad signature → 400, nothing written. The most common configuration error in production is a mismatched signing secret; you want it loud.
  2. The same event delivered twice → one business write. Send the identical payload twice and assert the subscription row changed once.
  3. Unknown event type → 200. Providers add event types; your endpoint must ignore them without erroring, or their dashboard will start disabling your webhook.

GoVueKit ships all three as integration tests, plus a Playwright journey that
posts a signed duplicate through the real binary. The unconfigured path is
tested too: with no Stripe key — which is the default, and what most
deployments run — the endpoints answer a deliberate 501 and nothing is
written.

Operational notes

  • Point Stripe at /api/billing/webhook, and subscribe the eight events the README lists rather than "all events": an endpoint that receives everything is an endpoint whose logs tell you nothing.
  • Local testing: stripe listen --forward-to localhost:8080/api/billing/webhook.
  • Log the event id on every branch. When a customer says "I paid and nothing happened", the id is what turns a conversation into a query.
  • Keep processed_events forever, or at least well past the provider's retry window. It is small, and it is your proof.

The rule underneath all of this is the same one that governs
tenancy: make the safe path the only
convenient path, then write the test that fails when someone finds a shortcut.

See how billing fits in the kit.


Originally published on govuekit.dev. GoVueKit is a complete Go + Vue 3 SaaS codebase you buy once and own; the whole stack runs on your machine with one docker compose up: https://govuekit.dev/labs

Top comments (3)

Collapse
 
dhruv_malaviya profile image
Dhruv Malaviya •

"No window in which the event is marked done but the business write is missing" is the clearest statement of that pattern I've seen.

Two additions. The tolerance window and the dedupe table defend different threats , replay attacks versus the provider's own retries , and neither substitutes for the other. And ordering is the gap this doesn't close: Stripe doesn't guarantee delivery order, so if your logic branches on the transition rather than the state, you need to compare versions and discard the stale one.

processed_events also needs retention that exceeds the provider's maximum retry horizon.

Collapse
 
koda2026 profile image
Harun - solo dev •

i’m 12 and building an ai mentor on edge compute, so i’m not processing saas billing yet. but reading "no window in which the event is marked done but the business write is missing" just clicked something massive in my brain. 🤯

i use supabase + cloudflare workers for koda's backend. when i eventually add subscriptions, i was planning to just use supabase edge functions to catch the webhook and update the user row. but your point about the "check then process" race condition means i need to wrap the INSERT INTO processed_events and the UPDATE profiles in a single supabase rpc transaction with a unique constraint on the stripe event_id.

no background queue needed, just pure db-level atomicity on the edge.

thanks for the masterclass, benjamin. saving this for when koda gets its first paying user. 🐯

Collapse
 
challan116ux profile image
challan116-ux •

The re-fetch-by-ID rule is the one I would underline — I'm Chris, founder of SignalEDI, and trusting the payload is how a replayed EDI shipment notice resurrects a cancelled order days later, so we treat the inbound document as a pointer and re-read the authoritative order state before writing. Pairing the primary-key dedup in the same transaction with keeping processed events past the provider's retry window also closes the crash-between-steps window we have been bitten by. How long do you actually retain processed events before archiving, given dispute events can arrive months after checkout?