Webhooks

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

  1. Create an endpoint in your dashboard: API & Webhooks+ Add webhook endpoint.
  2. Pick the events you want delivered (wallet_funded is the typical first subscription).
  3. Save — you’ll see a signing secret ONCE. Store it securely.
  4. Verify the signature on every incoming webhook (see below).

Headers

Every delivery includes:

HeaderValuePurpose
X-Swappr-Signaturet=<unix>,v1=<hex>Signed timestamp + HMAC. See verification below.
X-Swappr-Evente.g. wallet_fundedThe event type. Useful for routing without parsing the body.
User-AgentSwappr-Webhooks/1.0Identifies our deliveries in your logs.
Content-Typeapplication/jsonBody 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 a customer block.

Payout lifecycle

  • payout_paid — payout reached the recipient successfully
  • payout_failed — payout failed; if it was post-debit, the wallet was auto-reversed
  • payout_reversed — admin or merchant explicitly reversed a paid payout
  • payout_queued / payout_processing — fired during normal status transitions

Bulk batches

  • batch_awaiting_approval — bulk batch above your dual-control threshold; needs a second admin
  • batch_approved — batch cleared dual-control, dispatching to providers
  • batch_completed / batch_completed_with_errors — batch finished

Other

  • virtual_account_provisioned — admin provisioned a new VA on your wallet
  • beneficiary_verified — saved beneficiary’s name was confirmed via NUBAN resolve
  • kyc_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

FieldTypeNotes
eventstringAlways "wallet_funded".
merchantIdstringYour merchant id. Useful when one webhook endpoint serves multiple merchants.
railstring | undefinedThe 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.
virtualAccountobject | undefinedThe virtual account that received the credit. Omitted on Interac (CAD) inflows — Interac has no virtual account.
virtualAccount.providerstringOpaque internal slug. Surfaced for diagnostic correlation with support. Do not parse, switch on, or assert — the value may change without notice.
virtualAccount.accountNumberstringThe virtual account number that received the credit.
virtualAccount.bankNamestringDisplay name of the partner bank backing the VA.
virtualAccount.bankCodestringNIBSS code of the partner bank.
customerobject | undefinedPresent only when the credited virtual account belongs to a customer. Omitted (not null) for merchant-pool inflows — branch on event.customer?.id.
customer.idstringThe Swappr customer id (cus_...).
customer.customer_referencestring | nullYour own reference for that customer.
amountMinorstringNet amount credited to your wallet, in minor units (kobo for NGN). String to keep BigInt-safe over JSON.
currencystringISO 4217 currency code (e.g. NGN).
senderobject | undefinedPresent when the rail surfaces sender info. Absent on rails that don’t propagate sender details.
sender.namestringSender’s bank-of-record name. May be empty in rare cases.
sender.accountNumberstringSender’s account number, if exposed by the rail.
sender.bankCodestringNIBSS code of the sender’s bank, if exposed.
sender.interac_emailstringInterac (CAD) only. The sender’s Interac e-Transfer email, present in lieu of accountNumber/bankCode on the Interac rail.
narrationstring | undefinedThe free-text reference the sender typed on their transfer. Use this to reconcile against your own invoice numbers.
ledgerEntryIdstringStable id for the credit row in our ledger. Use this as your idempotency key (see below).
providerEventIdstringStable id for the underlying provider webhook event. Equal across redeliveries of the same provider event.
receivedAtstringISO 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:

Currencysender.nameAccount identifierBank identifiernarration
NGN (NUBAN inflows)accountNumber (10-digit NUBAN)bankCode (NIBSS code)
GBP (UK rails)accountNumber or ibansortCode or bic_code
USD (US rails)accountNumber or ibanroutingNumber (ACH) or bic_code (SWIFT)
EUR (EU rails)ibanbic_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

FieldTypeNotes
eventstringDotted 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_idstringThe Swappr payout id.
referencestringThe payout reference you can look up via GET /v1/payouts/{reference}.
merchant_referencestring | nullYour own reference passed at create time.
customerobject | undefinedPresent only when the payout was attributed to a customer (customer_id on create). Omitted otherwise.
amount_minorstringPrincipal in minor units (BigInt-safe string).
currencystringISO 4217 currency code.
statusstringThe payout status after the transition (processing / paid / failed / reversed).
failure_code / failure_messagestring | nullPopulated on payout.failed; null otherwise.
beneficiaryobjectaccount_name, account_number, bank_code, bank_name of the recipient.
providerstring | nullOpaque internal slug — diagnostic correlation only. Do not parse or switch on it.
provider_referencestring | nullNIP session id (network-level) when known, else the provider’s internal ref.
occurred_atstringISO 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:

AttemptDelay since previous
1(initial)
21 minute
35 minutes
430 minutes
52 hours
612 hours
724 hours
848 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:

EventIdempotency key
wallet_fundedevent.ledgerEntryId
payout_paid / payout_failed / payout_reversedevent.reference
kyc_status_changedthe submission id in the payload
Othersthe 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

  1. Acknowledge fast — return 2xx within 10 seconds. Process heavy work async.
  2. Verify signatures — never trust unsigned bodies, even on internal networks.
  3. Idempotent handlers — safely process the same event twice using the table above.
  4. Whitelist Swappr’s IPs if your firewall enforces inbound IP rules. Contact support for the current list.
  5. Log everything — timestamp, signature, body — for debugging.
  6. Monitor your endpoint — set up alerting on 5xx rates from Swappr deliveries; investigate spikes promptly so retry budget doesn’t expire silently.

What’s next