GoVueKit

Idempotent billing webhooks: exactly-once fulfilment with Stripe

June 3, 2026

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

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

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)
}

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:

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

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.