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 key
  • X-Nonce — new on every request, including a retry (UUID)
  • X-Timestamp — Unix epoch seconds
  • X-Signature — lowercase hex HMAC
  • X-Merchant-ID — your App ID
  • X-Tenant-ID — test or live: selects the environment and is not signed. See Sandbox vs Live.

Signature construction

  1. 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=value with no URL encoding. An empty string stays, as narration=. Do not sign any other field, and do not send one you would be tempted to sign.
  2. Compute bodySha256Hex = hex(SHA256(canonicalBody)).
  3. Build the base string (literal newlines between parts, none at the end):
Base string
apiKey
nonce
timestampEpochSeconds
METHOD
requestPath
bodySha256Hex

Then 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.

EndpointMethod and pathSigned fields
Create a payinPOST /payment/api/payinsamount currency customerEmail customerName gateway narration notifyUrl paymentMethod redirectUrl reference
Query payin statusGET /payment/api/payins/statusreference, the decoded value
Account enquiryPOST /payment/merchant-api/account/inquiryaccountNumber bankCode
Query payout statusPOST /payment/merchant-api/payout/statusreference

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:

Self-check
apiKey    = FAKEapiKEY000000000000
secretKey = FAKEsecretKEY000000000000000000000000000000
nonce     = fakeNonce0000000000001
timestamp = 1793440800
POST /payment/api/payins

canonicalBody = amount=2000&currency=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     = cbeb862278de6b112a4aeb6e99a12ffc398152475d3a055826b3bec02d958401

Server 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-ID is live, the business is approved for live: otherwise 403 LIVE_NOT_ENABLED
EndpointWhen authentication fails
Create a payin, Query payin status401 with an empty body: do not parse it as JSON. A non-numeric X-Timestamp gets 502 with a JSON body instead
Query payout status401 with a JSON code, such as SIGNATURE_MISMATCH
Account enquiryHTTP 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.