Payments through webhooks: duplicate invoices and an unchecked signature
Customers got two or three invoices for one payment, and course access could be had without paying. Idempotent processing and signature verification.
- Company
- Online courses and memberships, 7 people
- Team
- Two developers
- Stack
- Node.js and Express 5, PostgreSQL, payments through Stripe
- Constraints
- The application issues invoices itself; the accountant takes them over once a month
01Starting point
After payment through Stripe Checkout, Stripe sends a checkout.session.completed event to /webhooks/stripe. The application finds the order, marks it paid, grants access to the course, generates a PDF invoice and emails it. All within one request, while Stripe waits for an answer.
Signature verification had been in the code, but it did not work and the developer turned it off. The application used express.json() globally, which parses the request body before the verification ever sees it.
- Customer
- Stripe Checkout
- Stripe: webhook
- Node.js / Express (processing, PDF, email)
- PostgreSQL
02The problem
During the monthly close the accountant noticed that some customers had two or three invoices, with different numbers, for a single payment. Customers were asking why they had received several.
Looking for the cause, the developer came across the disabled signature check, which was the more serious problem. Anyone who knew the webhook URL (and /webhooks/stripe is easy to guess) could send their own checkout.session.completed event with the ID of their unpaid order, and the application would grant them the course.
Prerequisite: the attacker creates an order without paying and sends a forged event. They need nothing more than a browser and a tool for sending HTTP requests.
03Technical root cause
Stripe delivers events at least once, not exactly once. If it does not get a 2xx in time it retries, for up to three days in live mode, and the same event can also arrive more than once with no fault on the receiving side. Processing was not idempotent: every delivery issued a new invoice.
Processing was slow, because it generated the PDF and sent the email during the request. When it took too long, Stripe counted it as a failure and sent the event again, often while the first run was still going.
The signature is computed over the exact bytes of the request body, so stripe.webhooks.constructEvent needs the body unchanged. After express.json() it receives an object, verification fails, and instead of fixing the order of middleware the check was turned off.
04Investigation
The Stripe webhook view shows, for each event, every delivery attempt and the application's response. The duplicate invoices matched events with several attempts, where the first had timed out.
Whether anyone had used the missing signature: a script went through every paid order, fetched the corresponding Checkout Session through the Stripe API, and checked that its payment_status was paid and its amount matched the order. It found no mismatch.
05Remediation
The webhook has its own route with express.raw(), registered before the global express.json(), and verifies the signature. Each event is written, inside a transaction, to a table with a unique ID. If it is already there, nothing happens.
// registered BEFORE app.use(express.json()): the signature covers the raw bytes
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400); // unsigned or altered: not from Stripe
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// delivery is at least once: the event id is the idempotency key
const first = await client.query(
"INSERT INTO stripe_events (id) VALUES ($1) ON CONFLICT (id) DO NOTHING",
[event.id],
);
if (first.rowCount === 1) await handleEvent(client, event);
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err; // 500: Stripe retries later (Express 5 forwards the rejection)
} finally {
client.release();
}
res.sendStatus(200);
});The processing itself marks the order paid only through a conditional UPDATE, and only when the amount matches. The invoice and email are no longer produced during the request: the same transaction writes a row to an outbox table, which a separate process started by cron every minute sends out.
-- inside the same transaction as the event insert
UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = $1 AND status = 'pending' AND amount_cents = $2;
-- only when that UPDATE touched a row: the invoice email waits in an outbox
INSERT INTO outbox (kind, order_id) VALUES ('invoice_email', $1);For checkout.session.completed, payment_status is checked too. With delayed payment methods it can still be unpaid, and the order is then closed only on checkout.session.async_payment_succeeded.
Nightly reconciliation: a script compares the last few days of orders with the list of Checkout Sessions in Stripe. It also catches an event Stripe failed to deliver within its three days.
The accountant corrected the duplicate invoices with credit notes. An issued invoice cannot simply be deleted.
06Why the fix works
The signature proves the event came from Stripe and was not altered on the way. Without the webhook secret a valid signature cannot be produced, so a forged event ends in a 400.
The unique event ID in the database makes a repeated delivery harmless. When two copies arrive at once, the second INSERT waits until the first transaction finishes and then, thanks to ON CONFLICT DO NOTHING, inserts nothing.
The conditional UPDATE is a second safeguard: it will not change an order that is no longer pending, and it will not accept a mismatched amount.
The response is fast because the PDF and email happen outside the request, so Stripe does not retry needlessly. The outbox row is written in the same transaction as the payment, so the email is not lost even if the application crashes right after.
What it does not address: the process that sends the outbox can send an email twice if it crashes between sending and recording it. The invoice, though, is not issued twice; it is created only once.
07Validation
- Stripe CLI (
stripe listen --forward-toandstripe trigger checkout.session.completed) in test mode: one event, one invoice. - Resending the same event from the Stripe dashboard created no second invoice.
- A request without a signature and one with an altered body both returned 400.
- A test that sends the same event twice concurrently ends with a single invoice.
08Results
Fixed
- One payment means one invoice, even when the event arrives several times.
- Course access cannot be obtained with a forged event.
Risk reduced
- The webhook responds quickly, so Stripe retries only on a genuine failure.
- Nightly reconciliation catches a payment whose event never arrived.
Still open
- The invoice email can, rarely, arrive twice.
09Limitations
- The webhook secret is now the key to payments. It belongs in secret management, not in the repository, and should be replaced when someone with access leaves.
- Events can arrive in a different order than they happened. Processing must therefore rely on the order's state, not on sequence.
- Reconciliation covers the last few days only. Older mismatches are for accounting to find.
10Lessons
- Webhooks are delivered at least once. Every handler must cope with the same event twice.
- A webhook signature is checked against the unchanged request body. When verification fails, fix the order, do not switch it off.
- Answer webhooks quickly. Do slow work outside the request.
- Recording the event and changing state belong in one transaction.
- Trust neither the payment provider nor your own application blindly: compare them once a day.
11Next steps for a small company
- 1.An alert when unsent rows pile up in the
outbox. - 2.Limit the Stripe webhook to the events the application actually handles.
- 3.Add a Stripe CLI test to CI that exercises the whole payment flow.
- 4.When rotating the webhook secret, keep the old and new valid side by side for a while.
Recognise your own company in this?
Tell us what it involves. We reply within one business day and say whether it is work for us, even when the answer is no.