When a customer pays with Wave or Orange Money, your app doesn’t get the confirmation directly: the operator, or an aggregator such as PayTech, notifies you afterwards by calling an endpoint on your server. That call is a webhook.
On paper it’s simple. In production, it’s one of the most common sources of financial incidents we find during audits.
What can go wrong
A webhook isn’t a single, reliable message. It’s a notification attempt that the provider retries until it gets a satisfactory answer. In practice your server may receive:
- the same notification several times, because your first response was too slow;
- two notifications at the same moment, processed in parallel;
- a late notification, after the customer already retried a second payment;
- a forged notification, sent by someone who guessed your webhook URL.
Without safeguards, each of these ends up as an order confirmed twice, a balance credited twice, or an order shipped without being paid.
The four rules we apply
1. Verify the signature first
Every notification must be authenticated. Most providers sign the payload with a shared secret (often HMAC-SHA256). We recompute the signature server-side and reject anything that doesn’t match — before even reading the amount.
import { createHmac, timingSafeEqual } from 'node:crypto';
function isSignatureValid(rawBody: string, received: string, secret: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && timingSafeEqual(a, b);
}Two details matter: sign the raw request body (not the parsed JSON object), and compare in constant time so nothing leaks.
2. Store every event under a unique key
Each notification carries a transaction ID. We store it in a dedicated table with a unique constraint in the database. If the same ID arrives again, the insert fails: we know right away it’s a duplicate and reply “OK” without doing anything twice.
The database guarantees uniqueness, not application code — it’s the only protection that survives concurrent processing.
3. Lock the order while updating it
To move an order from “pending” to “paid”, we read it with a lock inside a transaction. A second process trying to update it at the same time waits for the first to finish, then sees it’s already paid.
await db.transaction(async (tx) => {
const order = await tx.orders.findForUpdate(orderId); // lock
if (order.status === 'paid') return; // already handled
await tx.orders.markPaid(orderId, transactionId);
await tx.ledger.record(order, transactionId); // ledger entry
});4. Answer fast, process later
The provider expects an answer within seconds. So we respond as soon as the event is verified and stored, and hand everything else (emails, payouts, notifications) to a job queue. Fewer delays, fewer retries — and fewer duplicates.
What if doubt remains?
Even with these rules, a webhook can simply never arrive. That’s why we always add reconciliation: a scheduled job that compares our recorded transactions with the operator’s statement and flags any gap to the team.
| Risk | Safeguard |
|---|---|
| Forged notification | Signature check |
| Replayed notification | Unique key in the database |
| Concurrent processing | Lock inside a transaction |
| Lost notification | Reconciliation with the statement |
Key takeaway
Reliable payments don’t rely on trusting the network, but on guarantees enforced in the database. These four rules apply whatever the payment rail: Wave, Orange Money, cards or an aggregator.
Have a payment integration that produces gaps? That’s exactly what we handle in a technical audit.