Skip to main content

Webhooks API

Kanall has two webhook flows:

  • Inbound — Nomba fires a webhook to Kanall on every payment. Kanall verifies, records, and fans out.
  • Outbound — Kanall fires a signed webhook to your callbackUrl for each payment received on your accounts.

Inbound webhook (Nomba → Kanall)

POST /webhooks/nomba

This endpoint is called by Nomba, not by your application. Do not call it directly.

When a payment arrives on any virtual account provisioned under your sub-account, Nomba delivers a signed webhook here. Kanall:

  1. Verifies the HMAC-SHA256 signature
  2. Checks idempotency (requestId)
  3. Writes two ledger entries (credit + debit) in a single transaction
  4. Queues outbound delivery to the account's callbackUrl

Nomba webhook payload:

{
"event_type": "payment_success",
"requestId": "17ef290f-ceff-4b6d-bd66-f1744fa96406",
"data": {
"merchant": {
"userId": "22f28de5-899a-49e8-ae05-f11d66250a74",
"walletId": "6a3bea8ba3da553c0da570cf",
"walletBalance": 4975
},
"transaction": {
"transactionId": "API-VACT_TRA-22F28-c7912a15-f3e2-4ade-99bb-cb268a143d27",
"type": "vact_transfer",
"time": "2026-07-03T10:47:15Z",
"responseCode": "",
"transactionAmount": 5000,
"fee": 25,
"currency": "NGN",
"aliasAccountReference": "driver-001",
"narration": "Transfer from Chidi Emmanuel"
},
"customer": {
"senderName": "Chidi Emmanuel",
"accountNumber": "0987654321",
"bankName": "Access Bank"
}
}
}

:::note Amounts in the webhook are in naira transactionAmount: 5000 means ₦5,000 — the gross amount the payer sent. fee: 25 is also in naira — the NIP fee Nomba deducted. Kanall records the net (transactionAmount - fee = ₦4,975) in the ledger. The bulk transactions list API uses kobo, but the webhook payload uses naira. :::

Key field mappings:

Nomba fieldKanall usage
requestIdIdempotency key — stored in processed_events. Stable across Nomba retries.
data.transaction.transactionIdledger_entries.NombaTxnRef
data.transaction.aliasAccountReferenceUsed to look up the matching virtual account
data.transaction.transactionAmountGross amount — used for fee calculation and expectedAmount matching
data.transaction.feeNomba's NIP fee — subtracted from gross to get the net ledger amount
data.customer.senderNameStored as narration on the ledger entry

Kanall always returns 200 OK to Nomba — including on signature failures and already-processed events — to prevent Nomba from retrying indefinitely.


Inbound signature verification

Nomba signs each webhook request with HMAC-SHA256. Kanall verifies this automatically. The signed string is a 9-field colon-separated string — not the raw request body:

{event_type}:{requestId}:{merchant.userId}:{merchant.walletId}:{transaction.transactionId}:{transaction.type}:{transaction.time}:{transaction.responseCode}:{nomba-timestamp}

responseCode == "null" (the string) is normalised to "" before signing.

Verification uses the nomba-signature and nomba-timestamp headers. The signing secret is set via the NOMBA_WEBHOOKS_SIGNING_SECRET environment variable — never hardcoded.


Event categories

Every inbound webhook event is classified into one of the following categories:

CategoryDescription
paymentSignature valid, aliasAccountReference matched a known virtual account, ledger written
misdirectedSignature valid, but aliasAccountReference did not match any provisioned account
sig_invalidSignature verification failed — event is not processed further
non_payment_eventNomba fired a non-payment event type (e.g. terminal events) — ignored
processing_errorSignature was valid and account found, but ledger write failed

You can query your own payment failures via GET /v1/webhooks/dead-letters. Misdirected events are visible from the dashboard — see List misdirected payments.


Outbound webhook (Kanall → your server)

When a payment is received, Kanall delivers a webhook to the callbackUrl you registered for the account.

Outbound payload:

{
"eventType": "payment.received",
"transactionGroupId": "9c4d1f3b-2e5a-4b7c-8d0e-1f2a3b4c5d6e",
"accountRef": "driver-001",
"amount": "4975.00",
"gross_amount": "5000.00",
"nomba_fee": "25.00",
"currency": "NGN",
"senderName": "Chidi Emmanuel",
"narration": "Transfer from Chidi Emmanuel",
"status": "provisional"
}
FieldDescription
amountNet amount credited to the balance (after Nomba's fee)
gross_amountWhat the payer actually sent
nomba_feeThe NIP fee Nomba deducted
transactionGroupIdKanall's internal group ID — use this to look up entries in the statement
statusAlways "provisional" on initial delivery

status is always "provisional" at delivery time. The confirmation pipeline promotes it to confirmed shortly after — usually within seconds. Query the Statement API if you need to check the current status of a specific entry.

Kanall retries failed deliveries with exponential backoff across up to 5 attempts (2 min → 5 min → 11 min → 24 min → 53 min). Deliveries that exhaust all attempts become dead letters.


Outbound signing

If you have configured a webhook signing secret via POST /auth/webhook-secret, Kanall signs every outbound delivery and includes the signature in the X-Kanall-Signature header:

X-Kanall-Signature: t=1751500000,v1=3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e

Verifying the signature

The signed payload is {timestamp}.{raw_body}.

import hmac, hashlib, time

def verify_kanall_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = {k: v for k, v in (p.split("=", 1) for p in header.split(","))}
timestamp = parts.get("t", "")
signature = parts.get("v1", "")

# Reject stale timestamps (±5 minutes)
if abs(time.time() - int(timestamp)) > 300:
return False

expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()

return hmac.compare_digest(expected, signature)
import { createHmac, timingSafeEqual } from "crypto";

function verifyKanallSignature(
header: string,
rawBody: Buffer,
secret: string,
): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const { t: timestamp, v1: signature } = parts;

if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

const payload = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = createHmac("sha256", secret).update(payload).digest("hex");

return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Accounts without a configured signing secret receive unsigned deliveries — the X-Kanall-Signature header is absent.


Get dead letters

GET /v1/webhooks/dead-letters

Returns outbound webhook deliveries that exhausted all retry attempts and were permanently marked as failed.

curl https://kanall.onrender.com/v1/webhooks/dead-letters \
-H "X-API-Key: ten_sk_..."

Response: 200 OK

{
"deadLetters": [
{
"ID": "d4e5f6a7-...",
"CallbackURL": "https://app.naijadash.com/webhooks/payment",
"Status": "dead_letter",
"AttemptCount": 5,
"LastError": "connection refused (dial tcp: connect: connection refused)",
"NextRetryAt": null,
"CreatedAt": "2026-07-01T11:00:00Z"
}
]
}
FieldDescription
CallbackURLThe URL Kanall attempted to deliver to
AttemptCountTotal delivery attempts made (max 5)
LastErrorThe HTTP or network error from the last attempt
NextRetryAtnull for dead letters — no further retries will be attempted
CreatedAtWhen the original payment event was received

Recovery: Dead letters are informational. To recover missed events, query the statement for the affected virtual account using GET /v1/accounts/:accountRef/statement and replay the entries in your own system.