Your payment notification endpoint
This is the endpoint you host at the band’s notification URL. The bank POSTs to it at step 6 of the sequence, after the credit has already been posted to your account. It carries a mac field in the body, HMAC-SHA256 over three fields, and nothing else by way of signature.
A bank-issued (LOCAL) band is not notified this way; it receives a transaction.completed webhook on your subscription, signed with the X-Webhook-Signature header. See Events and payloads and Verifying signatures, and the last section of this page for the virtual-account fields in that payload.
Registration
Section titled “Registration”The bank registers the notification URL and the MAC key on your band when it is set up; there is no API to change them. The key is write-only: the portal shows the URL, never the key. To rotate the key or move the URL, ask your relationship manager. Until a notification URL is set, every credit to the band is recorded as owed-but-SKIPPED and nothing is sent.
The request
Section titled “The request”POST /your/notify/path HTTP/1.1Content-Type: application/jsonContent-Length: 412
{"accountNumber":"1000000042","custid":"1000000042","amount":5000.0000,"date":"20261105141530","channel":"mobile","channelCode":"1","senderAccount":"0123456789","senderAccountName":"ADAEZE OKAFOR","senderBank":"000013","senderBankName":"GTBank","reference":"000013251105141512000000000042","narration":"Invoice 0042","mac":"3f1c...e8a2"}Only Content-Type is sent. There is no signature header, no event id header and no timestamp header. The body is sent with Content-Length, never chunked.
| Field | Type | Description |
|---|---|---|
accountNumber |
string | The virtual account number that was paid. |
custid |
string | The same number again, under the legacy name. |
amount |
number | The gross amount the payer sent, before any band fee. Four decimal places, for example 5000.0000. |
date |
string | When the credit was confirmed, as yyyyMMddHHmmss in Africa/Lagos time. |
channel |
string | Always the literal mobile. |
channelCode |
string | The NIBSS channel code the sending bank used, or 1 when none was carried (always 1 for internal transfers). |
senderAccount |
string | The payer’s account number at their bank. |
senderAccountName |
string | The payer’s name as their bank sent it. |
senderBank |
string | The sending institution’s NIBSS code, taken from the first six characters of the session id. For an internal transfer, the bank’s own code. |
senderBankName |
string | The institution’s name, or Unknown Bank. |
reference |
string | The NIP session id. For an internal transfer, the bank’s transaction reference (TXR...). Your idempotency key. |
narration |
string | The payer’s narration, or an empty string. |
mac |
string | Lowercase hex HMAC-SHA256, see below. Empty string if the band has no key. |
The callback names the virtual account that was paid, not the bank-side account the money settled into.
Verifying mac
Section titled “Verifying mac”message = reference + amount + accountNumbermac = lowercase_hex( HMAC-SHA256( key = your MAC key as UTF-8 bytes, message ) )amountin the message is the amount’s plain decimal text exactly as it appears in the body, including its four decimal places:5000.0000, not5000or5000.00. Read it from the raw body as text; parsing it into a float and re-formatting will change it.- There is no separator between the three parts.
- Compare in constant time.
Worked example with the key va-mac-key-xxxxxxxx (a documentation value):
reference 000013251105141512000000000042amount 5000.0000accountNumber 1000000042message 000013251105141512000000000042 5000.0000 1000000042 (shown with spaces; sign it without them)printf '%s' '0000132511051415120000000000425000.00001000000042' \ | openssl dgst -sha256 -hmac 'va-mac-key-xxxxxxxx'There is no timestamp in the signature, so a captured request stays valid until the key changes. Dedupe on reference (see below) and keep the endpoint private; that is your replay protection. The same key signs the name enquiry, with HMAC-SHA1 there.
Your response
Section titled “Your response”Answer with any 2xx status as soon as you have stored the request. The body is ignored, but it is recorded (up to 4,000 characters) and shown to the bank’s operators and to you on the inflow’s detail, so a short JSON {"status":"ok"} or {"status":"duplicate"} helps everyone later.
Timeouts are 10 seconds to connect and 15 seconds to read. Anything that is not a 2xx inside that window is a failure: a 4xx, a 5xx, a timeout, a TLS or DNS error. Do not process inline; acknowledge, then act.
Retries
Section titled “Retries”The callback job runs every 30 seconds and re-sends every callback that has not succeeded, up to 48 attempts, with no backoff between them. In practice that is about 24 minutes of retries after the first failure. After the 48th failure the callback is FAILED and nothing retries it automatically. The credit is unaffected: the money was in your account before the first attempt.
Every retry sends the identical body, including the same mac.
Idempotency
Section titled “Idempotency”- Dedupe on
reference(plusaccountNumberif you want belt and braces). It is the NIP session id, unique per transfer, and it is the same on every retry and every resend. - A duplicate must still be answered 2xx, or the bank will keep retrying the one you already have.
- Never credit your own ledger twice for one
reference.
Resend
Section titled “Resend”When your endpoint was down, you or the bank can send a callback again.
- In the portal, open the payment on Inflows and choose Resend. It is available to the portal roles that may resend notifications, limited to 20 resends per band per hour, and it posts synchronously: the page waits for your endpoint’s answer and shows it.
- The resend sends the original body byte for byte, with the original
mac. If the bank has rotated your MAC key since, the replayedmacwill fail your check; that outcome is recorded rather than re-signed, so agree key rotations with the bank before relying on resends. - A resend is a new delivery record linked to the original; the original’s outcome is never overwritten.
- Only a callback that has been attempted at least once can be resent; one still
PENDINGis with the queue.
Test pings
Section titled “Test pings”The bank’s operators can fire a connectivity test at your notifyUrl. It is a real, payment-shaped POST with amount 0, reference starting TEST-, senderAccount 0000000000, senderAccountName BANKCORE CONNECTIVITY TEST, senderBank 999999, accountNumber set to the first number of your band, and the narration Endpoint connectivity test - not a payment. Accept it with 2xx and do not credit anything. The mac is computed the same way, so it is also a signing check.
VA fields on the LOCAL-band webhook
Section titled “VA fields on the LOCAL-band webhook”For a LOCAL band the transaction.completed payload carries, beside the standard fields, the virtual-account context from the transaction’s metadata:
| Field | Description |
|---|---|
virtualAccountNumber, virtualAccountName |
The number paid and the name you set. |
externalRef |
The external_ref you passed at creation. |
beneficiaryAccount, beneficiaryName |
The settlement account that was credited. |
grossAmount, feeAmount, netAmount |
The split. The top-level amount is the net. |
senderName, senderAccount, senderBank, senderBankName, nipSessionId, narration |
The sender and the session. |
Dedupe on X-Webhook-Id, as for every webhook. The bank can scope a subscription to your bands only, so that it receives virtual-account events and nothing else; ask for that if your subscription is shared with other traffic.
In the portal
Section titled “In the portal”Inflows shows each payment’s notification status, the time it was delivered, attempt count, your endpoint’s HTTP status and the error text, with the Resend action on REMOTE bands. On a LOCAL band the same page shows the webhook outcome, and resends are on the Webhooks page.