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.

Backend Engineerstripewebhooksidempotency

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:

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:

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

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

Fix a Race Condition Double ChargeBackend projectCache LLM Responses With RedisBackend projectReturn the Correct HTTP Status CodeBackend projectBackend roadmapStep by step to hiredBackend interview questionsSTAR answersAll Backend projectsProjects hub

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 →