Request Signing (HMAC)

Sign requests that move money with your public key and an HMAC signature.

Endpoints that send money out of your account are protected with request signing. Instead of your bearer API key, you sign each request with a secret only you hold. Once bearer-only access ends on 28 November 2026, someone who obtains your API key alone can neither make a payout nor replace your signing keys.

Which endpoints use signing

EndpointAuthentication
POST /api/v1/wallet/transfer/singleSignature required
POST /api/v1/wallet/transfer/bulkSignature required
POST /api/v1/banking/disburseSignature or bearer API key until 28 November 2026, then signature required
POST /api/v1/wallet/send-money (legacy)Signature or bearer API key until 28 November 2026, then signature required

Every other endpoint (orders, escrow, direct pay, accounts, banks, transactions) keeps using your bearer API key: Authorization: Bearer cnt-live_....

🚧

Bearer key retirement

From 28 November 2026, banking/disburse and wallet/send-money stop accepting requests authenticated with only the bearer API key. Switch to signing before then.

1. Generate your signing keys

Call POST /api/v1/wallet/transfer/credentials with your bearer API key:

curl -X POST https://api.centiiv.com/api/v1/wallet/transfer/credentials \
  -H "Authorization: Bearer cnt-live_YOUR_API_KEY"
{
  "success": true,
  "data": {
    "publicKey": "pk_live_...",
    "secretKey": "sk_live_..."
  }
}
❗️

The secret key is shown once

Store secretKey in a secrets manager or server-side environment variable straight away. It is never shown again, and it must never be sent to a browser or mobile app.

Your test-mode API key (cnt-test_...) gives you test-mode signing keys, so you can try everything without moving real money.

Replacing your keys

Once you have a pair, the bearer API key alone can't replace it. Call the same endpoint with your bearer key and the three signing headers from your current pair (step 2 below). The request has no body, so sign "{timestamp}." (the timestamp followed by a full stop). The old pair stops working as soon as the new one is issued.

const timestamp = Date.now().toString();
const signature = crypto
  .createHmac("sha256", process.env.CENTIIV_SECRET_KEY)
  .update(`${timestamp}.`)
  .digest("hex");

await fetch("https://api.centiiv.com/api/v1/wallet/transfer/credentials", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CENTIIV_API_KEY}`,
    "X-Api-Public-Key": process.env.CENTIIV_PUBLIC_KEY,
    "X-Api-Timestamp": timestamp,
    "X-Api-Signature": signature,
  },
});

Lost your secret key? Regenerate your API key in the Centiiv dashboard. That removes the old signing pair along with the old API key, so you can then generate a new pair with the new API key.

2. Sign each request

Send these three headers in place of the Authorization header:

HeaderValue
X-Api-Public-KeyYour public key.
X-Api-TimestampThe current time in milliseconds since the Unix epoch, e.g. 1793296051123.
X-Api-SignatureHex-encoded HMAC-SHA256(secretKey, "{timestamp}.{raw request body}").

The signed string is the timestamp, a full stop, then the request body exactly as sent:

1793296051123.{"disbursements":[{"amount":10000,"currency":"NGN", ...}]}

Node.js

const crypto = require("crypto");

async function centiivRequest(path, payload) {
  const body = JSON.stringify(payload);
  const timestamp = Date.now().toString();
  const signature = crypto
    .createHmac("sha256", process.env.CENTIIV_SECRET_KEY)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const res = await fetch(`https://api.centiiv.com/api/v1${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Api-Public-Key": process.env.CENTIIV_PUBLIC_KEY,
      "X-Api-Timestamp": timestamp,
      "X-Api-Signature": signature,
    },
    body, // send exactly the string you signed
  });
  return res.json();
}

await centiivRequest("/banking/disburse", {
  disbursements: [
    {
      trackingId: crypto.randomUUID(),
      amount: 10000, // ₦100.00, in kobo
      currency: "NGN",
      recipientAccountNumber: "0123456789",
      recipientBankCode: "000013",
      description: "Supplier payment",
    },
  ],
});

Python

import hashlib, hmac, json, os, time, uuid
import requests

def centiiv_request(path, payload):
    body = json.dumps(payload, separators=(",", ":"))
    timestamp = str(int(time.time() * 1000))
    signature = hmac.new(
        os.environ["CENTIIV_SECRET_KEY"].encode(),
        f"{timestamp}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    return requests.post(
        f"https://api.centiiv.com/api/v1{path}",
        data=body,  # not json=..., which would re-serialise the body
        headers={
            "Content-Type": "application/json",
            "X-Api-Public-Key": os.environ["CENTIIV_PUBLIC_KEY"],
            "X-Api-Timestamp": timestamp,
            "X-Api-Signature": signature,
        },
    ).json()

Rules the server checks

  • The body must match byte for byte. Serialise the JSON once, sign that string, and send that same string. Letting your HTTP client re-encode the body (for example json= in Python's requests) changes the bytes and the signature no longer matches.
  • The timestamp must be within 30 seconds of our clock. Older requests are rejected so a captured request can't be replayed. Keep your servers' clocks synced (NTP), and sign each request just before sending it, including retries.
  • Milliseconds, not seconds. A seconds value looks 50 years old and is rejected.
  • Don't mix methods. If any of the three headers is present, the request must be correctly signed. A bad signature is rejected even when a valid bearer key is also sent.

Errors

StatusCodeMeaning
400INVALID_API_KEYUnknown public key, or the signature doesn't match. Check the secret key and that you signed the exact body you sent.
401UNAUTHORIZED_OPERATIONA signing header is missing, or the timestamp is missing, not a number, or more than 30 seconds off. Also returned when you try to replace existing keys without signing.

Retries and duplicate payouts

Give every payout a trackingId (a UUID you generate). If a request times out, retry with the same trackingId and a fresh timestamp and signature. If the first attempt already went through, the retry is rejected as a duplicate instead of paying twice.

On wallet/transfer/single and /bulk, a payout that failed can be retried with the same trackingId. On banking/disburse, a trackingId can only be used once, so use a new one after a failure.

A batch can contain up to 100 items.

Need help?

Email [email protected]. We're happy to check a signature with you in test mode.