Guides
Authentication
Payment APIs use HMAC-SHA256 with a nonce and timestamp to prevent tampering and replay attacks. Call them from your server: browser calls are refused, and the secret must stay server-side.
Required headers
X-API-KEY— public API keyX-Nonce— new on every request, including a retry (UUID)X-Timestamp— Unix epoch secondsX-Signature— lowercase hex HMACX-Merchant-ID— your App IDX-Tenant-ID—testorlive: selects the environment and is not signed. See Sandbox vs Live.
Signature construction
- Build a canonical body from the fields the endpoint signs, listed below: leave out nulls and absent fields, sort by key ascending, and join as
key=value&key=valuewith no URL encoding. An empty string stays, asnarration=. Do not sign any other field, and do not send one you would be tempted to sign. - Compute
bodySha256Hex = hex(SHA256(canonicalBody)). - Build the base string (literal newlines between parts, none at the end):
apiKey
nonce
timestampEpochSeconds
METHOD
requestPath
bodySha256HexThen signature = hex(HMAC_SHA256(secretKey, baseString)), in lowercase hex, using the secret exactly as issued. The method, path and fields depend on the endpoint. The path includes /payment and never a query string.
| Endpoint | Method and path | Signed fields |
|---|---|---|
| Create a payin | POST /payment/api/payins | amount currency customerEmail customerName gateway narration notifyUrl paymentMethod redirectUrl reference |
| Query payin status | GET /payment/api/payins/status | reference, the decoded value |
| Account enquiry | POST /payment/merchant-api/account/inquiry | accountNumber bankCode |
| Query payout status | POST /payment/merchant-api/payout/status | reference |
Send amount as a string
The server signs amount as the text you sent. Send a JSON string such as "amount":"2000" and sign amount=2000. "2000.00" signs as amount=2000.00, a different signature. Do not use leading zeros: "02000" signs as amount=2000. With a JSON number, your language can format the sent and signed values differently: PHP's json_encode(1000.10 + 1000.20) sends 2000.3000000000002, while casting it to a string gives 2000.3.
Check your code
These dummy values, which work on no account, must produce this signature:
apiKey = FAKEapiKEY000000000000
secretKey = FAKEsecretKEY000000000000000000000000000000
nonce = fakeNonce0000000000001
timestamp = 1793440800
POST /payment/api/payins
canonicalBody = amount=2000¤cy=NGN&customerEmail=jane@example.com&customerName=Jane Doe&narration=Order #1042&paymentMethod=CARD&redirectUrl=https://merchant.example/return&reference=ORDER-self-check-0001
bodySha256Hex = d29d6f5a3e2a0f61c4579972b23d801416913e1689e286921a92ed3fb4c696dc
signature = cbeb862278de6b112a4aeb6e99a12ffc398152475d3a055826b3bec02d958401Server checks
In this order:
- The merchant exists and is active, and the API key is its key
- Timestamp within ±300 seconds of server time
- Nonce never seen before. It is recorded here, before the next check, so a refused request burns its nonce.
- Signature matches the recomputed value, compared exactly
- If
X-Tenant-IDislive, the business is approved for live: otherwise 403LIVE_NOT_ENABLED
| Endpoint | When authentication fails |
|---|---|
| Create a payin, Query payin status | 401 with an empty body: do not parse it as JSON. A non-numeric X-Timestamp gets 502 with a JSON body instead |
| Query payout status | 401 with a JSON code, such as SIGNATURE_MISMATCH |
| Account enquiry | HTTP 200 with status: "error", a code and httpStatus: 401 in the body. A missing header gets a 502. |
Best practices
- Keep secretKey server-side only
- Synchronize clocks with NTP
- Use a new nonce on every attempt, and a UUID rather than a counter
- Sign the same text you send, field by field, and no fields beyond the list above
Continue with Create a payin.