Hosted QR checkout
Authentication and signing
Three layers protect your account: your key, your IP allow-list, and (if you ask for it) a signature on every request.
Your payment key
Send it on every call in the X-Api-Key header.
- Keep it on your server. Never put it in a web page, a mobile app or a public repository: anyone who can read it can create payments in your name.
- If it may have leaked, tell support at once and ask for a new one.
X-Api-Key: FDX_pk_live_EXAMPLE0000000000000000
The value above is a placeholder, not a key.
IP allow-list
Every call is checked against the list of server addresses on your account, after your key (and your signature, if signing is on) has been accepted. A call from any other address is refused.
Call over IPv4. The allow-list holds IPv4 addresses, and api.antpay.network is reachable over IPv4 only. If your server prefers IPv6, make sure it uses IPv4 for these calls, and give us its public IPv4 address.
| You get | Meaning | Fix |
|---|---|---|
403 IP_ALLOWLIST_NOT_CONFIGURED | No address is on your list yet. | Send your server's public IP address(es) to your account manager. |
403 IP_NOT_ALLOWED | The call came from an address that is not on your list. | Check which address your server really calls from (a NAT gateway, a proxy or a new host changes it), then ask us to add it. |
To see the address we would see, run this on the server that makes the calls: curl -s https://api.ipify.org (any "what is my IP" service works).
The order of checks
Each call passes these in turn, and stops at the first that fails. Knowing the order tells you what an error means.
| # | Check | If it fails |
|---|---|---|
| 1 | Rate limit | 429 |
| 2 | Your payment key, and that your account is active | 401, or 403 MERCHANT_INACTIVE |
| 3 | Your request signature, if signing is on for your account | 401 SIGNATURE_… |
| 4 | Your IP allow-list | 403 IP_… |
| 5 | The request itself (fields, amount, duplicate reference) | 400 / 422 |
So a call with a wrong key from an address that is not on your list gets 401, not 403: fix the key first, and the address error appears next.
Request signing (optional)
An extra layer you can ask for. When it is on for your account, every request carries a signature that we verify before anything else runs; an unsigned request is refused with 401. It is off unless you ask support to switch it on. If it is off, nothing on this page below this line applies to you and you send no extra headers.
X-Timestamp and X-Signature, covering time, method, path and body). The postback signature is us signing the result to you (header X-Mint-Signature, covering the body only). They use different secrets. See Postbacks for the second.
What you send
| Header | Value |
|---|---|
X-Timestamp | Unix time in seconds (not milliseconds). |
X-Signature | Hex HMAC-SHA256 of the canonical string below, keyed with your signing secret. |
The canonical string
Four parts joined by a full stop, in this order, nothing else:
{timestamp}.{METHOD}.{path}.{body}
| Part | Rule |
|---|---|
timestamp | The exact value you put in X-Timestamp. |
METHOD | The HTTP method in upper case: POST, GET. |
path | The request path from / onward, including the query string if there is one. No scheme, no host. |
body | The body exactly as you send it, byte for byte. Empty for a GET, so the string then ends with a full stop. |
- Sign the bytes you send. Serialise the JSON once, keep that string, sign it, send it. If your HTTP library encodes the object again, spacing or key order can change and the signature will not match.
- Use the secret as the 64-character text it is. Do not decode it from hex to 32 bytes first.
- Your signing secret is issued by support when signing is switched on, and shown once.
cURL
# Request signing (only if it is switched on for your account). Prints the two headers for one request.
# canonical = "{timestamp}.{METHOD}.{path with query}.{body exactly as sent}"
antpay_sign() { # antpay_sign METHOD PATH BODY [TIMESTAMP]
local method path="$2" body="${3:-}" ts="${4:-$(date +%s)}"
method=$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]')
printf 'X-Timestamp: %s\nX-Signature: %s\n' "$ts" "$(printf '%s' "${ts}.${method}.${path}.${body}" | antpay_hmac_hex "$SIGNING_SECRET")"
}Part of antpay.sh, the complete tested file
Node.js
// Request signing (only if it is switched on for your account).
// canonical = "{timestamp}.{METHOD}.{path with query}.{body exactly as sent}"
function signRequest(method, path, body = '', timestamp = Math.floor(Date.now() / 1000).toString()) {
const canonical = `${timestamp}.${method.toUpperCase()}.${path}.${body}`;
const signature = crypto.createHmac('sha256', SIGNING_SECRET) // the secret is used as the 64-character STRING
.update(canonical, 'utf8')
.digest('hex');
return { 'X-Timestamp': timestamp, 'X-Signature': signature };
}Part of antpay.js, the complete tested file
PHP
// Request signing (only if it is switched on for your account).
// canonical = "{timestamp}.{METHOD}.{path with query}.{body exactly as sent}"
function antpay_sign_request(string $method, string $path, string $body = '', ?string $timestamp = null): array {
$timestamp = $timestamp ?? (string) time(); // SECONDS
$canonical = $timestamp . '.' . strtoupper($method) . '.' . $path . '.' . $body;
return [
'X-Timestamp' => $timestamp,
'X-Signature' => hash_hmac('sha256', $canonical, ANTPAY_SIGNING_SECRET), // secret used as the 64-character STRING
];
}Part of antpay.php, the complete tested file
Python
# Request signing (only if it is switched on for your account).
# canonical = "{timestamp}.{METHOD}.{path with query}.{body exactly as sent}"
def sign_request(method, path, body="", timestamp=None):
timestamp = timestamp or str(int(time.time())) # SECONDS
canonical = f"{timestamp}.{method.upper()}.{path}.{body}"
signature = hmac.new(SIGNING_SECRET.encode(), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
return {"X-Timestamp": timestamp, "X-Signature": signature} # the secret is used as the 64-character STRINGPart of antpay.py, the complete tested file
Check your code against fixed values
Test values only; nothing here is a live secret. Run your own code on them before you call the API. These signatures were produced by the same code the API uses to verify yours.
Signing secret:
4f8b2c1d9e7a6350f1c48b2d0a9e7361c5d84f2b1a0e9d7c6b3a5f8e2d1c0b9a
A create call. X-Timestamp: 1756800000, POST /api/v1/checkouts, body:
{"reference":"ORD-10021","amount":1500,"paymentMethod":"qr","paymentType":"onetime","returnUrl":"https://shop.example.com/orders/ORD-10021"}
Canonical string (174 bytes, no line break at the end):
1756800000.POST./api/v1/checkouts.{"reference":"ORD-10021","amount":1500,"paymentMethod":"qr","paymentType":"onetime","returnUrl":"https://shop.example.com/orders/ORD-10021"}
Expected X-Signature:
6e004cd488661af7b547559a5afea9db3624ee8c308b4b137e6dae32ddac26bb
If you got this instead, you decoded the secret from hex. Use the text as it is:
822593c46f7f0a2d19f67ee09a5bc39c88096edb50f54453896507fb514bb7f0
A status call (no body, so the canonical string ends with a full stop). X-Timestamp: 1756800000, GET /api/v1/transactions/ORD-10021:
1756800000.GET./api/v1/transactions/ORD-10021.
Expected X-Signature:
7644f4a94f034bcf45f74cb3970c803b19b587098b4e2c972486e0c076e7b720
Time window
Your timestamp must be within 300 seconds of our server time, in either direction. Keep your server clock synchronised (NTP), and make a fresh timestamp and signature for every request, retries included.
Signing errors
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
401 | SIGNATURE_REQUIRED | Signing is on for your account and the request had no signature. | Add the two headers. |
401 | SIGNATURE_INVALID | The signature did not verify, or the timestamp is outside the window. | Check against the fixed values above, then check your clock. |
401 | SIGNATURE_CONFIG_ERROR, SIGNATURE_ALGO_UNSUPPORTED | A fault in your account's signing setup on our side. | Contact support with the time of a failed request. Nothing you change will fix it. |
503 | SIGNATURE_UNAVAILABLE | We could not perform the check. Your signature was not judged. | Retry with a fresh timestamp and signature, with backoff. |
A request refused at this layer never reaches the payment logic: nothing is created. It does count against your rate limit, so do not retry in a loop while debugging; a 429 is not a signing error.
Common mistakes
- Decoding the secret from hex instead of using the 64-character text.
- Milliseconds in
X-Timestamp. - Signing a re-encoded body instead of the bytes you send.
- Leaving the query string out of
path, or including the scheme and host. - A lower-case method in the canonical string.
- Forgetting the full stop at the end for a request with no body.
- A server clock that drifts.
Upper-case or lower-case hex in X-Signature both verify.
Changing your signing secret
Ask support. The change is made by support at a time agreed with you. From that moment only the new secret verifies; there is no period in which both work, so switch your server at the agreed time. Signed requests sent with the old secret after the change get 401 SIGNATURE_INVALID and are safe to send again with the new one.