If you use Stripe webhooks in a SaaS app, you need to think about idempotency. Not because Stripe is unreliable, but because webhooks are delivered over the internet, your server can fail halfway through a request, and payment events can be retried or resent.
The keyword here is Stripe webhook idempotency. It means your webhook can receive the same event more than once and still produce the correct result only once.
That sounds like a small backend detail until the duplicate action is a welcome email, a paid workspace, a credit grant, an invoice record, or a subscription entitlement. Then it becomes production behavior.
What is Stripe webhook idempotency?
Idempotency means an operation can run multiple times and leave the system in the same state as running once.
For Stripe webhooks, the practical version is simple:
If Stripe sends the same webhook twice, your app should not perform the same side effect twice.
Some database writes are naturally close to idempotent. Updating a user row with the latest subscription status is usually fine because the second write sets the same value again.
Other actions are not safe by default:
- Sending an order confirmation email
- Creating a workspace
- Granting credits
- Adding seats
- Creating an onboarding checklist
- Recording an audit event
- Capturing or cancelling a payment intent
Those actions need a durable duplicate guard.
Why duplicate Stripe webhooks happen
Stripe expects your webhook endpoint to return a successful response. If your endpoint times out, returns an error, or has a network problem, Stripe can retry the event.
You can also resend events manually from the Stripe Dashboard or the Stripe CLI during testing. That is useful, but it means your local code should behave the same way production code behaves: repeated delivery should not cause repeated fulfillment.
There is another subtle case. You may receive different Stripe event IDs that point at the same business object. For example, a checkout session can be involved in multiple related events, and a subscription can receive many updates over its lifetime.
That is why the idempotency key should match the side effect you are protecting, not blindly every Stripe event.
The wrong guard: only checking metadata
A common first attempt is to store something like processed: true in Stripe metadata and check it before doing work.
Metadata is useful. Plainform uses Stripe session metadata so the order page can show the final payment state after the webhook finishes.
But metadata should not be your only duplicate guard. It is not an atomic lock around your database, email provider, and provisioning logic. Two webhook deliveries can start close together, both read the old metadata, and both run the same side effect before either one writes the final state.
For anything important, use a database-backed unique key.
The durable key pattern
The safer pattern is to create a table for processed webhook work. In Plainform, that table is StripeWebhookProcess.
Before the webhook sends an email, captures a payment, syncs a subscription, or runs app-specific side effects, it claims a unique key:
const key = `stripe:checkout-session-completed:${session.id}`;
const claimed = await claimStripeWebhookKey({
key,
stripeEventId,
eventType: 'checkout.session.completed',
objectId: session.id,
});
if (!claimed) {
return NextResponse.json(
{ message: 'Checkout session already processed.' },
{ status: 200 }
);
}If the insert succeeds, this delivery owns the work. If another delivery tries the same key, the database rejects it and the handler returns 200 without repeating the side effect.
That 200 matters. You do not want Stripe to keep retrying an event you intentionally skipped because it was already handled.
Which key should you use?
The key should be based on the business action.
For checkout completion, Plainform uses the checkout session ID:
stripe:checkout-session-completed:cs_...
stripe:subscription-checkout-session-completed:cs_...That means resending the same completed checkout session does not resend order emails or rerun checkout-level fulfillment.
For subscription lifecycle events, Plainform uses different keys depending on the event:
stripe:subscription-created:sub_...
stripe:subscription-updated:evt_...
stripe:subscription-deleted:sub_...created and deleted are tied to the subscription ID because those should normally run once for that subscription lifecycle. updated uses the Stripe event ID because a subscription can be updated many legitimate times. Blocking every future update by subscription.id would be wrong.
If you add more specific business actions, make the key more specific. For example, a cancellation-scheduled email might use the subscription ID plus the cancel date, while a plan-change side effect might include the old and new price IDs.
What should happen on failure?
A good idempotency system should not permanently block retries after a failed attempt.
Plainform marks a claimed key as:
processingwhile the handler is runningprocessedafter successfailedif the handler throws or returns a failed response
Failed keys can be reclaimed on a later retry. That gives you protection against duplicates without hiding real failures.
How to Handle Stripe Webhooks Safely
Treat every Stripe webhook as something that may arrive more than once. The handler should either repeat a harmless update or skip side effects that already ran.
If a webhook only updates the same database field to the same value, duplicate delivery may not hurt you. But once the webhook sends emails, creates records, grants access, captures payments, or provisions paid features, you need a durable duplicate guard.
Use Stripe metadata for UI state. Use your database for idempotency. That split keeps the user experience clear and the fulfillment logic safe.
