Billing engineering

Stripe webhook idempotency: the production checks coding agents often miss

A Stripe webhook handler can look correct, pass a happy-path test and still double-credit a wallet during a retry. Production safety comes from treating signature verification, replay protection and the database mutation as one explicit contract.

By Moresq Corpus11 min read
Layered evidence sheets representing signature checks, idempotency records and payment state.
A payment webhook is a state transition with evidence. Signature verification and replay safety belong to the same production contract. Open image.

A production-oriented Stripe webhook idempotency guide covering raw-body signatures, replay protection, transactions, event states and regression tests.

Assume every event can arrive more than once

Webhook delivery is at-least-once in practice. Timeouts, network failures and a slow acknowledgement can cause the same event or business transition to be delivered again. Your handler must make the second delivery harmless.

Do not rely on process memory, a queue consumer’s current state or a check that runs outside the wallet transaction. Two workers can observe “not processed” at the same time and both apply the credit.

Choose an idempotency identity that matches the business mutation. For a checkout credit, the Checkout Session ID is often a useful stable key. Preserve the Stripe event ID as evidence as well, because delivery attempts and business objects answer different audit questions.

Verify the signature against the raw request body

Stripe signs the exact payload bytes. Parsing JSON and serializing it again can change whitespace or key ordering, making the verification meaningless or unreliable. Capture the raw body first, verify the timestamped signature, then parse the authenticated event.

Enforce a timestamp tolerance and compare signatures using a timing-safe primitive. Reject missing or malformed signature headers before any database or wallet operation. Log a request identifier and event type, never the secret or full sensitive payload.

Safe processing ordertext
raw_body = request.read_raw_body()
event = verify_stripe_signature(raw_body, signature_header, tolerance=300)
payload = parse_authenticated_event(event)
apply_idempotent_transition(payload)

Make the idempotency record and wallet mutation atomic

The safest design writes a unique processing key and the resulting credit inside the same database transaction. The unique constraint is the concurrency guard. If inserting the key conflicts, the handler returns the previous outcome without applying another mutation.

Validate payment state and metadata before entering the mutation. Confirm that the event type is relevant, the session is paid, the pack identifier is allowed and the user or wallet reference belongs to the expected project. Ignore unrelated events explicitly rather than treating every successful parse as a credit instruction.

  1. 01Begin a database transaction.
  2. 02Insert the business idempotency key under a unique constraint.
  3. 03If the key already exists, return the recorded result.
  4. 04Validate the wallet owner, pack and paid state.
  5. 05Apply the ledger entry and balance change.
  6. 06Store the Stripe event and payment identifiers for audit.
  7. 07Commit, then acknowledge the webhook.
Transactional outlinesql
BEGIN;
INSERT INTO processed_payments (session_id, event_id)
VALUES (:session_id, :event_id)
ON CONFLICT (session_id) DO NOTHING;

-- Continue only when one row was inserted.
INSERT INTO wallet_ledger (...);
UPDATE wallets SET balance = balance + :credits WHERE user_id = :user_id;
COMMIT;

Model refunds and disputes as new idempotent transitions

A completed checkout is not the end of the payment lifecycle. Refunds, disputes and reversals should create their own ledger entries, linked to the original payment. Never delete the original credit record or edit history in place.

Each reversal path needs its own stable idempotency key, usually derived from the Stripe refund, charge or dispute object. If a wallet cannot go negative, define whether the reversal creates debt, blocks retrieval or enters manual review. That policy belongs in product logic, not in a generic webhook utility.

The minimum regression pack

A useful webhook test suite signs realistic raw payloads and verifies state, not only the HTTP status. It should prove that tampering fails before the mutation and that replaying the same event leaves the wallet unchanged.

Run the handler against isolated storage so a test cannot credit a real or shared wallet. Include concurrency or unique-constraint coverage when the database supports it.

  • Valid signature and paid session credits exactly once.
  • Modified raw body fails signature verification.
  • Expired timestamp is rejected.
  • Duplicate delivery returns success without another credit.
  • Unrelated event type is ignored.
  • Unknown pack or wallet reference is rejected.
  • Refund or dispute creates one compensating ledger entry.
  • Secrets and full payloads are absent from logs.

FAQ

Frequently asked questions

Why does Stripe send the same webhook more than once?

Retries can happen when delivery times out or Stripe does not receive a successful acknowledgement. Handlers should therefore be idempotent by design.

Should I deduplicate by Stripe event ID or Checkout Session ID?

Store both. Use the identifier that uniquely represents the business mutation as the idempotency key, and retain the event ID for delivery audit.

Why must signature verification use the raw body?

The signature covers the exact request payload. Parsing and reserializing JSON may change those bytes before verification.

Is an in-memory set enough for webhook idempotency?

No. It disappears on restart and does not coordinate multiple workers. Use durable storage with a unique constraint and an atomic transaction.

Moresq Corpus

Retrieve proven code with source, rights and proof attached.