AfroPin Developers
Postman Sign in Get test keys

Start here

Signing requests

Every request is signed with your API secret using HMAC-SHA256, and carries a timestamp, a one-time nonce and an Idempotency-Key. Together they prove the request is yours, unchanged and not a replay.

API keys

Create keys under API keys. Each key has two parts:

Key ID
A public identifier starting with apk_. You send it in the X-Key-Id header.
Secret
A 64-character value used to compute the signature. It is shown once, when the key is created, and is never sent over the wire.
  • A key works in the mode the dashboard was in when you created it: test or live. Environments
  • You can restrict a key to IPv4 and IPv6 addresses or CIDR ranges. IP whitelisting
  • Revoke a key from the dashboard to stop it working immediately. Create the new key first so you can rotate without downtime.
Keep the secret on your server, for example in an environment variable. Never put it in a mobile app, browser code or a Git repository. If it leaks, revoke the key and create a new one.

Request headers

X-Key-Id
Your key ID.
X-Timestamp
Current Unix time in whole seconds. It must be within 5 minutes of server time, in either direction.
X-Nonce
A random value you generate for each request, for example 16 random bytes as hex. A nonce can be used once per key within 10 minutes.
X-Signature
The HMAC-SHA256 of the canonical string, keyed with your secret, as lowercase hex.
Idempotency-Key
A unique value per request, at most 50 characters. A UUID works well. Idempotency keys
Content-Type
application/json for requests with a body.

Signing a request

Using one of our SDKs? It signs every request and adds these headers for you. Official SDKs

Join these five values with a dot, in this order, to build the canonical string:

METHOD.PATH.TIMESTAMP.NONCE.BODY_HASH
METHOD
The HTTP method in uppercase: GET, POST or DELETE.
PATH
The path of the URL you call, without the leading slash, the host or the query string. For https://afropin.com/api/v1/reseller/wallets it is api/v1/reseller/wallets.
TIMESTAMP
The same value you send in X-Timestamp.
NONCE
The same value you send in X-Nonce.
BODY_HASH
SHA-256 of the exact body bytes you send, as lowercase hex. With no body (GET and DELETE), hash the empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Then compute HMAC-SHA256(secret, canonical) and send it, as lowercase hex, in X-Signature.

Worked example

With the secret sk_test_example_secret_do_not_use, timestamp 1767225600 and nonce 9f1c2e7a4b224c10, a GET to the balance endpoint signs this string:

GET.api/v1/reseller/wallets.1767225600.9f1c2e7a4b224c10.e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

and sends this signature:

9242039a994a939e81660a48e5cc839c9b60156ee27eb7b728a36be11f9663ff

Code samples

Each sample sends a signed request. Swap in your key ID and secret; the samples derive the path from the URL, so they also work if the API is served under a sub-folder.

PHP

<?php
$keyId  = getenv('API_KEY_ID');
$secret = getenv('API_SECRET');

function signedRequest(string $method, string $url, ?array $payload = null): array
{
    global $keyId, $secret;

    $body      = $payload === null ? '' : json_encode($payload);
    $timestamp = (string) time();
    $nonce     = bin2hex(random_bytes(16));
    $path      = ltrim(parse_url($url, PHP_URL_PATH), '/');

    $canonical = implode('.', [strtoupper($method), $path, $timestamp, $nonce, hash('sha256', $body)]);
    $signature = hash_hmac('sha256', $canonical, $secret);

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => strtoupper($method),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POSTFIELDS     => $body === '' ? null : $body,
        CURLOPT_HTTPHEADER     => [
            'Content-Type: application/json',
            'X-Key-Id: ' . $keyId,
            'X-Timestamp: ' . $timestamp,
            'X-Nonce: ' . $nonce,
            'X-Signature: ' . $signature,
            'Idempotency-Key: ' . bin2hex(random_bytes(16)),
        ],
    ]);
    $response = curl_exec($ch);
    curl_close($ch);

    return json_decode($response, true);
}

$balance = signedRequest('GET', 'https://afropin.com/api/v1/reseller/wallets');

Node.js

const crypto = require('crypto');

const keyId = process.env.API_KEY_ID;
const secret = process.env.API_SECRET;

async function signedRequest(method, url, payload) {
  // Sign and send the same string: never re-serialize the body after hashing it
  const body = payload === undefined ? '' : JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomBytes(16).toString('hex');
  const path = new URL(url).pathname.replace(/^\//, '');

  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const canonical = [method.toUpperCase(), path, timestamp, nonce, bodyHash].join('.');
  const signature = crypto.createHmac('sha256', secret).update(canonical).digest('hex');

  const res = await fetch(url, {
    method,
    body: body || undefined,
    headers: {
      'Content-Type': 'application/json',
      'X-Key-Id': keyId,
      'X-Timestamp': timestamp,
      'X-Nonce': nonce,
      'X-Signature': signature,
      'Idempotency-Key': crypto.randomUUID(),
    },
  });
  return res.json();
}

signedRequest('GET', 'https://afropin.com/api/v1/reseller/wallets').then(console.log);

Python

import hashlib, hmac, json, os, secrets, time, uuid
from urllib.parse import urlparse
import requests

KEY_ID = os.environ["API_KEY_ID"]
SECRET = os.environ["API_SECRET"]

def signed_request(method, url, payload=None):
    # Send the exact bytes you hashed: pass data=body, not json=payload
    body = "" if payload is None else json.dumps(payload)
    timestamp = str(int(time.time()))
    nonce = secrets.token_hex(16)
    path = urlparse(url).path.lstrip("/")

    body_hash = hashlib.sha256(body.encode()).hexdigest()
    canonical = ".".join([method.upper(), path, timestamp, nonce, body_hash])
    signature = hmac.new(SECRET.encode(), canonical.encode(), hashlib.sha256).hexdigest()

    return requests.request(method, url, data=body or None, headers={
        "Content-Type": "application/json",
        "X-Key-Id": KEY_ID,
        "X-Timestamp": timestamp,
        "X-Nonce": nonce,
        "X-Signature": signature,
        "Idempotency-Key": str(uuid.uuid4()),
    }).json()

print(signed_request("GET", "https://afropin.com/api/v1/reseller/wallets"))

cURL

KEY_ID="apk_..."
SECRET="..."
URL="https://afropin.com/api/v1/reseller/wallets"
SIGN_PATH="api/v1/reseller/wallets"

TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '' | openssl dgst -sha256 | awk '{print $NF}')
SIGNATURE=$(printf '%s' "GET.$SIGN_PATH.$TIMESTAMP.$NONCE.$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')

curl -X GET "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: $KEY_ID" \
  -H "X-Timestamp: $TIMESTAMP" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE" \
  -H "Idempotency-Key: $(uuidgen)"

Idempotency keys

Every request needs an Idempotency-Key of at most 50 characters that your account has never sent before. The key is recorded as soon as the request reaches the API, whether it succeeds or fails, so each key works exactly once.

Sending a key again does not return the earlier response: it fails with idempotency_key_reused (403). Use a new key on every attempt, including retries.

To retry a write safely, keep the same external_reference and send a new Idempotency-Key. If the first attempt went through, the retry fails with duplicate_external_reference instead of doing the operation twice. Then look the result up, for example with Look up a PIN.

IP whitelisting

When you create a key you can list the IPv4 and IPv6 addresses allowed to use it. Entries can be single addresses or CIDR ranges, such as 203.0.113.0/24 or 2001:db8::/32. A request passes if its address matches any entry in either list. Requests from other addresses fail with ip_not_allowed (403). A key with both lists empty accepts any address.

Limits

  • Up to 60 requests per minute. Above that the API answers with HTTP 429; wait and retry with backoff.
  • Write requests are processed one at a time per account. A write sent while another is still running fails with rate_limited (429). Retry it after a moment with a new Idempotency-Key and nonce.

Troubleshooting

Checks run in this order and the first failure is returned. All failures use the standard error format.

Code HTTP Cause Fix
missing_auth_headers 401 One of X-Key-Id, X-Timestamp, X-Nonce or X-Signature is missing. Send all four headers on every request.
timestamp_out_of_window 401 X-Timestamp is not whole seconds, or is more than 5 minutes from server time. Use the current Unix time in seconds and keep your server clock in sync (NTP).
nonce_replayed 401 This nonce was already used with this key in the last 10 minutes. Generate a new random nonce for every request, including retries.
invalid_api_key 401 The key ID is unknown, revoked or past its expiry date, or it is a live key and live access is not yet enabled. Check the key ID and its mode. For live keys, check that KYC is approved and live access is granted.
invalid_signature 401 The signature does not match the request. See Fixing invalid_signature below.
ip_not_allowed 403 The key has an IP whitelist and the request came from another address. Call from a whitelisted address, or update the key's whitelist.
idempotency_key_required 403 The Idempotency-Key header is missing. Send a new unique value on every request.
idempotency_key_too_long 403 The Idempotency-Key is longer than 50 characters. Use a UUID (36 characters).
idempotency_key_reused 403 This Idempotency-Key was already used by your account. Use a new key for every request, including retries.
wrong_account_type 403 The endpoint belongs to the other account type, for example a brand key calling a reseller endpoint. Call the endpoints for your account type.

Fixing invalid_signature

  • Sign the path only: api/v1/reseller/wallets, not /api/v1/reseller/wallets or the full URL. Leave out the query string.
  • If the API is served under a sub-folder, that folder is part of the path. Derive the path from the URL you actually call, as the samples do.
  • Hash the exact body bytes you send. Letting your HTTP client re-encode the JSON changes spacing or key order and breaks the signature.
  • For requests without a body, hash the empty string, not {} or null.
  • Use the same timestamp and nonce in the headers and in the canonical string.
  • Use the secret, not the key ID, as the HMAC key, and send the signature as lowercase hex, not Base64.
  • Make sure the method is uppercase and matches the request.
The nonce is recorded before the signature is checked, so a request that fails with invalid_signature still uses up its nonce. Always generate a new nonce when you retry.