Process Stripe webhooks serverlessly with retries and logs
Stripe retries webhooks for up to 72 hours on failure. Handle them correctly: verify the HMAC signature on the raw body, write an idempotency key before any mutation, acknowledge within 30 seconds, and continue fulfillment in a background pipeline.
Last updated: 2026-04-20
Answer first
Direct answer
Process Stripe webhooks serverlessly with retries and logs. One function for Stripe ingress: verify signature on raw body, write event ID as idempotency key, return 200 fast, trigger fulfillment pipeline.
When it fits
- payment_intent.succeeded, charge.refunded, subscription.updated, invoice.paid
- Any Stripe event that triggers fulfillment, email, or inventory changes
Tradeoffs
- Mixing user auth, payment webhook, and fulfillment in one endpoint makes the stripe-signature header logic fragile and couples scaling dimensions you want separate.
- Inline fulfillment (charge → fulfill → email in one request) means Stripe retry pressure hits your slowest operation.
Workload and what breaks
The three Stripe webhook failure modes
- Parsing body before HMAC verification: signature mismatch on valid events
- Slow inline fulfillment: Stripe retries after 30s, causing double-charges
- Missing idempotency key: retry delivers event twice, two orders created
Stripe signs webhooks with HMAC-SHA256 over the raw body. If you JSON.parse before verifying, whitespace normalization can break the signature. If your handler runs fulfillment synchronously and takes over 30 seconds, Stripe retries—and without idempotency, that means duplicate side effects.
Where shortcuts fail
Why monolithic API handlers struggle with Stripe
Mixing user auth, payment webhook, and fulfillment in one endpoint makes the stripe-signature header logic fragile and couples scaling dimensions you want separate.
Inline fulfillment (charge → fulfill → email in one request) means Stripe retry pressure hits your slowest operation.
How Inquir helps
Stripe webhook pattern on Inquir
One function for Stripe ingress: verify signature on raw body, write event ID as idempotency key, return 200 fast, trigger fulfillment pipeline.
The fulfillment pipeline runs outside the HTTP window—no Stripe timeout pressure. Retries apply per step; execution history shows every event processed.
What you get
Stripe webhook implementation checklist
Raw body for HMAC
event.body arrives as a string—never JSON.parse before stripe.webhooks.verifySignature.
Idempotency key
Upsert evt.id before any mutation. If the upsert finds an existing record, skip and return 200.
Fast ACK
Return 200 before starting fulfillment. Stripe expects response within 30 seconds.
Pipeline for fulfillment
Trigger async pipeline for order fulfillment, email, and inventory updates after ACK.
What to do next
Stripe webhook flow
Verify HMAC on raw body
Read event.body as string. Call stripe.webhooks.verifySignature with raw body and stripe-signature header.
Write idempotency key, return 200
Upsert evt.id in your events table. Return immediately—do not start fulfillment yet.
Trigger fulfillment orchestration
Call global.durable.startNew with event type and data. The orchestration handles retries, email, and inventory updates independently.
Code example
Stripe webhook handler
Complete pattern: raw body, signature verify, idempotency key, fast ACK, async fulfillment pipeline.
export async function handler(event) { const rawBody = event.body ?? ''; const sig = event.headers['stripe-signature'] ?? ''; let evt; try { evt = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET); } catch (err) { return { statusCode: 400, body: `Webhook Error: ${err.message}` }; } const isNew = await db.webhookEvents.upsert({ id: evt.id, type: evt.type }); if (!isNew) return { statusCode: 200, body: 'duplicate' }; if (evt.type === 'payment_intent.succeeded') { await global.durable.startNew('stripe-fulfill', undefined, { intentId: evt.data.object.id, amount: evt.data.object.amount }); } return { statusCode: 200, body: JSON.stringify({ received: true }) }; }
When it fits
Use this pattern for
When this works
- payment_intent.succeeded, charge.refunded, subscription.updated, invoice.paid
- Any Stripe event that triggers fulfillment, email, or inventory changes
When to skip it
- Read-only Stripe events where duplicate delivery has no side effects
FAQ
FAQ
Which Stripe events should I handle?
payment_intent.succeeded for purchase fulfillment; invoice.payment_failed for subscription dunning; customer.subscription.deleted for access revocation.
How do I test without real Stripe traffic?
Use stripe listen --forward-to <local URL> for local testing, or stripe trigger payment_intent.succeeded to send a test event to your deployed function.