feat(webhook): Stripe event_id idempotency on receipt #110
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
psa-systems/bunyip!110
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/stripe-webhook-idempotency"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes BUNYIP-89.
Stripe delivers webhooks at-least-once: any non-2xx, slow response, or connection drop triggers a retry. Today
bunyip-api/src/handlers/webhook.rs::stripe_webhookruns the event-specific handler for every delivery with no event-id tracking, so a single Stripe event can produce duplicate emails (customer.subscription.deleted-> two cancellation emails), duplicate audit-log rows (checkout.session.completed-> twoMembershipCreatedentries), and a duplicate receipt oninvoice.payment_succeeded. None of the handlers are idempotent on their own.Changes:
bunyip-api/migrations/20260611000010_create_stripe_webhook_events.sql: new tablestripe_webhook_events(event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). PK onevent_idis sufficient enforcement since Stripe ids (evt_xxx) are stable;ON CONFLICT DO NOTHINGresolves the race between concurrent retries cleanly. No retention policy in this migration; ops can prune byreceived_atlater.bunyip-api/src/handlers/webhook.rs::stripe_webhook: after signature verification and event-type extraction, before the existing handler routing, parseevent["id"]andINSERT INTO stripe_webhook_events ... ON CONFLICT (event_id) DO NOTHING RETURNING event_id.Some(_)-> first delivery, proceed.None-> duplicate, log at debug and return 200 so Stripe stops retrying. Missingevent.idreturns a 4xx validation error (Stripe always sets it; missing means malformed input).Uses runtime
sqlx::query_asrather than the compile-timesqlx::query!macro because the bunyip CLAUDE.md convention is that onlybunyip-oidcuses compile-time queries (whose.sqlx/offline cache regen happens per-crate).No change to the per-event handlers themselves; the dedup gate is the single source of truth for "this event ran once."
just check-containerclean (modulo the pre-existingtest_config_defaultsparallel-env-var flake onmain; 188 other tests pass; fmt + clippy + build clean).#BUNYIP-89