One Payment Event, Two Credit Grants: A TypeScript Webhook Bug
What happens if your payment handler receives the same event twice? In this local TypeScript lab, an event represents a $19 payment. The broken handler grants $19 of application credit on every delivery. Replay the even
What happens if your payment handler receives the same event twice?
In this local TypeScript lab, an event represents a $19 payment. The broken handler grants $19 of application credit on every delivery. Replay the event, and the local balance becomes $38.
There was no second payment. The application repeated its own business action. These are synthetic events; no real charges are made.
Here is the demo output:
BROKEN
Delivery 1: processed; 1 credit grant(s); $19.00 local credit
Delivery 2: processed; 2 credit grant(s); $38.00 local credit
CORRECTED
Delivery 1: processed; 1 credit grant(s); $19.00 local credit
Delivery 2: duplicate; 1 credit grant(s); $19.00 local credit
Why remembering the event ID is only part of the fix
The broken handler validates the event and immediately inserts a credit grant. It never claims the event ID.
Adding a processed-event table helps, but the order of writes matters:
- Commit the event marker first, then fail before granting credit: a retry may skip work that never happened.
- Grant credit first, then fail before recording the event: a retry may grant credit again.
The marker and the local business write need to commit together.
The TypeScript correction
The SQLite table gives each processed event a unique key:
CREATE TABLE IF NOT EXISTS processed_events (
event_id TEXT PRIMARY KEY
);
Here is the handler from the lab with its failure-injection hooks omitted for readability. This excerpt uses the lab's helpers: parsePayment validates the synthetic event and returns its payment fields, while grantCredit inserts a row into credit_grants using the same database connection. It is not a standalone HTTP endpoint.
import type { DatabaseSync } from 'node:sqlite';
export function correctedHandler(db: DatabaseSync, event: unknown) {
const payment = parsePayment(event);
if (!payment) return 'ignored';
db.exec('BEGIN IMMEDIATE');
try {
const claim = db.prepare(`
INSERT INTO processed_events (event_id) VALUES (?)
ON CONFLICT(event_id) DO NOTHING
`).run(payment.eventId);
if (claim.changes === 0) {
db.exec('COMMIT');
return 'duplicate';
}
grantCredit(db, payment);
db.exec('COMMIT');
return 'processed';
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
BEGIN IMMEDIATE obtains SQLite's write lock before claiming the event. The unique key arbitrates duplicate claims. The transaction makes the marker and credit grant commit or roll back together.
After a successful commit, replaying the same event finds the marker and returns duplicate. If the transaction fails, the error propagates so a real adapter can arrange a retry rather than falsely acknowledge success. An error starting the transaction also propagates; it is not treated as a duplicate.
All competing handlers must use the same database. Separate SQLite files on separate servers do not coordinate. Keep external network calls outside this short transaction.
Test the failure paths, not just the happy path
The free lab includes 10 tests, covering cases such as:
- Replaying the same event without a second credit grant.
- Injecting a failure after the marker or after the grant, then retrying.
- Closing and reopening the database connection.
- Eight concurrent processes contending on one database file.
- Distinct event IDs for the same payment, documenting a limit of event-ID deduplication.
Injected exceptions test transaction rollback; they are not a power-loss durability test.
Try the complete free sample
Download the duplicate-delivery lab. It includes both handlers, the helpers and schema, a demo, and all 10 tests.
With Node.js 24.18 or newer, extract the ZIP and run:
npm run demo
npm test
On Windows, use npm.cmd if PowerShell blocks npm.ps1. No npm install, Stripe account, API keys, or Docker needed. Verified on Windows with Node 24.18.0.
Where this pattern stops
This is an offline application-handler example. A live endpoint still needs raw-body signature verification and appropriate acknowledgment and retry behavior.
Event-ID deduplication does not deduplicate different event IDs for the same business operation. Define a business key when that is the operation you need to protect.
A database rollback cannot undo an email, shipment, or external API call. Those need their own durable delivery and idempotency design; this lab does not promise exactly-once external effects.
Disclosure: Failure Mode Labs makes the linked sample and paid package. The free sample is complete on its own. The optional US$19 Webhook Failure Lab adds out-of-order subscription-state reconciliation and durable acceptance before acknowledgment: three labs and 20 tests in total.
Independent educational product; not endorsed by Stripe.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.