DevelopersAPI referenceSupport

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.

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 getMeaningFix
403 IP_ALLOWLIST_NOT_CONFIGUREDNo address is on your list yet.Send your server's public IP address(es) to your account manager.
403 IP_NOT_ALLOWEDThe 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.

#CheckIf it fails
1Rate limit429
2Your payment key, and that your account is active401, or 403 MERCHANT_INACTIVE
3Your request signature, if signing is on for your account401 SIGNATURE_…
4Your IP allow-list403 IP_…
5The 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.

This is not the postback signature. Request signing is you signing calls to us (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

HeaderValue
X-TimestampUnix time in seconds (not milliseconds).
X-SignatureHex 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}
PartRule
timestampThe exact value you put in X-Timestamp.
METHODThe HTTP method in upper case: POST, GET.
pathThe request path from / onward, including the query string if there is one. No scheme, no host.
bodyThe body exactly as you send it, byte for byte. Empty for a GET, so the string then ends with a full stop.

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 STRING

Part 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

HTTPCodeMeaningWhat to do
401SIGNATURE_REQUIREDSigning is on for your account and the request had no signature.Add the two headers.
401SIGNATURE_INVALIDThe signature did not verify, or the timestamp is outside the window.Check against the fixed values above, then check your clock.
401SIGNATURE_CONFIG_ERROR, SIGNATURE_ALGO_UNSUPPORTEDA 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.
503SIGNATURE_UNAVAILABLEWe 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

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.