feat(webhook): Stripe event_id idempotency on receipt #110

Merged
YousifShkara merged 1 commit from feat/stripe-webhook-idempotency into main 2026-06-11 05:51:13 +02:00
Owner

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_webhook runs 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 -> two MembershipCreated entries), and a duplicate receipt on invoice.payment_succeeded. None of the handlers are idempotent on their own.

Changes:

  • bunyip-api/migrations/20260611000010_create_stripe_webhook_events.sql: new table stripe_webhook_events(event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). PK on event_id is sufficient enforcement since Stripe ids (evt_xxx) are stable; ON CONFLICT DO NOTHING resolves the race between concurrent retries cleanly. No retention policy in this migration; ops can prune by received_at later.

  • bunyip-api/src/handlers/webhook.rs::stripe_webhook: after signature verification and event-type extraction, before the existing handler routing, parse event["id"] and INSERT 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. Missing event.id returns a 4xx validation error (Stripe always sets it; missing means malformed input).

Uses runtime sqlx::query_as rather than the compile-time sqlx::query! macro because the bunyip CLAUDE.md convention is that only bunyip-oidc uses 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-container clean (modulo the pre-existing test_config_defaults parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-89

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_webhook` runs 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` -> two `MembershipCreated` entries), and a duplicate receipt on `invoice.payment_succeeded`. None of the handlers are idempotent on their own. Changes: - `bunyip-api/migrations/20260611000010_create_stripe_webhook_events.sql`: new table `stripe_webhook_events(event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`. PK on `event_id` is sufficient enforcement since Stripe ids (`evt_xxx`) are stable; `ON CONFLICT DO NOTHING` resolves the race between concurrent retries cleanly. No retention policy in this migration; ops can prune by `received_at` later. - `bunyip-api/src/handlers/webhook.rs::stripe_webhook`: after signature verification and event-type extraction, before the existing handler routing, parse `event["id"]` and `INSERT 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. Missing `event.id` returns a 4xx validation error (Stripe always sets it; missing means malformed input). Uses runtime `sqlx::query_as` rather than the compile-time `sqlx::query!` macro because the bunyip CLAUDE.md convention is that only `bunyip-oidc` uses 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-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on `main`; 188 other tests pass; fmt + clippy + build clean). #BUNYIP-89
feat(webhook): Stripe event_id idempotency on receipt
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m1s
007e86cc26
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_webhook` runs 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` -> two `MembershipCreated` entries), and a duplicate receipt on `invoice.payment_succeeded`. None of the handlers are idempotent on their own.

Changes:

- `bunyip-api/migrations/20260611000010_create_stripe_webhook_events.sql`: new table `stripe_webhook_events(event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`. PK on `event_id` is sufficient enforcement since Stripe ids (`evt_xxx`) are stable; `ON CONFLICT DO NOTHING` resolves the race between concurrent retries cleanly. No retention policy in this migration; ops can prune by `received_at` later.

- `bunyip-api/src/handlers/webhook.rs::stripe_webhook`: after signature verification and event-type extraction, before the existing handler routing, parse `event["id"]` and `INSERT 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. Missing `event.id` returns a 4xx validation error (Stripe always sets it; missing means malformed input).

Uses runtime `sqlx::query_as` rather than the compile-time `sqlx::query!` macro because the bunyip CLAUDE.md convention is that only `bunyip-oidc` uses 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-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on `main`; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-89
YousifShkara deleted branch feat/stripe-webhook-idempotency 2026-06-11 05:51:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!110
No description provided.