How to Make a Stripe Webhook Idempotent and Retry-Safe
Stripe retries webhook deliveries whenever your endpoint is slow, so a handler that processes every POST can charge the same customer twice. The fix is to make the Stripe webhook idempotent: dedup on Stripe's event["id"] with Redis SET NX, which succeeds only on the first delivery and turns every retry into a no-op 200.
Why retries cause duplicate charges
Stripe's webhook docs are explicit: "we may retry deliveries if your endpoint
is slow." Every retry re-sends the exact same event - same event["id"], same
payload. If your handler runs its side effect on every delivery, three retries
of one payment_intent.succeeded become three charges.
The buggy handler processes unconditionally:
@app.route("/webhook", methods=["POST"])
def webhook():
event = request.get_json()
process_payment(event) # fires on every delivery, retries included
return jsonify({"received": True}), 200
Returning 200 fast is not enough - if process_payment already ran before the
timeout, Stripe still retries and you charge again. You need a dedup gate that
remembers which events you have already handled.
The fix: dedup on event id with Redis SET NX
Every Stripe event carries a unique id (e.g. evt_test_123). Use it as a
one-shot key. SET key value NX sets the key only if it does not already exist
and returns whether it won the write - so the first delivery gets True and
every retry gets False:
import redis
from flask import Flask, request, jsonify
app = Flask(__name__)
_r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
@app.route("/webhook", methods=["POST"])
def webhook():
event = request.get_json()
key = f"stripe:event:{event['id']}"
# SET NX succeeds only the first time this event id is seen.
if _r.set(key, "1", nx=True, ex=86400):
process_payment(event)
# else: already processed - fall through and return 200 without re-charging
return jsonify({"received": True}), 200
Two details make this correct:
nx=Trueis the atomic check-and-set. Doingif not redis.exists(key)then a separateredis.setreopens the race - two retries could both see "not present" before either writes.SET NXdoes the test and the write in one round-trip.ex=86400expires the key after 24 hours so Redis does not grow forever. Stripe stops retrying an event long before a day, so a 24h window safely covers the entire retry backoff.
A retried delivery still returns 200 {"received": true} - Stripe sees success
and stops retrying - but process_payment never runs a second time.
Verify with a simulated retry
Post the identical event three times, the way Stripe's backoff would, and confirm the ledger shows a single charge:
bash /workspace/run.sh
EVENT='{"id":"evt_x","type":"payment_intent.succeeded","data":{"object":{"id":"pi_x","amount":100}}}'
for _ in 1 2 3; do
curl -X POST -H "Content-Type: application/json" \
-d "$EVENT" http://localhost:5001/webhook
done
# Ledger should show a single charge
curl http://localhost:5001/charges
A different event["id"] still processes normally, so the gate blocks retries
without blocking new events.
Hardening for production
The Redis gate is the core of idempotency, but a real payment handler adds:
- Signature verification - check the
Stripe-Signatureheader against your signing secret before trusting the payload, so nobody can forge events. - Atomic dedup plus side effect - Redis works, but writing the dedup marker
and the charge in one database transaction is even safer. Postgres
INSERT ... ON CONFLICT DO NOTHINGon an events table gives you dedup and durability together. - A retry-count metric so a spike in redeliveries stays visible instead of silently doubling load.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Using Redis SET NX with an expiry as an atomic check-and-set dedup gate
- Keying idempotency on Stripe's event id so retried deliveries become no-ops
- Verifying retry-safety by posting the same event repeatedly and asserting one side effect
FAQ
Why does Stripe send the same webhook event more than once?
Stripe retries a delivery whenever your endpoint is slow, returns a non-2xx status, or times out. Each retry re-sends the identical event with the same event id, so a handler that runs its side effect on every POST will process the same payment multiple times unless it deduplicates.
How do I make a Stripe webhook idempotent?
Deduplicate on Stripe's event["id"]. Before processing, run redis.set(f"stripe:event:{event_id}", "1", nx=True, ex=86400) - it succeeds only the first time that id is seen. Process the event when the SET NX returns True, and skip it (still returning 200) on any retry.
Why use Redis SET NX instead of checking if the key exists first?
A separate EXISTS-then-SET has a race - two concurrent retries can both read "not present" before either writes, and both process. SET NX performs the existence check and the write as one atomic operation, so exactly one delivery wins the key.
Should the dedup key expire?
Yes. Set a TTL such as ex=86400 (24 hours) so old event ids clear out and Redis does not grow unbounded. Stripe stops retrying an event well within a day, so a 24-hour window covers the full retry backoff while keeping memory bounded.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →