GuidesBuilding a webhook handler

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:

EventIdempotency key
wallet_fundedevent.ledgerEntryId
payout_paid / payout_failed / payout_reversedevent.reference
kyc_status_changedthe 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= and v1= 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 _test check
  • 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=failed to see what’s failing + replay from the dashboard.
  • Test from cURL: use the /test endpoint 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.