8 min read
Idempotent Stripe webhooks for AI-built products
Stripe delivers events at least once and not always in order. Here is how to verify, record and process webhooks so retries never double-fulfill an order or miss a cancellation.
Idempotent Stripe webhooks produce the same result no matter how many times an event arrives. Verify the signature on the raw body, record each event ID before acting, derive state from the object's current status rather than event order, and acknowledge quickly while slow work runs in a retried job. That prevents double fulfillment and missed cancellations.
Why webhook handling breaks in AI-built products
Stripe delivers events at least once. If your endpoint times out, returns an error or the network drops the response, Stripe retries, and it keeps retrying for an extended period. Events can also arrive out of order: a subscription update can land before the checkout completion that preceded it.
Generated code usually handles the happy path: a single event arriving once, in order. A typical AI-built checkout either creates an order in the browser after the success redirect, or inserts a row on every checkout.session.completed event without checking whether that session was already processed. It works in testing because testing sends one event at a time.
The resulting failures are expensive and quiet: a customer charged once and credited twice, a subscription canceled in Stripe but still active in your database, a welcome email sent three times, or fulfillment that never happens because the buyer closed the tab before the success page loaded.
What teams often get wrong
- Treating the success redirect as proof of payment. Redirects can be skipped, replayed or forged; the webhook is the source of truth.
- Parsing the JSON before verifying the signature. Verification needs the exact raw body, and a framework that parses it first breaks verification or tempts someone to disable it.
- Doing slow work inline, such as generating documents, calling a model or sending email. The handler times out, Stripe retries and the work runs again.
- Using increments such as adding credits on each event instead of absolute state tied to the invoice, so a replay adds more.
- Trusting the event payload as current state. An old event carries an old status.
- Returning success on internal failure to stop retries, which silently drops the event.
A practical architecture
Split the handler into three steps: accept, record and process. Each step has one job, and each can fail in a way you can see.
- Accept: read the raw body, verify the signature with the endpoint's signing secret and reject with a client error if verification fails. Check the event type against the list you actually handle and acknowledge the rest.
- Record: insert the event ID into a processed-events table with a unique constraint, in the same transaction as any state change, or mark it received and enqueue it. If the insert conflicts, the event was already handled, so return success.
- Process: apply the change as absolute state. For subscriptions, read the current subscription from Stripe, or compare the object's status and timestamp with what you stored, and set your record to match. For one-time purchases, key fulfillment on the checkout session or payment intent ID so a second attempt finds the existing order.
Respond with a success status once the event is safely recorded. If recording fails, return a server error so Stripe retries. Slow side effects go to a job queue with their own idempotency key, typically derived from the event or object ID, so a retried job does not send a second email.
Idempotency on outbound calls too
When your code calls Stripe, for example to create a refund, a subscription or a customer, send an idempotency key derived from your own record, such as the order ID plus the action name. If your request is retried after a timeout, Stripe returns the original result instead of performing the action twice. This is the other half of idempotent payments and is easy to forget.
Implementation considerations
Choose the unit of deduplication carefully. The event ID prevents processing the same delivery twice. The business key, whether a checkout session, invoice or payment intent, prevents two different events from fulfilling the same purchase. Most systems need both.
Handle ordering by comparing, not assuming. Store the Stripe object's last-seen timestamp on your record and ignore updates older than what you have. For subscriptions, re-reading the current object on each relevant event is simple and removes most ordering bugs, at the cost of an extra API call per event.
Keep the webhook route away from anything that consumes or redirects the request: body-parsing middleware, localization redirects and authentication middleware that expects a user session. In serverless frameworks, read the request as text and pass it to the verification function unchanged.
Use separate endpoints and signing secrets for test and live mode, and never expose the signing secret to the browser. Log the event ID and type on every delivery so support questions can be answered from logs rather than guesswork.
Build a replay path. The Stripe dashboard and CLI can resend events, and your own tooling should be able to reprocess a stored event by ID. When the handler is idempotent, replay becomes a safe recovery tool rather than a risk.
A minimal data model
You do not need much schema to get this right. Three small records cover most products:
- Processed events: event ID as the unique key, event type, received time and processing status. This is the ledger that makes duplicates harmless.
- Orders or entitlements: keyed by the Stripe business object, such as the checkout session, invoice or subscription, with the current status and the timestamp of the last Stripe change you applied.
- Outbound actions: refunds, emails and fulfillment jobs keyed by an idempotency key derived from the order and the action, so a retried job finds its earlier result.
With those three in place, every question support asks, such as whether a customer paid, whether access was granted and whether the refund went through, can be answered from your own database instead of by cross-checking dashboards.
Trade-offs
A processed-events table and a queue add moving parts. For a low-volume product, a unique constraint on the business key plus absolute state writes inside the handler may be enough, as long as the work is fast and failures return a server error.
Re-fetching objects from Stripe improves correctness but adds latency and API usage. Relying on stored timestamps avoids the extra call but needs careful handling of equal timestamps and missing fields.
Leaning on Stripe-hosted features such as Checkout, the customer portal and Stripe-managed subscriptions reduces the number of events you must interpret. The trade-off is less control over the flow and the interface, which is usually a good trade for an early product.
Lessons from ImadDhin work
The ImadDhin portal's billing webhook handles a different payment provider than Stripe, but the same principles apply. These are code-level observations, not claims about transaction volumes.
The handler reads the raw body, verifies the provider signature before parsing and rejects unverified requests. When the payload lacks the subscription identifier or status, it reads the subscription from the provider instead of trusting partial data. State writes are absolute: paid access is set to active or inactive, not incremented, so a replayed event does not stack benefits. If the write fails, it returns a server error so the provider retries instead of dropping the event.
The pattern we check hardest in AI-built payment code is ordering. Absolute writes and a processed-event ledger stop duplicates, but they do not stop a delayed activation event that arrives after a cancellation from setting access back to active. The fix is small: store the last applied event time per subscription and ignore anything older, or read the current status from the provider before every write. It is the most common gap we find when reviewing generated webhook handlers, and it deserves its own test.
Common mistakes to test for
- Send the same event twice with the Stripe CLI and confirm one order, one email and one credit grant.
- Deliver a subscription update before its creation event and confirm the final state is correct.
- Force the database write to fail and confirm the endpoint returns a server error and the retry succeeds.
- Change one byte of a signed payload and confirm it is rejected.
- Close the browser before the success page loads and confirm fulfillment still happens.
- Cancel a subscription in the Stripe dashboard and confirm access is removed in your app.
- Retry an outbound refund call after a simulated timeout and confirm exactly one refund exists.
When a simpler solution is better
If you sell a handful of one-time products, Stripe Payment Links or Checkout with manual fulfillment and email receipts may be enough until volume justifies automation. If you only need subscriptions gated by an entitlement, a billing platform that manages entitlements can replace most of your custom webhook logic. Build custom handling when your fulfillment genuinely needs it, not because the generated code already started down that road.
Make payments correct before you make them clever
Payments are where AI-built apps most often fail quietly. For the broader production path, see how to turn a Lovable or v0 prototype into a production application. For agents that spend money or write to other systems, the limits of vibe-coded agents explains why approval gates and cost ceilings belong in the design. If your checkout was generated and you are not sure it survives retries, vibe-code rescue covers payment hardening, and a 30-minute call is a good place to start.
Frequently asked questions
Does Stripe guarantee each webhook event is delivered once?
No. Delivery is at least once, and events can arrive out of order. Your handler must tolerate duplicates and older events arriving after newer ones.
Should I deduplicate by event ID or by object ID?
Both. The event ID stops the same delivery being processed twice, and the business key such as the checkout session or invoice stops two different events from fulfilling the same purchase.
How quickly should my webhook endpoint respond?
As soon as the event is verified and safely recorded. Move slow work such as email, document generation or model calls to a background job so timeouts do not trigger repeat processing.
Can I fulfill orders from the success redirect instead?
Use the redirect for the confirmation screen only. Fulfillment should be driven by verified webhook events, because redirects can be skipped, closed early or replayed.
Do I need a job queue for Stripe webhooks?
Not always. Low-volume products with fast handlers can rely on unique constraints and absolute state writes. Add a queue when side effects are slow or must be retried independently.
Harden the checkout before it scales
Walk through your webhook handler and the retry cases it needs to survive.
Book a 30-minute callPayment hardening is part of every prototype-to-production engagement.
See vibe-code rescueChoose the option for an existing prototype.
Start a briefKeep reading
How to turn a Lovable/v0 prototype into a production application
AI builders ship demos fast. Production needs auth, payments, secrets hygiene, observability, and an architecture that survives real users — here is the hardening path I use.
The limits of building agents only from vibe-code tools — and what it costs you at scale
Vibe-coded agents demo brilliantly and stall in production. Here is the exact wall they hit, what has to be rebuilt, and how to keep the speed without paying for it twice.
Secrets in AI-generated apps: moving keys out of the client bundle
AI app builders often wire provider keys straight into browser code. Here is how to classify credentials, move secret-bearing calls server-side, rotate what shipped and stop it from coming back.
Rewrite or rescue an AI-built app? A decision guide
Whether to rewrite or rescue an AI-built app depends on the data model, authorization and side effects, not on how the code looks. Here is a practical way to decide before you spend the budget.