Webhooks
Swappr sends webhooks to your registered HTTPS endpoints when events happen — virtual accounts get credited, payouts complete, customer KYC updates, etc. Each webhook is signed so you can verify it came from Swappr.
The most important event for most integrations is wallet_funded — fired every time a payment lands in one of your virtual accounts, with the sender’s name, account, bank, and narration included.
Quick start
- Create an endpoint in your dashboard: API & Webhooks → + Add webhook endpoint.
- Pick the events you want delivered (
wallet_fundedis the typical first subscription). - Save — you’ll see a signing secret ONCE. Store it securely.
- Verify the signature on every incoming webhook (see below).
Headers
Every delivery includes:
| Header | Value | Purpose |
|---|---|---|
X-Swappr-Signature | t=<unix>,v1=<hex> | Stripe-pattern signed timestamp. See verification below. |
X-Swappr-Event | e.g. wallet_funded | The event type. Useful for routing without parsing the body. |
User-Agent | Swappr-Webhooks/1.0 | Identifies our deliveries in your logs. |
Content-Type | application/json | Body is always JSON. |
Signature verification
Each delivery’s signature is computed as:
hmac_sha256(secret, "<timestamp>.<raw_body>")Where <timestamp> is the unix-seconds value from the t= part of X-Swappr-Signature, and <raw_body> is the exact bytes of the request body (do not re-serialize the JSON).
To verify, parse the header into t + v1, recompute the HMAC, and use a constant-time comparison. Reject any delivery whose timestamp is older than ~5 minutes — that defeats replay attacks.
import { createHmac, timingSafeEqual } from 'crypto';
import express from 'express';
const app = express();
// IMPORTANT: use express.raw() to get the raw body bytes — not express.json().
// We need the EXACT bytes the server signed, not a re-stringified version.
app.post(
'/webhooks/swappr',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.header('x-swappr-signature') ?? '';
// Header format: "t=<unix>,v1=<hex>"
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');
// 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');
}
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');
}
const event = JSON.parse(body.toString('utf8'));
console.log('Verified:', event.event, event);
// Acknowledge fast — process async if heavy.
res.status(200).send('OK');
},
);Always verify against the raw request body bytes, not a re-serialized JSON. JSON parsers reorder keys; the signature won’t match if you re-encode.
Event catalog
Virtual account credits
wallet_funded— money landed in your wallet via one of your virtual accounts. Includes sender name, account, bank, and narration when the rail provides them.
Payout lifecycle
payout_paid— payout reached the recipient successfullypayout_failed— payout failed; if it was post-debit, the wallet was auto-reversedpayout_reversed— admin or merchant explicitly reversed a paid payoutpayout_queued/payout_processing— fired during normal status transitions
Bulk batches
batch_awaiting_approval— bulk batch above your dual-control threshold; needs a second adminbatch_approved— batch cleared dual-control, dispatching to providersbatch_completed/batch_completed_with_errors— batch finished
Other
virtual_account_provisioned— admin provisioned a new VA on your walletbeneficiary_verified— saved beneficiary’s name was confirmed via NUBAN resolvekyc_status_changed— your merchant KYC submission status changed (approved / rejected / needs_more_info)
Customer-level and per-customer-VIBAN events (customer_pending / customer_verified / virtual_account_activated etc.) are available when your account has the Remittances API enabled. See the Remittances reference for that flow.
wallet_funded payload
Fired the moment a sender’s transfer settles into one of your virtual accounts. The HTTP POST body is JSON:
{
"event": "wallet_funded",
"merchantId": "ckxxxxxxxxxxxxxxxxxx",
"virtualAccount": {
"provider": "monnify",
"accountNumber": "6730344622",
"bankName": "Moniepoint MFB",
"bankCode": "50515"
},
"amountMinor": "5000000",
"currency": "NGN",
"sender": {
"name": "OLAMIDE TUNRAYO ALADE",
"accountNumber": "0123456789",
"bankCode": "058"
},
"narration": "Invoice 2026-04-512 from Sunrise Logistics",
"ledgerEntryId": "ckxxxxxxxxxxxxxxxxxx",
"providerEventId": "ckxxxxxxxxxxxxxxxxxx",
"receivedAt": "2026-05-05T14:23:01.234Z"
}Field reference
| Field | Type | Notes |
|---|---|---|
event | string | Always "wallet_funded". |
merchantId | string | Your merchant id. Useful when one webhook endpoint serves multiple merchants. |
virtualAccount.provider | string | The funding provider that credited you. Used internally for support escalation. |
virtualAccount.accountNumber | string | The virtual account number that received the credit. |
virtualAccount.bankName | string | Display name of the partner bank backing the VA. |
virtualAccount.bankCode | string | NIBSS code of the partner bank. |
amountMinor | string | Net amount credited to your wallet, in minor units (kobo for NGN). String to keep BigInt-safe over JSON. |
currency | string | ISO 4217 currency code (e.g. NGN). |
sender | object | undefined | Present when the rail surfaces sender info. Absent on rails that don’t propagate sender details. |
sender.name | string | Sender’s bank-of-record name. May be empty in rare cases. |
sender.accountNumber | string | Sender’s account number, if exposed by the rail. |
sender.bankCode | string | NIBSS code of the sender’s bank, if exposed. |
narration | string | undefined | The free-text reference the sender typed on their transfer. Use this to reconcile against your own invoice numbers. |
ledgerEntryId | string | Stable id for the credit row in our ledger. Use this as your idempotency key (see below). |
providerEventId | string | Stable id for the underlying provider webhook event. Equal across redeliveries of the same provider event. |
receivedAt | string | ISO 8601 timestamp when we processed the credit on our side. |
Worked example: invoice reconciliation
A customer pays you ₦50,000 with their invoice number in the narration. Your handler matches it to an open invoice and marks it paid:
async function handleWalletFunded(event: WalletFundedEvent) {
// 1. Idempotency — skip duplicates of the same ledger entry.
if (await db.processedEvents.findUnique({ where: { id: event.ledgerEntryId } })) {
return;
}
// 2. 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.processedEvents.create({ data: { id: event.ledgerEntryId } }),
db.payments.create({
data: {
amountMinor: BigInt(event.amountMinor),
currency: event.currency,
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) } })]
: []),
]);
}Sample sender data per rail
Sender capture coverage varies slightly by funding provider. The shape is the same; what’s populated depends on what the rail exposes:
| Provider | sender.name | sender.accountNumber | sender.bankCode | narration |
|---|---|---|---|---|
| Monnify | ✓ | ✓ | ✓ | ✓ |
| Squad | ✓ (most rails) | ✓ (most rails) | ✓ (most rails) | ✓ |
| Paystack | ✓ (when authorization data present) | ✓ | ✓ | ✓ (falls back to reference if narration empty) |
| Blaaiz (FX VIBANs) | ✓ | ✓ (or iban) | ✓ (or sort_code / routing_number / bic_code) | ✓ |
When a field is missing from the upstream payload, we omit the key rather than send a placeholder so your handler can branch cleanly on event.sender?.name.
Payout lifecycle payload
Payout-side events follow a similar flat shape. Example for payout_paid:
{
"event": "payout_paid",
"type": "payout.paid",
"reference": "po_xxxxxxxxxxxx",
"amount_minor": "500000",
"currency": "NGN",
"recipient_name": "AMARACHI BLESSING NWAFOR",
"recipient_account": "0987654321",
"recipient_bank_code": "044",
"provider_ref": "TRF_xxxxxxxxxxxx",
"nip_reference": "100004250505...",
"completed_at": "2026-05-05T12:34:56.789Z"
}Field set varies slightly between payout_paid / payout_failed / payout_reversed. Failure events include failure_code + failure_message; reversal events include reversal_reason. Refer to the Payouts API for the full status lifecycle.
Retry policy
Failed deliveries (any non-2xx response or network error) are retried with exponential backoff:
| Attempt | Delay since previous |
|---|---|
| 1 | (initial) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
| 7 | 24 hours |
| 8 | 48 hours |
After 8 failed attempts, the delivery is marked giving_up and dropped. We alert your registered support email so you don’t silently lose webhooks.
You can replay any failed delivery manually from your dashboard at API & Webhooks → click the endpoint → Deliveries tab → Retry.
Idempotency on receive
Webhooks may be delivered more than once (for example, your handler returns 200 but our connection times out before we record the success). 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 |
| Others | the primary resource id in the payload |
Store the key alongside your downstream side-effect in a single transaction so a retry can’t leave you in a half-applied state.
Test endpoint
Use the Send test webhook button in your dashboard to fire a synthetic event without waiting for a real one. The synthetic body uses fictional sender data (e.g. BABATUNDE CHINEDU OKONKWO / 9988776655 / 057) and a _test: true flag so your handler can short-circuit any post-processing.
You can also fire it programmatically:
curl https://api.swappr.me/v1/webhook_endpoints/whe_xxx/test \
-X POST \
-H "Authorization: Bearer sk_test_..."Best practices
- Acknowledge fast — return 2xx within 10 seconds. Process heavy work async.
- Verify signatures — never trust unsigned bodies, even on internal networks.
- Idempotent handlers — safely process the same event twice using the table above.
- Whitelist Swappr’s IPs if your firewall enforces inbound IP rules. Contact support for the current list.
- Log everything — timestamp, signature, body — for debugging.
- Monitor your endpoint — set up alerting on 5xx rates from Swappr deliveries; investigate spikes promptly so retry budget doesn’t expire silently.