Building a webhook handler
Production-grade patterns for receiving Swappr webhooks: signature verification, idempotent handlers, retry safety, and async processing.
See the Webhooks reference for the full event catalog and per-event payload shapes.
Minimum viable handler
The simplest correct handler verifies the Stripe-pattern signature, ACKs fast, and processes the event asynchronously:
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
app.post(
'/webhooks/swappr',
express.raw({ type: 'application/json' }), // RAW bytes, not JSON
(req, res) => {
// 1. Parse the signature header: "t=<unix>,v1=<hex>"
const header = req.header('x-swappr-signature') ?? '';
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=') as [string, string]),
);
const ts = parts['t'];
const sig = parts['v1'];
if (!ts || !sig) {
return res.status(400).send('Bad signature header');
}
// 2. Replay protection: reject anything older than 5 minutes
const ageSeconds = Math.floor(Date.now() / 1000) - Number(ts);
if (Number.isNaN(ageSeconds) || ageSeconds > 300) {
return res.status(401).send('Stale signature');
}
// 3. Recompute HMAC over `<ts>.<raw_body>` and compare in constant time
const body = req.body as Buffer; // raw bytes from express.raw()
const signed = `${ts}.${body.toString('utf8')}`;
const expected = createHmac('sha256', process.env.SWAPPR_WEBHOOK_SECRET!)
.update(signed)
.digest('hex');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
) {
return res.status(401).send('Invalid signature');
}
// 4. Parse + ACK fast
const event = JSON.parse(body.toString('utf8'));
res.status(200).send('OK'); // ACK FIRST
// 5. Process async — don't block the ACK
handleEvent(event).catch((err) => {
console.error('Webhook handler error:', err);
// Optional: send to your error tracking service
});
},
);
async function handleEvent(event: any) {
switch (event.event) {
case 'wallet_funded':
await recordInflow(event);
break;
case 'payout_paid':
await markPayoutPaid(event);
break;
case 'payout_failed':
await markPayoutFailed(event);
break;
// ... other events
}
}Critical patterns
1. Verify against raw body bytes
Always verify the signature against the exact bytes the server signed. JSON parsers reorder keys; if you parse-then-stringify, the bytes change and the signature won’t match.
In Express, use express.raw({ type: 'application/json' }) — NOT express.json(). Then req.body is a Buffer of raw bytes; parse it AFTER signature verification.
2. Enforce timestamp freshness
The signed string is <unix-seconds>.<raw_body>. Always check that the t= timestamp is within ~5 minutes of “now” before trusting the signature. Without this, a captured request body from yesterday could be replayed against your endpoint forever.
3. Acknowledge fast (< 5 seconds)
Swappr considers a webhook delivered when your handler returns 2xx within ~10 seconds. If you do heavy work synchronously (DB writes, third-party calls, email sends), the request times out and we mark it failed → retry storm.
Pattern: ACK first, process async. Use a queue (BullMQ / Sidekiq / Celery / SQS) or background tasks.
4. Idempotent handlers
Webhooks may be delivered more than once — your ACK might not reach us in time, or the retry-after-failure path may double-fire. Make your handler idempotent by keying on the right id per event type:
| Event | Idempotency key |
|---|---|
wallet_funded | event.ledgerEntryId |
payout_paid / payout_failed / payout_reversed | event.reference |
kyc_status_changed | the submission id in the payload |
async function handleEvent(event: any) {
// Compute the dedup key per event type
const dedupKey =
event.event === 'wallet_funded' ? event.ledgerEntryId :
event.event.startsWith('payout_') ? event.reference :
null;
if (!dedupKey) {
console.warn('No dedup key for event', event.event);
return;
}
// Check if we've already processed
const existing = await db.processedWebhooks.findUnique({ where: { id: dedupKey } });
if (existing) {
console.log('Already processed:', dedupKey);
return;
}
// Atomic: process + record in one transaction
await db.$transaction(async (tx) => {
await processEvent(tx, event);
await tx.processedWebhooks.create({
data: { id: dedupKey, eventType: event.event, processedAt: new Date() },
});
});
}5. Reconciling wallet_funded against your invoices
When a sender funds your virtual account, the payload includes their name, account, bank, and the narration they typed on the transfer. A typical reconciliation handler matches the narration against an open invoice:
async function recordInflow(event: any) {
// Idempotency
if (await db.processedWebhooks.findUnique({ where: { id: event.ledgerEntryId } })) {
return;
}
// Try to match the narration against an open invoice
const invoiceMatch = event.narration?.match(/INV-(\d+)/i);
const invoiceId = invoiceMatch?.[1];
await db.$transaction([
db.processedWebhooks.create({ data: { id: event.ledgerEntryId } }),
db.payments.create({
data: {
amountMinor: BigInt(event.amountMinor),
currency: event.currency,
// Sender details — present when the rail surfaces them.
// Example payload values (fictional): "OLAMIDE TUNRAYO ALADE",
// "0123456789", "058" (GTBank). Real deliveries carry the
// actual sender's bank-of-record name.
senderName: event.sender?.name ?? null,
senderAccount: event.sender?.accountNumber ?? null,
senderBankCode: event.sender?.bankCode ?? null,
narration: event.narration ?? null,
invoiceId: invoiceId ?? null,
receivedAt: new Date(event.receivedAt),
},
}),
...(invoiceId
? [db.invoices.update({ where: { id: invoiceId }, data: { paidAt: new Date(event.receivedAt) } })]
: []),
]);
}6. Differentiate test events
The dashboard’s “Send test webhook” button + the POST /v1/webhook_endpoints/{id}/test endpoint fire synthetic events with _test: true in the body and X-Swappr-Test: true in headers. Skip business logic for these:
async function handleEvent(event: any) {
if (event._test === true) {
console.log('Test webhook received:', event.event);
return; // Don't actually process
}
// Real event
await processEvent(event);
}7. Handle the auto-reverse path
When a payout fails post-debit, Swappr auto-reverses the wallet (credits principal + fee + tax back). The payout_failed webhook arrives with metadata.autoReversed = true.
Don’t double-credit your end-user! Always check this flag before adjusting balances on your side.
async function handlePayoutFailed(event: any) {
if (event.metadata?.autoReversed === true) {
// Wallet already credited back at Swappr — sync our DB to match
await db.payouts.update({
where: { swapprReference: event.reference },
data: { status: 'failed', refundedAt: new Date() },
});
// Notify the end-user that the transfer didn't go through + the funds are back in their balance
}
}Production checklist
- Signature verification parses both
t=andv1=from the header - Timestamp freshness check (reject anything older than 5 minutes)
- HMAC computed over
<ts>.<raw_body>(not just the body) - Constant-time compare via
timingSafeEqual/hmac.compare_digest(not===) - Raw body bytes preserved through to verification
- ACK within 5 seconds; defer heavy work to background queue
- Idempotent handler keyed on the per-event id from the table above
- Test events skipped via
_testcheck - Auto-reverse path handled in
payout_failed - Endpoint behind HTTPS only (HTTP is rejected at endpoint creation)
- Logs: timestamp + signature + event type + dedup key (don’t log full body — may contain sender PII)
- Monitoring: alert on > 5% 5xx rate from Swappr deliveries (your endpoint is failing)
- Replay path: dashboard’s per-delivery Retry button OR programmatic trigger when needed
Debugging
- Signature failures: check that you’re using the RAW body bytes + the correct webhook secret. Each endpoint has its own secret. Also verify the timestamp is in unix seconds, not milliseconds.
- Body parse errors: log the raw bytes when verification succeeds but parsing fails — sometimes a wrong Content-Type is set client-side.
- Missing events: query
GET /v1/webhook_deliveries?endpoint_id=...&status=failedto see what’s failing + replay from the dashboard. - Test from cURL: use the
/testendpoint to fire a synthetic event without waiting for a real payout. The synthetic body uses fictional sender data (e.g.BABATUNDE CHINEDU OKONKWO/9988776655/057) so you can verify the parser without exposing real customer PII in logs.