Add Stripe webhook idempotency to prevent duplicate event processing #519

Open
pook wants to merge 59 commits from agent-task/512 into main
Owner

Summary

  • Adds stripe_webhook_events table with event_id as primary key for deduplication
  • Webhook handler checks for prior processing before handling any event, returns 200 immediately if already processed
  • Records successful event outcomes after processing; uses onConflictDoNothing for race-condition safety
  • Includes SQL migration file and e2e test for duplicate delivery scenario

Test plan

  • Run migration to create stripe_webhook_events table
  • POST a webhook payload twice with the same event ID
  • Confirm first call processes normally, second call returns {received: true, deduplicated: true} with no side effects
  • Verify subscription table has only one record for the event
## Summary - Adds `stripe_webhook_events` table with `event_id` as primary key for deduplication - Webhook handler checks for prior processing before handling any event, returns 200 immediately if already processed - Records successful event outcomes after processing; uses `onConflictDoNothing` for race-condition safety - Includes SQL migration file and e2e test for duplicate delivery scenario ## Test plan - [ ] Run migration to create `stripe_webhook_events` table - [ ] POST a webhook payload twice with the same event ID - [ ] Confirm first call processes normally, second call returns `{received: true, deduplicated: true}` with no side effects - [ ] Verify subscription table has only one record for the event
Add webhook idempotency to prevent duplicate Stripe event processing
Some checks are pending
CI Quality Gate / Lint / Typecheck / Test / Build (pull_request) Waiting to run
6caa7e457d
Stripe redelivers webhook events on failure or timeout. Without idempotency,
duplicate deliveries can create duplicate subscription records and potentially
double-charge customers. This adds a stripe_webhook_events table with event_id
as primary key, checks for prior processing before handling, and records
successful outcomes after processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat: issue #512 critical-billing-integrity-fix-same-risk (agent task liancebot512)
Some checks failed
CI Quality Gate / Lint / Typecheck / Test / Build (pull_request) Has been cancelled
daf1ca91f9
Author
Owner

PR #519 Verification: Stripe Webhook Idempotency

1. Duplicate webhook delivery — PASS

Second POST with the same event_id returns { received: true, deduplicated: true } with HTTP 200 and no side effects. The SELECT check at billing.ts:83-90 (in the PR) gates this correctly.

2. Concurrent processing of same event ID — ⚠️ RACE CONDITION

Critical finding. The implementation uses a check-then-act pattern (SELECT → process → INSERT) without any database-level lock. Two concurrent requests with the same event_id can both pass the SELECT (neither finds the event yet), both execute the handler (handleCheckoutCompleted, handleSubscriptionUpdated, etc.), and then both attempt INSERT. The onConflictDoNothing() prevents a duplicate row but does not prevent duplicate processing.

Recommended fix: Use an INSERT-first approach to claim the event atomically:

// Attempt to claim the event before processing
const [claimed] = await db
  .insert(stripeWebhookEvents)
  .values({ eventId: event.id, eventType: event.type, outcome: 'processing' })
  .onConflictDoNothing()
  .returning({ eventId: stripeWebhookEvents.eventId });

if (!claimed) {
  return c.json({ received: true, deduplicated: true });
}

// Process the event...
// On success: UPDATE outcome = 'success'
// On failure: DELETE the row so Stripe can retry

3. Event processing order — ℹ️ NOT HANDLED (acceptable)

No mechanism for out-of-order event handling (e.g., subscription.deleted arriving before subscription.updated). This is a known Stripe limitation and acceptable for the current scope, but worth a follow-up if subscription state conflicts arise.

4. Failed event processing is retryable — PASS

The INSERT into stripe_webhook_events only executes after successful processing. On error, the catch block logs to webhook_failures and returns HTTP 500, allowing Stripe to retry. Failed events are not marked as processed.

5. Test coverage — ⚠️ INCOMPLETE

Scenario Covered?
Duplicate event (same ID, sequential) Yes
Concurrent event (same ID, parallel) Missing
Invalid event ID Missing
Failed processing remains retryable Missing

The existing test is also fragile — it allows HTTP 500 for the first call (meaning the idempotency row may not be inserted), and only asserts deduplication conditionally (if (res1.status === 200)). This means the test can pass vacuously even when idempotency is completely broken.

Verdict: Needs revision before merge

The core deduplication logic is sound for sequential duplicates but has a real race condition for concurrent delivery — which is exactly the scenario Stripe's retry/failover system produces. The INSERT-first pattern above would close this gap with minimal changes.


Automated verification by Claude Code

## PR #519 Verification: Stripe Webhook Idempotency ### 1. Duplicate webhook delivery — ✅ PASS Second POST with the same `event_id` returns `{ received: true, deduplicated: true }` with HTTP 200 and no side effects. The SELECT check at `billing.ts:83-90` (in the PR) gates this correctly. ### 2. Concurrent processing of same event ID — ⚠️ RACE CONDITION **Critical finding.** The implementation uses a check-then-act pattern (SELECT → process → INSERT) without any database-level lock. Two concurrent requests with the same `event_id` can both pass the SELECT (neither finds the event yet), both execute the handler (`handleCheckoutCompleted`, `handleSubscriptionUpdated`, etc.), and then both attempt INSERT. The `onConflictDoNothing()` prevents a duplicate row but does **not** prevent duplicate processing. **Recommended fix:** Use an INSERT-first approach to claim the event atomically: ```ts // Attempt to claim the event before processing const [claimed] = await db .insert(stripeWebhookEvents) .values({ eventId: event.id, eventType: event.type, outcome: 'processing' }) .onConflictDoNothing() .returning({ eventId: stripeWebhookEvents.eventId }); if (!claimed) { return c.json({ received: true, deduplicated: true }); } // Process the event... // On success: UPDATE outcome = 'success' // On failure: DELETE the row so Stripe can retry ``` ### 3. Event processing order — ℹ️ NOT HANDLED (acceptable) No mechanism for out-of-order event handling (e.g., `subscription.deleted` arriving before `subscription.updated`). This is a known Stripe limitation and acceptable for the current scope, but worth a follow-up if subscription state conflicts arise. ### 4. Failed event processing is retryable — ✅ PASS The INSERT into `stripe_webhook_events` only executes after successful processing. On error, the catch block logs to `webhook_failures` and returns HTTP 500, allowing Stripe to retry. Failed events are **not** marked as processed. ### 5. Test coverage — ⚠️ INCOMPLETE | Scenario | Covered? | |---|---| | Duplicate event (same ID, sequential) | ✅ Yes | | Concurrent event (same ID, parallel) | ❌ Missing | | Invalid event ID | ❌ Missing | | Failed processing remains retryable | ❌ Missing | The existing test is also fragile — it allows HTTP 500 for the first call (meaning the idempotency row may not be inserted), and only asserts deduplication conditionally (`if (res1.status === 200)`). This means the test can pass vacuously even when idempotency is completely broken. ### Verdict: Needs revision before merge The core deduplication logic is sound for sequential duplicates but has a real race condition for concurrent delivery — which is exactly the scenario Stripe's retry/failover system produces. The INSERT-first pattern above would close this gap with minimal changes. --- *Automated verification by Claude Code*
Some checks failed
CI Quality Gate / Lint / Typecheck / Test / Build (pull_request) Has been cancelled
This pull request has changes conflicting with the target branch.
  • .forgejo/workflows/ci.yml
  • bun.lock
  • package.json
  • packages/api/src/db/schema.ts
  • packages/api/src/index.ts
  • packages/api/src/middleware/csrf.ts
  • packages/api/src/middleware/rate-limit.ts
  • packages/api/src/middleware/security-headers.ts
  • packages/api/src/routes/admin.ts
  • packages/api/src/routes/billing.ts
  • packages/api/src/routes/generate-tos.ts
  • packages/api/src/routes/generate.ts
  • packages/api/src/routes/health.ts
  • packages/api/src/routes/questionnaire.ts
  • packages/api/src/services/document-generator.ts
  • packages/api/src/services/llm.ts
  • packages/api/src/templates/index.ts
  • packages/api/tsconfig.json
  • packages/shared/src/types.ts
  • packages/web/src/app/questionnaire/page.tsx
  • packages/web/src/components/documents/DocumentList.tsx
  • packages/web/src/components/questionnaire/ReviewStep.tsx
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin agent-task/512:agent-task/512
git switch agent-task/512
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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
pook/compliancebot!519
No description provided.