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:
apk_. You send it in the X-Key-Id header.- 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.
Request headers
X-Key-IdX-TimestampX-NonceX-SignatureIdempotency-KeyContent-Typeapplication/json for requests with a body.Signing a request
Join these five values with a dot, in this order, to build the canonical string:
METHOD.PATH.TIMESTAMP.NONCE.BODY_HASH
METHODGET, POST or DELETE.PATHhttps://afropin.com/api/v1/reseller/wallets it is api/v1/reseller/wallets.TIMESTAMPX-Timestamp.NONCEX-Nonce.BODY_HASHe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.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.
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/walletsor 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
{}ornull. - 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.
invalid_signature still uses up its nonce. Always generate a new nonce when you retry.