The Golden Rule of Distributed Financial Systems
In computer science, distributed network networks guarantee two things: messages will be delayed, and messages will be duplicated.
If you build an e-commerce or B2B payment consumer that simply receives a webhook and runs:
class="text-[class="text-[#6a9955]">#6a9955]">// THE DANGEROUS PATTERN:
app.post(&class="text-[#6a9955]">#class="text-[#b5cea8]">039;/webhook/stripeclass="text-[#b5cea8]">039;, async (req, res) => {
const event = req.body;
if (event.type === &class="text-[#6a9955]">#class="text-[#b5cea8]">039;payment_intent.succeededclass="text-[#b5cea8]">039;) {
class="text-[class="text-[#6a9955]">#6a9955]">// If Stripe sends this webhook twice due to network timeout:
class="text-[class="text-[#6a9955]">#6a9955]">// The customer gets credited twice!
await userAccount.increment(&class="text-[#6a9955]">#class="text-[#b5cea8]">039;balanceclass="text-[#b5cea8]">039;, event.data.amount);
}
res.json({ received: true });
});
You will eventually suffer accounting discrepancies, erroneous payouts, and balance corruption.
Building an Idempotent Webhook Engine
An idempotent operation is one that produces the exact same state whether executed once or ten times.
Idempotency Pipeline:
[ Incoming Webhook ]
│
▼
[ Extract Event ID / Idempotency Key ]
│
├───[ Check Database: Key Already Processed? ]
│ ├───(YES) ──> [ Return Cached 200 OK Immediately ]
│ └───(NO)
▼
[ Acquire Distributed Lock (Redis) ]
│
▼
[ Execute Double-Entry Transaction ]
│
▼
[ Record Processed Event ID in DB ]
│
▼
[ Release Lock & Return 200 OK ]
Here is a robust implementation pattern:
import { db } from &class="text-[#6a9955]">#class="text-[#b5cea8]">039;@/lib/dbclass="text-[#b5cea8]">039;;
export async function processPaymentWebhookWithIdempotency(event: { id: string; amount: number; accountId: string }) {
return await db.transaction(async (tx) => {
class="text-[class="text-[#6a9955]">#6a9955]">// class="text-[#b5cea8]">1. Check if event was already handled
const existing = await tx.query(&class="text-[#6a9955]">#class="text-[#b5cea8]">039;SELECT status FROM processed_events WHERE event_id = $class="text-[#b5cea8]">1 FOR UPDATEclass="text-[#b5cea8]">039;, [event.id]);
if (existing.rows.length > class="text-[#b5cea8]">0) {
return { status: &class="text-[#6a9955]">#class="text-[#b5cea8]">039;already_processedclass="text-[#b5cea8]">039; };
}
class="text-[class="text-[#6a9955]">#6a9955]">// class="text-[#b5cea8]">2. Insert event record in pending state
await tx.query(&class="text-[#6a9955]">#class="text-[#b5cea8]">039;INSERT INTO processed_events (event_id, status, created_at) VALUES ($class="text-[#b5cea8]">1, $class="text-[#b5cea8]">2, NOW())class="text-[#ce9178]">class="text-[#b5cea8]">039;, [event.id, class="text-[#b5cea8]">039;processingclass="text-[#b5cea8]">039;]);
class="text-[class="text-[#6a9955]">#6a9955]">// class="text-[#b5cea8]">3. Post balanced double-entry ledger transactions
await postLedgerTransaction(tx, {
debitAccount: &class="text-[#6a9955]">#class="text-[#b5cea8]">039;stripe_clearingclass="text-[#b5cea8]">039;,
creditAccount: event.accountId,
amountCents: event.amount,
reference: event.id
});
class="text-[class="text-[#6a9955]">#6a9955]">// class="text-[#b5cea8]">4. Mark event complete
await tx.query(&class="text-[#6a9955]">#class="text-[#b5cea8]">039;UPDATE processed_events SET status = $class="text-[#b5cea8]">1 WHERE event_id = $class="text-[#b5cea8]">2class="text-[#ce9178]">class="text-[#b5cea8]">039;, [class="text-[#b5cea8]">039;completedclass="text-[#b5cea8]">039;, event.id]);
return { status: &class="text-[#6a9955]">#class="text-[#b5cea8]">039;successclass="text-[#b5cea8]">039; };
});
}
Double-Entry Bookkeeping in Modern Code
In double-entry bookkeeping, money cannot be created out of thin air. Every transaction consists of at least two entries:
- Debit (where the money went)
- Credit (where the money came from)
The fundamental invariant of the system: Sum of Debits must ALWAYS equal Sum of Credits.
| Transaction ID | Account | Type | Amount ($) | Balance Verification |
|---|---|---|---|---|
| TX-101 | Stripe Clearing | Debit | $500.00 | +$500 Asset |
| TX-101 | Customer Cash Balance | Credit | $500.00 | -$500 Liability to Customer |
| Sum | $0.00 | Balanced |
By calculating balances on demand or maintaining audit-verified summary tables, discrepancies become mathematically impossible to hide.



