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
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:
charge.refundedstamps the purchase rather than deleting it. The row stays because it is what reconciles with the provider's own records.charge.dispute.createdis 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:
- Bad signature → 400, nothing written. The most common configuration error in production is a mismatched signing secret; you want it loud.
- The same event delivered twice → one business write. Send the identical payload twice and assert the subscription row changed once.
- 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_eventsforever, 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.