Your logs show one payment. Your database shows two. The customer emails support: "Why was I charged twice?"
🚨 At-Least-Once Delivery
Stripe uses at-least-once delivery. This means if Stripe doesn't get a 200 OK within a few seconds, it assumes you failed and retries. Even if your server actually processed the payment but timed out while sending a confirmation email.
💻 The Code: Vulnerable vs Safe
The "Dangerous" Way
Most tutorials show this. It's clean, simple, and broken for production:
// ❌ Vulnerable to double-processing
app.post('/webhook', async (req, res) => {
const event = req.body;
if (event.type === 'checkout.session.completed') {
// If Stripe retries, this runs twice!
await fulfillOrder(event.data.object);
}
res.json({received: true});
});
The "Safe" Way (Idempotent)
You must check if the event.id has been processed before taking action:
// ✅ Protected by OnceOnly (check-lock) or a custom DB check
app.post('/webhook', async (req, res) => {
const event = req.body;
const eventId = event.id; // e.g., evt_123...
const lockRes = await fetch("https://api.onceonly.tech/v1/check-lock", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ONCEONLY_API_KEY}`, // once_live_***
"Content-Type": "application/json",
},
body: JSON.stringify({ key: eventId, ttl: 86400, metadata: { source: "stripe" } }),
});
const lock = await lockRes.json();
if (lock.status === "duplicate") return res.status(200).json({ duplicated: true });
await fulfillOrder(event.data.object);
res.json({ success: true });
});
🔄 FAQ: Retries vs. Deduplication
Many developers confuse these two concepts. Here is the breakdown:
What is the difference between a Retry and a Duplicate?
A Retry is an intentional attempt by the sender (Stripe) to deliver data that it thinks didn't arrive. Deduplication (Dedupe) is the receiver's (your app) ability to recognize that "new" data is actually a copy and should be ignored.
Can't I just use a database unique constraint?
Partially. A UNIQUE constraint on stripe_event_id in your DB prevents saving twice, but it doesn't prevent side effects (like sending two "Order Confirmed" Slack messages or calling a 3rd party shipping API) that happen before the DB save.
Is Dedupe only for Stripe?
No. Any webhook (GitHub, Shopify, Twilio) and any AI Agent action needs a deduplication layer. If an AI agent retries a "Send Refund" tool call because of a network lag, you definitely don't want it to run twice.
🚀 Summary
Building for the happy path is easy. Building for network instability is what separates junior code from production-grade engineering. Don't trust the network; verify the execution.