Payments & access
Stripe sent the webhook twice. Make the second delivery harmless.
If one purchase creates two welcome emails, two account credits or two shipping requests, start by comparing the webhook deliveries. A request can reach your app, finish its work and still time out before Stripe receives the response. The next delivery then looks like a new instruction to code that has forgotten the first one.
Stripe documents duplicate deliveries and delivery without a guaranteed order. Treat those as normal conditions your handler must tolerate. Keep the event ID, and give fulfillment its own business key: for a Checkout purchase, the saved Checkout Session ID. Separate events can refer to the same object. Stripe’s webhook guidance explains both cases.
Verify the request before it reaches your business logic
Use the endpoint’s signing secret and Stripe’s signature verification library against the original request bytes. Parsing JSON and then serializing it again changes the bytes used for verification. If your framework normally parses every request first, configure this route to retain its raw body. Only the verified event should enter the processing queue. Stripe’s signature instructions describe the required input.
In an existing Express app, place the webhook route’s raw-body middleware before the general JSON parser. Use express.raw({type: 'application/json'}) for that route; the Express raw-body reference explains its content-type matching. Confirm this with a signed local fixture: changing one byte must make verification fail.
Put the decision in the database
A process-local set disappears when the server restarts. A “have we seen this?” query followed by an insert also leaves room for two workers to answer “no” together.
Use unique keys and make the receipt, fulfillment decision and follow-up job part of one transaction. PostgreSQL’s ON CONFLICT gives concurrent inserts a defined result. The INSERT reference describes that behavior.
Here is a small schema for an application that fulfills one-time Checkout purchases. Keep account and payment mode in the keys so test events and separate Stripe accounts cannot collide:
CREATE TABLE stripe_receipts (
account_id text NOT NULL, live boolean NOT NULL, event_id text NOT NULL,
PRIMARY KEY (account_id, live, event_id)
);
CREATE TABLE fulfilled_checkouts (
account_id text NOT NULL, live boolean NOT NULL, session_id text NOT NULL,
PRIMARY KEY (account_id, live, session_id)
);
CREATE TABLE fulfillment_outbox (
account_id text NOT NULL, live boolean NOT NULL, session_id text NOT NULL,
status text NOT NULL DEFAULT 'queued',
PRIMARY KEY (account_id, live, session_id)
);
After your worker retrieves the saved session from Stripe and validates its payment status, order, amount, currency, account and mode, execute this parameterized statement. Its parameters are the trusted account ID, mode, verified event ID and saved session ID:
WITH received AS (
INSERT INTO stripe_receipts VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING RETURNING event_id
), claimed AS (
INSERT INTO fulfilled_checkouts
SELECT $1, $2, $4 FROM received
ON CONFLICT DO NOTHING RETURNING session_id
)
INSERT INTO fulfillment_outbox (account_id, live, session_id)
SELECT $1, $2, session_id FROM claimed
ON CONFLICT DO NOTHING;
All three writes succeed or roll back together. A repeat event cannot create a second job. A different event for the same Checkout Session cannot create one either. A durable webhook inbox can acknowledge receipt after saving the event; its worker can retry this fulfillment transaction separately.
The outbox worker still needs safe delivery behavior. Give a provider request a stable idempotency key where supported. If a request times out after it might have succeeded, reconcile that original request before sending again. The database cannot undo an email that another service has already accepted.
Test the failure you are fixing
Deliver the same signed fixture concurrently. Then deliver a second event ID for the same session. Check that there is one fulfillment and one outbox row. Force a failure after the claim and verify the transaction leaves no partial receipt that would suppress a legitimate retry. Finally, restart the worker and repeat the delivery.
For an existing incident, compare provider delivery history, your event receipt and the fulfillment record before replaying anything. A replay should repair missing work, not repeat work whose acknowledgment was lost.
If you would rather start with customer records, saved checkout attempts and payment reconciliation already connected, Funnel and Revenue provides that source. Your app still supplies the verified customer identity and your Stripe configuration.