Customer got two deliveries: troubleshooting duplicate fulfilment in Stripe
The same order sent two delivery emails. Whose bug is it? A six-step path from the Stripe dashboard to the database to the code, with runnable commands and three fixes.
A customer sends a screenshot: "I got two."
The first instinct is "Stripe charged twice". Almost certainly not. The charge happens once; what duplicates is your system's response to a single successful payment.
The distinction matters: a duplicate charge is a Stripe support issue, a duplicate delivery is your code.
TL;DR
Three possible sources:
| Source | Share | Signature |
|---|---|---|
| Webhook delivered multiple times | 80% | Same order, multiple event retries |
| User refreshed the success page | 15% | Front-end re-calls "confirm order" |
| Reconciliation job re-ran | 5% | Scheduled backfill without idempotency |
80% of cases: Stripe sent the webhook twice and your handler processed both.
Stripe's duplicate delivery is by design — at-least-once. Idempotency is the receiver's job.
Step 1: Rule out a duplicate charge
Stripe Dashboard → Payments → search the order or customer email.
Look for two successful charges with the same amount close in time. If there is only one, the problem is yours.
stripe charges list --limit 10 --customer cus_xxxxx
One charge → keep going.
Step 2: Count the webhook events
Developers → Events → filter payment_intent.succeeded or checkout.session.completed for that payment.
stripe events list --limit 20 --type payment_intent.succeeded
Two or more events with different event IDs: normal retries (your endpoint may have timed out before returning 200).
Same event ID twice: the endpoint was called twice — load balancer or client retry.
Same fix either way: idempotency.
Step 3: Is there an event record in the database?
SELECT event_id, created_at, COUNT(*) AS cnt
FROM processed_events
WHERE event_id = 'evt_xxxxx'
GROUP BY event_id, created_at;
cnt > 1 means the table exists but the constraint doesn't.
No table at all means every webhook executes business logic directly.
SELECT id, order_id, status, created_at
FROM deliveries
WHERE order_id = 'ORD-1234'
ORDER BY created_at;
Two rows seconds apart confirms duplicate processing.
Step 4: Read the logs
grep "ORD-1234" /var/log/app.log | grep -i "deliver\|fulfill"
Timing tells you which failure mode:
- Under 1 second apart: concurrent duplicate — two requests both passed the check
- Seconds to minutes apart: Stripe retry, because the first attempt timed out or was judged failed
Concurrency is the harder case, because check-then-write cannot work there.
Step 5: The three broken patterns
Pattern 1: status check (fails under concurrency)
// BAD: both concurrent requests pass this
const order = await db.orders.findUnique({ where: { id } });
if (order.status === "paid") return;
await deliver(order);
await db.orders.update({ where: { id }, data: { status: "fulfilled" } });
There is a window between read and write; two requests enter simultaneously.
Pattern 2: no event table
// BAD: Stripe retries re-run this
export async function POST(req: Request) {
const event = await verifySignature(req);
if (event.type === "checkout.session.completed") {
await deliver(event.data.object);
}
return new Response("ok");
}
Stripe retries for up to three days when it doesn't get a 200.
Pattern 3: unique index on the wrong column
-- BAD: this protects nothing
CREATE UNIQUE INDEX idx ON orders (order_id);
-- The constraint belongs on the processing table
Step 6: Fix it
Event-ID deduplication (preferred)
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT now()
);
export async function POST(req: Request) {
const event = await verifySignature(req);
try {
await db.processed_events.create({
data: { event_id: event.id, event_type: event.type },
});
} catch (e) {
if (isUniqueViolation(e)) {
return new Response("already processed", { status: 200 });
}
throw e;
}
if (event.type === "checkout.session.completed") {
await deliver(event.data.object);
}
return new Response("ok", { status: 200 });
}
The unique constraint does the atomicity. Never rely on application-level check-then-write.
Conditional update (state machine)
const result = await db.orders.updateMany({
where: { id: orderId, status: "paid" },
data: { status: "fulfilled" },
});
if (result.count === 0) {
return new Response("no-op", { status: 200 });
}
await deliver(order);
Use both
Deduplication stops retries; the state machine stops concurrency. Production should have both.
Step 7: Clean up existing data
SELECT order_id, COUNT(*) AS cnt, MIN(created_at), MAX(created_at)
FROM deliveries
GROUP BY order_id
HAVING COUNT(*) > 1;
Digital goods: usually write it off. Physical: contact the customer. Services: confirm whether it was used twice.
Flag those orders so reconciliation doesn't double count.
Detect it early
Log duplicate events even when you reject them — seeing the line means idempotency is working.
Reconcile daily: successful Stripe payments versus locally fulfilled orders.
Watch delivery email volume: the same address receiving two identical emails should surface somewhere.
One overlooked detail
When you return 200 matters.
If your handler runs for 30 seconds and Stripe times out at ~20, Stripe retries.
Correct pattern: record the event, return 200 immediately, process asynchronously.
await db.processed_events.create({ data: { event_id: event.id } });
queue.push({ event });
return new Response("ok", { status: 200 });
Now a one-minute fulfilment doesn't trigger a retry.
Bottom line
Stripe guarantees at-least-once. You have to guarantee exactly-once. Status checks don't survive concurrency — use a unique constraint or a conditional update, and return 200 before doing the slow work.
The full audit checklist is on the Stripe payments service page. For the deeper idempotency patterns, read this note.
Does your site have these problems?
Run the free audit tool for a 30-second report, or just write to me and describe the situation — I will tell you whether it is worth fixing.