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> | Signed timestamp + HMAC. 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. When the credited account belongs to a customer, the payload also carries acustomerblock.
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": "<provider_slug>",
"accountNumber": "8001234567",
"bankName": "Moniepoint MFB",
"bankCode": "50515"
},
"customer": {
"id": "cus_xxxxxxxxxxxx",
"customer_reference": "your-internal-user-id"
},
"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"
}On the Interac e-Transfer (CAD) rail the shape differs: rail is "interac", the sender is an email (sender.interac_email), and there is no virtualAccount (Interac has no virtual account). The common fields — amountMinor, currency, narration, ledgerEntryId, receivedAt — are identical, so ledgerEntryId is your idempotency key on CAD too. customer is present only when the credit lands on a customer-linked address.
{
"event": "wallet_funded",
"rail": "interac",
"merchantId": "ckxxxxxxxxxxxxxxxxxx",
"amountMinor": "1000000",
"currency": "CAD",
"sender": {
"name": "JOE DOE",
"interac_email": "joe@doe.ca"
},
"narration": "Rent June",
"ledgerEntryId": "ckxxxxxxxxxxxxxxxxxx",
"receivedAt": "2026-06-21T14: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. |
rail | string | undefined | The inbound rail. Present as "interac" on Interac e-Transfer (CAD) inflows; omitted on virtual-account (bank-transfer) inflows. Branch on event.rail === "interac" to read the Interac sender shape. |
virtualAccount | object | undefined | The virtual account that received the credit. Omitted on Interac (CAD) inflows — Interac has no virtual account. |
virtualAccount.provider | string | Opaque internal slug. Surfaced for diagnostic correlation with support. Do not parse, switch on, or assert — the value may change without notice. |
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. |
customer | object | undefined | Present only when the credited virtual account belongs to a customer. Omitted (not null) for merchant-pool inflows — branch on event.customer?.id. |
customer.id | string | The Swappr customer id (cus_...). |
customer.customer_reference | string | null | Your own reference for that customer. |
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. |
sender.interac_email | string | Interac (CAD) only. The sender’s Interac e-Transfer email, present in lieu of accountNumber/bankCode on the Interac rail. |
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) } })]
: []),
]);
}Sender data coverage by currency
Sender field capture depends on what the underlying rail exposes. Swappr normalizes everything into the same sender shape; what’s populated varies:
| Currency | sender.name | Account identifier | Bank identifier | narration |
|---|---|---|---|---|
| NGN (NUBAN inflows) | ✓ | accountNumber (10-digit NUBAN) | bankCode (NIBSS code) | ✓ |
| GBP (UK rails) | ✓ | accountNumber or iban | sortCode or bic_code | ✓ |
| USD (US rails) | ✓ | accountNumber or iban | routingNumber (ACH) or bic_code (SWIFT) | ✓ |
| EUR (EU rails) | ✓ | iban | bic_code | ✓ |
| CAD (Interac e-Transfer) | ✓ | interac_email (in lieu of account number) | n/a | ✓ |
When a field is missing from the upstream payload — some rails don’t propagate sender info under all conditions — we omit the key rather than send a placeholder. Branch cleanly on event.sender?.name etc.
Payout lifecycle payload
Payout events fire on every real status transition — payout.processing, payout.paid, payout.failed, payout.reversed — and carry a standardized flat shape:
{
"event": "payout.paid",
"payout_id": "ckpayout_xxxxxxxxxxxx",
"reference": "swappr_xxxxxxxxxxxx",
"merchant_reference": "your-ref-123",
"customer": {
"id": "cus_xxxxxxxxxxxx",
"customer_reference": "your-internal-user-id"
},
"amount_minor": "500000",
"currency": "NGN",
"status": "paid",
"failure_code": null,
"failure_message": null,
"beneficiary": {
"account_name": "AMARACHI BLESSING NWAFOR",
"account_number": "0987654321",
"bank_code": "044",
"bank_name": "Access Bank"
},
"provider": "<provider_slug>",
"provider_reference": "100004250505...",
"occurred_at": "2026-05-05T12:34:56.789Z"
}Field reference
| Field | Type | Notes |
|---|---|---|
event | string | Dotted event name: payout.processing / payout.paid / payout.failed / payout.reversed. (The X-Swappr-Event header carries the underscore enum form — payout_paid — so route on the header or the body’s event, consistently.) |
payout_id | string | The Swappr payout id. |
reference | string | The payout reference you can look up via GET /v1/payouts/{reference}. |
merchant_reference | string | null | Your own reference passed at create time. |
customer | object | undefined | Present only when the payout was attributed to a customer (customer_id on create). Omitted otherwise. |
amount_minor | string | Principal in minor units (BigInt-safe string). |
currency | string | ISO 4217 currency code. |
status | string | The payout status after the transition (processing / paid / failed / reversed). |
failure_code / failure_message | string | null | Populated on payout.failed; null otherwise. |
beneficiary | object | account_name, account_number, bank_code, bank_name of the recipient. |
provider | string | null | Opaque internal slug — diagnostic correlation only. Do not parse or switch on it. |
provider_reference | string | null | NIP session id (network-level) when known, else the provider’s internal ref. |
occurred_at | string | ISO 8601 timestamp of the transition. |
Happy-path payout events now fire on normal transitions. Previously payout.* events fired only when an admin force-closed a payout; they now fire on every real state change (provider webhook, async re-query, dispatch → process → terminal). Each fires once per transition — duplicate provider webhooks / poll ticks don’t re-fire. Idempotency on receive is still your responsibility — key on reference (see the table below).
Heads up — two payout payload shapes exist today. The standardized shape above fires on every real provider/poll transition (the vast majority of cases). A small number of payouts force-closed by Technest support emit a legacy shape with a type field (instead of event), a flat recipient.* block (instead of nested beneficiary{}), and completed_at (instead of occurred_at). Treat both shapes equivalently — key on reference for idempotency, and read either event (dotted) or type (dotted) for the transition. A future release will standardize the admin path to the shape above; this callout will go away when it does.
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 delivery via the API — POST /v1/webhook_deliveries/{id}/replay (see the webhook deliveries reference) — or 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.
Testing webhooks in sandbox
There are two ways to exercise your handler in sandbox, depending on whether you want a quick synthetic ping or a realistic end-to-end event.
1. Send test webhook
Use the Send test webhook button in your dashboard to fire a synthetic event without waiting for a real one. The synthetic body uses generic placeholder sender data (e.g. JANE DOE / 0000000000 / 058) and a _test: true flag so your handler can short-circuit any post-processing. No funds move.
You can also fire it programmatically:
curl https://api.swappr.me/api/v1/webhook_endpoints/whe_xxx/test \
-X POST \
-H "Authorization: Bearer sk_test_..."2. Simulate inbound funding
For a realistic, rail-faithful test, use Simulate inbound funding on a sandbox wallet (Wallets → pick a wallet → Simulate funding). Unlike the synthetic test above, this posts a real ledger credit and fires the exact wallet_funded payload your production handler will receive — with a real ledgerEntryId, no _test flag.
The event is rail-faithful: on a CAD wallet you pick the Interac rail and it fires the Interac-shaped event (rail: "interac", sender.interac_email, no virtualAccount); on other currencies it fires the virtual-account shape. This is the recommended way to verify your idempotency keying and sender parsing end-to-end before going live.
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.