Skip to main content

Step 3: Receive Payment Webhooks

When Emeka transfers to his NUBAN, Kanall fires a POST to your webhook URL within seconds. Your job: verify the signature, respond 200 OK immediately, then process asynchronously.


Webhook payload

{
"eventType": "payment.received",
"transactionGroupId": "9c4d1f3b-2e5a-4b7c-8d0e-1f2a3b4c5d6e",
"accountRef": "distributor-emeka",
"amount": "45000.00",
"gross_amount": "45050.00",
"nomba_fee": "50.00",
"currency": "NGN",
"senderName": "Emeka Okafor",
"narration": "Transfer from Emeka Okafor",
"status": "provisional"
}
FieldDescription
accountRefYour externalRef — use this to look up the distributor in your database
amountNet naira credited to the balance (after Nomba's NIP fee)
gross_amountWhat Emeka actually sent
nomba_feeWhat Nomba deducted — informational only
transactionGroupIdKanall's internal group ID — use this for idempotency and statement lookups
statusAlways "provisional" on first delivery — see below

Verify the signature

Every delivery from Kanall includes an X-Kanall-Signature: t={timestamp},v1={hmac_hex} header. Before processing any event, verify it using HMAC-SHA256 against your KANALL_WEBHOOK_SECRET (retrieve it once with POST /auth/webhook-secret).

For the complete verification function in all languages, see the Webhook Signature Verification guide. The handlers below show it wired in directly.


Webhook handler

Acknowledge Kanall immediately with 200 OK, then process asynchronously. If your endpoint is slow or returns non-2XX, Kanall retries with exponential backoff — so never make the webhook wait on a database write.

// routes/webhooks.js — Express
const express = require('express')
const router = express.Router()
const { verifyKanallSignature } = require('../verify-signature')

const WEBHOOK_SECRET = process.env.KANALL_WEBHOOK_SECRET

router.post('/kanall', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['x-kanall-signature']

if (!sig || !verifyKanallSignature(req.body, sig, WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'invalid signature' })
}

// Acknowledge immediately — never block on DB writes here
res.status(200).json({ received: true })

// Parse and enqueue for async processing
const event = JSON.parse(req.body)
queue.add('handle-payment', event)
})

module.exports = router
// workers/handle-payment.js
async function handlePayment(event) {
const { accountRef, transactionGroupId, amount, status } = event

// Idempotency — skip if already processed
const existing = await db.query(
'SELECT id FROM payment_events WHERE group_id = $1',
[transactionGroupId]
)
if (existing.rows.length > 0) return

const distributor = await db.query(
'SELECT id FROM distributors WHERE kanall_ref = $1',
[accountRef]
)
if (!distributor.rows.length) {
console.error(`Unknown accountRef: ${accountRef}`)
return
}

await db.query(
`INSERT INTO payment_events (group_id, distributor_id, amount, status, received_at)
VALUES ($1, $2, $3, $4, now())`,
[transactionGroupId, distributor.rows[0].id, amount, status]
)
}

:::warning Use raw body for signature verification Parse the JSON body after verifying the signature. Many frameworks buffer and re-encode the body before it reaches your handler, which changes whitespace and breaks the HMAC. Use express.raw() in Express, request.body in Django, or @RequestBody byte[] in Spring Boot — before any JSON parsing middleware touches the body. :::


Handling provisional vs confirmed

status is always "provisional" at first delivery. Kanall has received the webhook from Nomba but the confirmation pipeline has not yet verified the payment against Nomba's transaction ledger. This typically resolves within a few seconds.

Do not clear invoices or release credit holds on provisional events.

When the payment is confirmed, Kanall does not send a second webhook. Instead, either:

  • Query GET /v1/accounts/:accountRef/statement a few seconds later and check whether the entry moved to confirmed
  • Or design your system to act on provisional and reconcile against the statement at end-of-day (covered in Step 4)

Retry behaviour

If your endpoint returns non-2XX, Kanall retries with exponential backoff:

AttemptDelay
1 (initial)Immediate
22 minutes
35 minutes
411 minutes
524 minutes
653 minutes

After 6 total attempts, the delivery is marked dead_letter. Check GET /v1/webhooks/dead-letters and reconcile any missed events against the statement API.


Test locally with ngrok

ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> localhost:3000

Update your tenant webhook URL to the ngrok tunnel:

curl -X POST https://kanall.onrender.com/auth/webhook-url \
-H "X-API-Key: $KANALL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://abc123.ngrok.io/webhooks/kanall"}'

Or override a single account's URL without changing the tenant default:

curl -X PATCH https://kanall.onrender.com/v1/accounts/distributor-emeka \
-H "X-API-Key: $KANALL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"callbackUrl": "https://abc123.ngrok.io/webhooks/kanall"}'

Common errors

ProblemCauseFix
Signature fails after HMAC looks correctFramework re-encoded the JSON bodyCapture the raw byte stream before JSON parsing
Signature fails on replayTimestamp more than 5 minutes oldEnsure your server clock is synced (NTP)
401 on webhook endpointNo signature or wrong secretConfirm KANALL_WEBHOOK_SECRET matches the value from POST /auth/webhook-secret
Duplicate events processedNo idempotency checkAlways check transactionGroupId before inserting

Next: Reconcile and report →