2026-05-20
Stripe webhooks: idempotency, duplicates, and concurrency
Minimal schema and transactional handling for duplicate, out-of-order, at-least-once webhook delivery.
Stripe (and serious equivalents) delivers webhooks at-least-once: the same event id can arrive twice, and distinct events may appear out of order relative to business time. Handlers must be idempotent and safe under concurrent delivery attempts.
Primary references: Stripe’s docs on duplicate events and signature verification with the endpoint signing secret (Stripe-Signature). Keep your Stripe-Version aligned with the server integration you tested.
At-least-once semantics
This implies three obligations:
- Return 2xx quickly after you durably store the event or enqueue it transactionally—Stripe retries on timeouts or 5xx.
- Never double-apply domain side effects for the same
event.id. - Order-sensitive updates need discipline:
customer.subscription.updatedmay follow…deletedduring retries—use monotonic versioning or compare Stripeevent.createdunder a lock.
Minimal table schema
CREATE TABLE stripe_events (
id text PRIMARY KEY, -- evt_*
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
payload_hash bytea
);
id is Stripe’s event id, not your internal surrogate key. processed_at null means pending or in-flight.
Transactional handler (pseudocode)
BEGIN;
INSERT INTO stripe_events (id, type) VALUES ($eventId, $type)
ON CONFLICT (id) DO NOTHING;
IF NOT FOUND (row inserted) THEN
COMMIT; RETURN 200;
END IF;
INSERT INTO jobs (event_id, ...) VALUES ($eventId, ...);
COMMIT;
If business logic is slow, prefer a short transaction that deduplicates + enqueues; a worker sets processed_at.
Concurrency and unique constraints
Two instances may POST the same payload milliseconds apart. A unique event.id with INSERT … ON CONFLICT (Postgres) or SQLite INSERT OR IGNORE prevents double processing. Avoid SELECT then INSERT without locking—you will race.
Security and replay
Before mutating state:
- Verify the signature with the webhook endpoint secret (not your generic API key).
- Validate the signed timestamp within Stripe’s documented tolerance.
- Optionally reject events older than your replay window.
Minimal tests
- Double POST identical payload → one durable side effect.
- Reordered events via mocks → final state matches the rule you chose (e.g. latest
createdwins). - Slow handler → Stripe retries; second attempt must not duplicate internal billing.
What Stripe publishes (verifiable)
- Retries: failed webhook deliveries retry with backoff for up to ~3 days—treat each
event.idas your natural idempotency key. Operational detail: Process undelivered events plus the overview Receive Stripe events. constructEvent/ signatures: official SDKs need the raw POST body +Stripe-Signature+ the endpoint-specific secret—middleware that parses JSON before verification breaks HMAC validation. See Resolve signature errors.- Local dev:
stripe listen --forward-to localhost:3000/webhookis still the fastest way to capture realistic payloads while integrating.
Out of scope
This does not cover Stripe Connect multi-party routing, beta thin events, or REST Idempotency-Key headers on charge APIs—related but different mechanics. For non-Stripe vendors, reuse the same skeleton: stable event id + uniqueness + short transactions.
Summary
Persist Stripe’s event id with a uniqueness guarantee before side effects; commit before responding 2xx; push heavy work to a queue. You survive duplicates, retries, and limited reordering without corrupting money or state.