Comprehensive audit of ALL webhook handlers — Stripe and POD provider (Printify/Printful). Covers signature verification, idempotency guarantees, error isolation, retry safety, database transaction consistency, and monitoring. Use when asked to audit webhooks, idempotency, event handling, or webhook security.
Installs into .claude/skills of the current project.
Are you the author of Audit Webhooks Idempotency?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/lroy-stack-audit-webhooks-idempotency)
---
name: Webhooks & Idempotency Audit
description: >
Comprehensive audit of ALL webhook handlers — Stripe and POD provider (Printify/Printful).
Covers signature verification, idempotency guarantees, error isolation, retry safety, database
transaction consistency, and monitoring. Use when asked to audit webhooks, idempotency, event
handling, or webhook security.
allowed-tools: Read, Grep, Glob, Bash
---
# Webhooks & Idempotency Audit
Systematic audit of every webhook endpoint: signature verification, idempotent event processing, error isolation between event types, database transaction consistency, retry safety (always return 200 after signature verification), audit logging, and monitoring/alerting gaps.
## Prerequisites
Before starting, read these files for architectural context:
- `CLAUDE.md` — component standards, Supabase connection reference, Stripe/Printful details
- `memory/MEMORY.md` — current project state, known blockers, connection details
## File Map
Keep this reference open throughout the audit. All paths relative to `frontend/src/`.
### Stripe Webhook
| File | Role |
|---|---|
| `app/api/webhooks/stripe/route.ts` (1002 lines) | Main Stripe webhook handler — all Stripe events |
**Events handled in Stripe webhook:**
| Event | Purpose | Lines (approx) |
|---|---|---|
| `checkout.session.completed` | Creates order from paid session | 90-200 |
| `subscription.created` | Activates user subscription | 200-300 |
| `subscription.updated` | Updates subscription tier/status | 300-400 |
| `subscription.deleted` | Deactivates subscription | 400-450 |
| `payment_intent.succeeded` | Credit purchase fulfillment | 450-550 |
| `payment_intent.failed` | Failed payment logging | 550-600 |
| `invoice.payment_failed` | Subscription payment failure handling | 600-700 |
| `charge.dispute.created` | Dispute/chargeback handling | 700-800 |
**Key idempotency mechanisms:**
- Order creation: checks `orders.stripe_session_id` before INSERT (lines 151-162)
- Credit transactions: UNIQUE constraint on `(user_id, stripe_payment_id)` in `credit_transactions`
- Always returns 200 after successful signature verification
### POD Provider Webhooks
| File | Role |
|---|---|
| `app/api/webhooks/pod/[provider]/route.ts` (146 lines) | Dynamic route for Printify/Printful webhooks |
**Provider-specific signature verification:**
- Printify: HMAC signature verification
- Printful: Secret parameter verification
**Event normalization:**
- `providerInstance.normalizeEvent()` — converts provider-specific event to common format
### Webhook Event Handlers
| File | Role |
|---|---|
| `lib/pod/webhooks/handlers/order-shipped.ts` (167 lines) | Updates order status, sends shipping email |
| `lib/pod/webhooks/handlers/order-delivered.ts` | Updates order status, sends delivery email |
| `lib/pod/webhooks/handlers/order-cancelled.ts` | Updates order status, sends cancellation email |
### Supporting Infrastructure
| File | Role |
|---|---|
| `lib/stripe.ts` | Stripe client initialization |
| `lib/supabase-admin.ts` | Admin Supabase client (used in webhooks for RLS bypass) |
| `lib/pod/providers/` | Provider implementations with event normalization |
### Database Tables Affected by Webhooks
| Table | Webhook Source | Operation |
|---|---|---|
| `orders` | Stripe (checkout.session.completed) | INSERT |
| `order_items` | Stripe (checkout.session.completed) | INSERT |
| `notifications` | Stripe + POD | INSERT |
| `audit_log` | POD webhooks | INSERT |
| `credit_transactions` | Stripe (payment_intent.succeeded) | INSERT |
| `users` | Stripe (subscription.*) | UPDATE (tier, subscription_status) |
---
## Workflow
Execute each phase sequentially. Record every finding with severity, file path, and line number.
### Phase 1: Stripe Webhook Signature Verification (6 checks)
#### 1.1 Signature Construction
- Read `app/api/webhooks/stripe/route.ts` — lines 1-60
- **Check**: `stripe.webhooks.constructEvent()` is called with raw body (not parsed JSON)
- **Check**: Raw body is obtained via `request.text()` or equivalent (not `request.json()`)
- **Check**: Webhook signing secret is from env var (`STRIPE_WEBHOOK_SECRET`)
- **Check**: Secret is not hardcoded or logged
- **Severity**: CRITICAL if signature verification is missing or uses parsed body
#### 1.2 Signature Failure Handling
- **Check**: Invalid signature returns HTTP 400 (not 200 — must not acknowledge bad events)
- **Check**: Error message does not leak the webhook secret or internal details
- **Check**: Failed verification is logged for monitoring
#### 1.3 Event Type Routing
- **Check**: Event type is extracted from verified event object (not from request body before verification)
- **Check**: Unknown event types are handled gracefully (logged and return 200)
- **Check**: Switch/if-else covers all documented event types
#### 1.4 HTTP Method
- **Check**: Only POST is accepted (GET returns 405)
- **Check**: Content-Type is not strictly enforced (Stripe sends various types)
#### 1.5 Replay Protection
- **Check**: Stripe's built-in timestamp tolerance is not overridden to an unsafe value
- **Check**: Events older than 5 minutes (Stripe default) are rejected by `constructEvent`
- **Benchmark**: Stripe default tolerance is 300 seconds — do not increase this
#### 1.6 Multiple Webhook Endpoints
- Search for other Stripe webhook routes in the codebase
- **Check**: There is only ONE Stripe webhook endpoint (multiple endpoints = duplicate processing risk)
- **Check**: If multiple exist, they handle disjoint event sets
### Phase 2: POD Webhook Signature Verification (5 checks)
#### 2.1 Provider Routing
- Read `app/api/webhooks/pod/[provider]/route.ts`
- **Check**: Dynamic `[provider]` parameter is validated against allowed list (printify, printful)
- **Check**: Unknown provider returns 400 (not 500 or unhandled error)
#### 2.2 Printify Signature
- Search for HMAC, signature verification in POD webhook and provider files
- **Check**: HMAC-SHA256 signature is verified against request body
- **Check**: Printify webhook secret is from env var
- **Check**: Timing-safe comparison is used (not `===`)
- **Severity**: CRITICAL if timing-safe comparison is missing (timing attack vulnerability)
#### 2.3 Printful Signature
- **Check**: Printful webhook secret/token verification is implemented
- **Check**: Secret is from env var
- **Check**: Verification happens before any event processing
#### 2.4 Event Normalization
- **Check**: `providerInstance.normalizeEvent()` converts to a common event schema
- **Check**: Normalization failures are caught and logged (not thrown to crash handler)
- **Check**: Normalized event includes: event_type, order_id, provider, timestamp, raw_data
#### 2.5 Audit Logging
- **Check**: Every POD webhook event is logged to `audit_log` table
- **Check**: Log includes: provider, event_type, payload (sanitized), timestamp, processing_result
- **Check**: Failed events are logged with error details
### Phase 3: Idempotency — Stripe Events (8 checks)
#### 3.1 Order Creation Idempotency
- Read `app/api/webhooks/stripe/route.ts` — checkout.session.completed handler
- **Check**: Before INSERT into `orders`, handler checks if order with same `stripe_session_id` exists
- **Check**: If order already exists, handler returns 200 without creating duplicate
- **Check**: The idempotency check and INSERT are in the same transaction (no TOCTOU race)
- **Severity**: CRITICAL if duplicate orders can be created on webhook retry
#### 3.2 Credit Transaction Idempotency
- Read payment_intent.succeeded handler
- **Check**: `credit_transactions` table has UNIQUE constraint on `(user_id, stripe_payment_id)`
- **Check**: Duplicate INSERT is caught via unique constraint violation (not pre-check only)
- **Check**: Constraint violation returns 200 (not 500 — Stripe would retry on 500)
#### 3.3 Subscription Idempotency
- Read subscription.created/updated/deleted handlers
- **Check**: Subscription updates are idempotent (applying same update twice = same result)
- **Check**: Subscription status transitions are valid (can't go from cancelled to active without new subscription)
- **Check**: `subscription_id` is stored and checked for dedup
#### 3.4 Payment Failed Idempotency
- Read payment_intent.failed and invoice.payment_failed handlers
- **Check**: Failed payment logging is idempotent (duplicate fail events don't create duplicate notifications)
- **Check**: User is not spammed with multiple failure notifications for same event
#### 3.5 Dispute Idempotency
- Read charge.dispute.created handler
- **Check**: Dispute handling is idempotent
- **Check**: Order status update from dispute is idempotent (setting "disputed" on already "disputed" order is no-op)
#### 3.6 Stripe Event ID Tracking
- Search for `event.id`, `stripe_event_id` in webhook handler
- **Check**: Is Stripe event ID (`evt_xxx`) stored and checked for global dedup?
- **Check**: If not, assess risk — Stripe can send same event ID multiple times on retry
- **Benchmark**: Best practice is to store processed event IDs in a dedup table with TTL
#### 3.7 Database Transaction Boundaries
- Search for `BEGIN`, `TRANSACTION`, `.rpc(`, `supabase.from(...).insert(...).select(` patterns
- **Check**: Order creation (order + order_items + notification) is in a single transaction
- **Check**: If any step fails, all steps are rolled back (not partial order)
- **Check**: Supabase client supports transactions (or uses RPC for atomic operations)
- **Severity**: FAIL if order can be created without order_items (partial write)
#### 3.8 Concurrent Webhook Handling
- **Check**: Two concurrent webhooks for different events don't interfere (e.g., subscription update + order creation)
- **Check**: Two concurrent webhooks for SAME event (retry) are handled via idempotency checks
- **Check**: Database locks (row-level or advisory) are used where needed
### Phase 4: Idempotency — POD Events (5 checks)
#### 4.1 Order Shipped Idempotency
- Read `lib/pod/webhooks/handlers/order-shipped.ts`
- **Check**: Shipping update is idempotent (setting status to "shipped" on already "shipped" order is no-op)
- **Check**: Tracking number update is idempotent (same tracking = no duplicate notification)
- **Check**: Email is not sent again if order is already "shipped"
- **Severity**: FAIL if duplicate shipping emails are sent
#### 4.2 Order Delivered Idempotency
- Read `lib/pod/webhooks/handlers/order-delivered.ts`
- **Check**: Delivery update is idempotent
- **Check**: Duplicate delivery webhook does not re-send email
#### 4.3 Order Cancelled Idempotency
- Read `lib/pod/webhooks/handlers/order-cancelled.ts`
- **Check**: Cancellation is idempotent
- **Check**: Refund is not triggered twice on duplicate cancellation events
- **Check**: If refund is triggered, it has its own idempotency (Stripe refund idempotency key)
- **Severity**: CRITICAL if duplicate refunds are possible
#### 4.4 Status Transition Validation
- **Check**: Order status follows valid transitions (pending -> processing -> shipped -> delivered)
- **Check**: Invalid transitions are rejected (can't go from "delivered" back to "processing")
- **Check**: Transition validation is in the handler (not just in the DB)
#### 4.5 Event Dedup at POD Level
- **Check**: POD webhook events have a unique event ID from the provider
- **Check**: Event ID is stored and checked before processing
- **Check**: If no provider event ID, what prevents duplicate processing?
### Phase 5: Error Isolation & Retry Safety (7 checks)
#### 5.1 Always Return 200
- Read both webhook route files
- **Check**: After successful signature verification, handler ALWAYS returns 200
- **Check**: Even if internal processing fails (DB error, email error), response is still 200
- **Check**: Only signature verification failure returns non-200 (400)
- **Severity**: CRITICAL if processing errors return 500 (Stripe will retry endlessly)
#### 5.2 Error Isolation Between Events
- **Check**: Failure in one event handler does not affect other events
- **Check**: Each event type has its own try-catch block
- **Check**: A crash in subscription handler doesn't prevent order creation handler from working
#### 5.3 Email Sending Failure
- Search for email sending in webhook handlers (Resend, nodemailer, etc.)
- **Check**: Email sending failure is caught and logged (not thrown)
- **Check**: Order/status update succeeds even if email fails
- **Check**: Failed emails are queued for retry (or at least logged for manual follow-up)
- **Severity**: FAIL if email failure causes order creation to fail
#### 5.4 Database Error Handling
- **Check**: Supabase errors (connection timeout, constraint violation) are caught
- **Check**: Caught DB errors are logged with context (event type, event ID, error message)
- **Check**: DB errors return 200 (with internal logging) — not 500
#### 5.5 External Service Failures
- **Check**: Printful API calls from webhook handlers (e.g., fetching order details) handle timeouts
- **Check**: If external call fails, webhook still returns 200 (with internal error logging)
- **Check**: Retry logic for external calls has backoff (not infinite retry)
#### 5.6 Payload Size Limits
- **Check**: Webhook endpoint has body size limit (large payloads could be attack vector)
- **Check**: Next.js default body limit is appropriate (or explicitly configured)
- **Check**: Oversized payloads are rejected before processing
#### 5.7 Timeout Handling
- **Check**: Webhook handler completes within Stripe's timeout (20 seconds)
- **Check**: Long-running operations (email, Printful API) are async/queued (not blocking response)
- **Check**: If processing takes too long, response is still sent and processing continues in background
- **Benchmark**: Stripe expects response within 20 seconds; retries on timeout
### Phase 6: Database Consistency (6 checks)
#### 6.1 Order Creation Atomicity
- Read checkout.session.completed handler in detail
- **Check**: `orders` INSERT and `order_items` INSERTs happen atomically
- **Check**: If `order_items` INSERT fails, the `orders` row is rolled back
- **Check**: `notifications` INSERT failure does not roll back the order
- **Severity**: CRITICAL if partial orders can exist (order without items)
#### 6.2 User Tier Update Consistency
- Read subscription.* handlers
- **Check**: User `tier` and `subscription_status` updates are atomic
- **Check**: Subscription metadata (plan_id, period_end) is updated consistently
- **Check**: Downgrade cleans up tier-specific data (or marks for cleanup)
#### 6.3 Credit Balance Integrity
- Read payment_intent.succeeded handler
- **Check**: Credit is added atomically (INSERT credit_transaction + UPDATE user balance)
- **Check**: UNIQUE constraint prevents double-credit
- **Check**: If UPDATE fails after INSERT, balance is inconsistent — is this handled?
- **Severity**: CRITICAL if users can receive double credits
#### 6.4 Order Status Updates
- Read POD webhook handlers
- **Check**: Status update uses UPDATE with WHERE clause including current status (optimistic locking)
- **Check**: If UPDATE matches 0 rows (status already changed), handler returns success
- **Check**: Status history is preserved (audit trail of status changes)
#### 6.5 Notification Creation
- **Check**: Notifications are created for: order placed, shipped, delivered, cancelled, payment failed
- **Check**: Notification creation failure is non-blocking (order still processes)
- **Check**: Notifications are user-scoped (correct user_id)
#### 6.6 Audit Log Completeness
- Search for `audit_log` INSERTs across all webhook handlers
- **Check**: All event types are logged (not just POD events)
- **Check**: Log entries include: event_type, source (stripe/printify/printful), payload_hash, result, error
- **Check**: Sensitive data is redacted from audit log (no card numbers, no full addresses)
### Phase 7: Monitoring, Alerting & Operational Readiness (6 checks)
#### 7.1 Webhook Secret Rotation
- **Check**: Is there a documented process for rotating webhook secrets?
- **Check**: Can secrets be rotated without downtime (e.g., support for old+new secret during transition)?
- **Check**: Stripe supports multiple webhook signing secrets — is this leveraged?
- **Benchmark**: Secrets should be rotated at least annually
#### 7.2 Failed Webhook Monitoring
- Search for monitoring, alerting, error reporting integrations (Sentry, LogRocket, etc.)
- **Check**: Failed webhook processing is reported to an error tracking service
- **Check**: Alerts fire when webhook failure rate exceeds threshold
- **Check**: Dashboard or log query exists for webhook health
#### 7.3 Dead Letter Queue
- Search for DLQ, dead letter, failed_events, retry_queue patterns
- **Check**: Events that fail processing after all retries are stored for manual review
- **Check**: If no DLQ exists, how are permanently failed events handled?
- **Benchmark**: Production systems should have a DLQ or equivalent
#### 7.4 Webhook Health Endpoint
- Search for health check, status endpoint related to webhooks
- **Check**: Is there a way to verify webhook connectivity (e.g., test event processing)?
- **Check**: Stripe dashboard shows webhook delivery status — is it monitored?
#### 7.5 Rate Limiting on Webhook Endpoints
- **Check**: Webhook endpoints are not rate-limited (they should accept all events)
- **Check**: However, abuse protection exists (e.g., reject events from non-Stripe IPs)
- **Check**: Body size limits protect against payload abuse
#### 7.6 Logging Standards
- **Check**: All webhook logs include correlation ID (Stripe event ID or provider event ID)
- **Check**: Log levels are appropriate (INFO for success, ERROR for failure, WARN for dedup skip)
- **Check**: Logs are structured (JSON format) for searchability
- **Check**: PII is not logged (no email addresses, names, or addresses in logs)
---
## Output Format
Generate `AUDIT_WEBHOOKS_IDEMPOTENCY_[DATE].md` at the workspace root with the following structure:
```markdown
# Webhooks & Idempotency Audit — [DATE]
## Executive Summary
[2-3 paragraph overview: what is solid, what has gaps, what is at risk. State whether webhooks are production-safe for payment processing and order fulfillment.]
## Scorecard
| Category | Checks | Pass | Warn | Fail | Critical | Score |
|---|---|---|---|---|---|---|
| Stripe Signature Verification | 6 | X | X | X | X | X% |
| POD Signature Verification | 5 | X | X | X | X | X% |
| Idempotency — Stripe Events | 8 | X | X | X | X | X% |
| Idempotency — POD Events | 5 | X | X | X | X | X% |
| Error Isolation & Retry Safety | 7 | X | X | X | X | X% |
| Database Consistency | 6 | X | X | X | X | X% |
| Monitoring & Operations | 6 | X | X | X | X | X% |
| **TOTAL** | **43** | **X** | **X** | **X** | **X** | **X%** |
## Findings
| ID | Severity | Category | Finding | File:Line | Recommendation |
|---|---|---|---|---|---|
| WH-001 | CRITICAL | Idempotency | [description] | `src/app/api/webhooks/stripe/route.ts:XX` | [fix] |
| WH-002 | FAIL | Error Isolation | [description] | `src/lib/pod/webhooks/handlers/order-shipped.ts:XX` | [fix] |
| WH-003 | WARN | Monitoring | [description] | N/A | [fix] |
| ... | ... | ... | ... | ... | ... |
## Critical Blockers (Must Fix Before Production)
1. [WH-001] — [description and fix]
2. ...
## High Priority (Fix Within Sprint)
1. [WH-0XX] — [description]
2. ...
## Warnings (Fix Before V2)
1. [WH-0XX] — [description]
2. ...
## Priority Actions
1. **Immediate** (before launch): [list CRITICAL items — any idempotency gaps on payment/order events]
2. **This sprint**: [list FAIL items — error isolation, email failure handling]
3. **Next sprint**: [list WARN items — monitoring, DLQ, secret rotation]
## Webhook Event Matrix
| Event | Source | Idempotency Key | Dedup Mechanism | Email Sent | Status |
|---|---|---|---|---|---|
| checkout.session.completed | Stripe | stripe_session_id | SELECT before INSERT | Order confirmation | [PASS/FAIL] |
| subscription.created | Stripe | subscription_id | [describe] | Welcome email | [PASS/FAIL] |
| subscription.updated | Stripe | subscription_id | [describe] | None | [PASS/FAIL] |
| subscription.deleted | Stripe | subscription_id | [describe] | Cancellation email | [PASS/FAIL] |
| payment_intent.succeeded | Stripe | stripe_payment_id | UNIQUE constraint | Credit confirmation | [PASS/FAIL] |
| payment_intent.failed | Stripe | [describe] | [describe] | Failure notice | [PASS/FAIL] |
| invoice.payment_failed | Stripe | [describe] | [describe] | Payment reminder | [PASS/FAIL] |
| charge.dispute.created | Stripe | [describe] | [describe] | None (admin alert) | [PASS/FAIL] |
| order.shipped | POD | [describe] | [describe] | Shipping notification | [PASS/FAIL] |
| order.delivered | POD | [describe] | [describe] | Delivery notification | [PASS/FAIL] |
| order.cancelled | POD | [describe] | [describe] | Cancellation notice | [PASS/FAIL] |
```
## Severity Levels
- **CRITICAL**: Double charges, duplicate orders, duplicate refunds, missing signature verification, data corruption. Blocks production launch.
- **FAIL**: Missing idempotency on non-financial events, email spam on retry, partial database writes. Must fix before release.
- **WARN**: Missing monitoring, no DLQ, missing audit logs, no secret rotation plan. Should fix before V2.
- **PASS**: Meets standards and payment processing best practices.
## Notes
- Always verify findings against actual code (read the file, cite the line number). Never report speculative issues.
- Webhooks are the backbone of payment processing and order fulfillment. Any CRITICAL finding is a hard launch blocker.
- Pay special attention to the "return 200" rule: after signature verification passes, the handler MUST return 200 regardless of internal errors. Returning 500 causes Stripe to retry, potentially creating duplicates or causing webhook suspension.
- For idempotency checks, verify not just that the check exists, but that it is atomic with the write (no TOCTOU window).
- For each CRITICAL or FAIL finding, include a concrete code-level recommendation with the specific fix.
- The Webhook Event Matrix in the output must be filled with real data from the codebase (actual idempotency keys used, not assumptions).