Skip to content

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.

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.

POST /your/notify/path HTTP/1.1
Content-Type: application/json
Content-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.

message = reference + amount + accountNumber
mac = lowercase_hex( HMAC-SHA256( key = your MAC key as UTF-8 bytes, message ) )
  • amount in the message is the amount’s plain decimal text exactly as it appears in the body, including its four decimal places: 5000.0000, not 5000 or 5000.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 000013251105141512000000000042
amount 5000.0000
accountNumber 1000000042
message 000013251105141512000000000042 5000.0000 1000000042 (shown with spaces; sign it without them)
Terminal window
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.

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.

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.

  • Dedupe on reference (plus accountNumber if 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.

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 replayed mac will 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 PENDING is with the queue.

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.

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.

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.