Stripe retries webhook delivery up to 3 days when it doesn't receive a 200 response within 30 seconds. If your webhook handler performs a plain db.orders.insert() keyed only on customer data — not on event.id or paymentIntent.id — every retry creates a duplicate order record, triggers a duplicate fulfillment, and sends the customer a duplicate confirmation email. CWE-362 (Race Condition) applies when two concurrent deliveries of the same event race to insert the same order. This is not a theoretical failure: network blips during high-traffic events (flash sales, holiday peaks) cause Stripe retries at exactly the moment your database is under maximum load.
Low because non-idempotent webhook handlers create duplicate orders and emails on every Stripe retry, with retry frequency increasing precisely when database load causes initial delivery to timeout.
Deduplicate by recording processed event IDs in a database table before executing business logic.
export async function POST(req: Request) {
// ... signature verification ...
await db.$transaction(async (tx) => {
// Mark the event as processed first — unique constraint prevents double-processing
await tx.processedWebhookEvents.create({ data: { stripeEventId: event.id } })
switch (event.type) {
case 'payment_intent.succeeded':
// Upsert instead of insert for additional safety
await tx.orders.upsert({
where: { stripePaymentIntentId: event.data.object.id },
create: { /* order fields */ },
update: { status: 'confirmed' },
})
break
}
})
return new Response('OK', { status: 200 })
}
Add a unique index on processed_webhook_events.stripe_event_id. The unique constraint causes the second delivery's create to throw, which is caught by the $transaction and aborted cleanly.
ID: ecommerce-payment-security.payment-errors.webhook-idempotent-processing
Severity: low
What to look for: Check whether webhook handlers deduplicate events before processing. List all state-changing operations in webhook handlers (inserts, updates, email sends) and for each, classify as idempotent (upsert, uniqueness constraint, dedup check) or non-idempotent (plain insert without constraint). Look for checks against a processed_events database table or cache keyed by event.id before performing state changes. Count the total write operations and the ratio that are idempotent.
Pass criteria: Webhook handlers check whether the event ID has already been processed before executing business logic. 100% of database writes use upsert or uniqueness constraints keyed on the event ID or payment intent ID. Processing the same event twice produces the same outcome as processing it once.
Fail criteria: Webhook handlers execute state-changing operations (insert order, decrement inventory, send email) without any deduplication check. The same event arriving twice (due to network retry) would create duplicate records or send duplicate emails.
Skip (N/A) when: The project has no webhook handlers implemented.
Detail on fail: Describe the non-idempotent pattern. Example: "Webhook handler calls db.orders.insert() on payment_intent.succeeded without checking if an order with that paymentIntentId already exists — Stripe retries will create duplicate orders"
Remediation: Deduplicate events by tracking processed event IDs:
export async function POST(req: Request) {
// ... signature verification ...
// Check if this event was already processed
const existing = await db.processedWebhookEvents.findUnique({
where: { stripeEventId: event.id }
})
if (existing) {
return new Response('Already processed', { status: 200 })
}
// Mark as processed first (within a transaction) to prevent races
await db.$transaction(async (tx) => {
await tx.processedWebhookEvents.create({ data: { stripeEventId: event.id } })
switch (event.type) {
case 'payment_intent.succeeded':
// Use upsert instead of insert for additional idempotency
await tx.orders.upsert({
where: { stripePaymentIntentId: event.data.object.id },
create: { /* ... */ },
update: { status: 'confirmed' }, // Safe to update if already exists
})
break
}
})
return new Response('OK', { status: 200 })
}