DevelopersAPI referenceSupport

Hosted QR checkout

Quickstart

Take a QR payment without building a payment screen. Your server makes one call, you send the customer to the link it returns, and AntPay tells your server the result.

How it works

  1. Get your credentials from your AntPay account manager: a payment key, a postback secret, your postback URL on file and your server's IP address on the allow-list.
  2. Create a checkout from your server: POST /api/v1/checkouts. You get back a checkoutUrl.
  3. Send the customer to checkoutUrl (https://pay.antpay.network/qr/…). The AntPay page collects their wallet details, shows the QR with a 10-minute timer and then the result.
  4. Receive the postback. AntPay sends one signed request to your server with completed or failed.
  5. Confirm with a status call if you need to: GET /api/v1/transactions/{reference}.
The one rule: act on status, never on success. success: true only means the API call itself worked. Whether money moved is in status: pending, completed or failed.

Before you start

You needWhat it isWhere it comes from
Payment keySent as the X-Api-Key header on every call. Server-side only.Issued by your AntPay account manager
IP allow-listThe public IP address(es) your server calls from. Calls from any other address are refused.You tell us the addresses; we add them
Postback URLThe https address on your server that receives the payment result.You tell us the URL; we set it on your account
Postback secret64 characters. Used to check that a postback really came from AntPay.Issued by your AntPay account manager, once

Base URL for every call: https://api.antpay.network. Requests and responses are JSON. Amounts are in PKR.

The sample files

Every snippet in these pages is a part of one file per language. The files are complete and tested: download the one for your language, set the environment variables named at its top, and call its functions.

LanguageFileNeeds
cURLantpay.shbash, curl, sha256sum
Node.jsantpay.jsNode.js 18 or newer
PHPantpay.phpPHP 7.4 or newer with curl
Pythonantpay.pyPython 3.8 or newer

This is the top of each file, and the two helpers every other snippet uses (call sends a request with your key, and signs it if signing is on for your account):

cURL

set -euo pipefail

API_BASE="${ANTPAY_API_BASE:-https://api.antpay.network}"
: "${ANTPAY_API_KEY:?set ANTPAY_API_KEY}"                 # your payment key
SIGNING_SECRET="${ANTPAY_SIGNING_SECRET:-}"               # only if request signing is on for your account
POSTBACK_SECRET="${ANTPAY_POSTBACK_SECRET:-}"             # issued with your account, 64 characters

# HMAC-SHA256 in hex, key up to 64 characters, message on standard input. Pure bash + sha256sum: the key is handled
# by shell built-ins only, so it is never an argument of another program.
antpay_hmac_hex() {   # antpay_hmac_hex KEY < message
  local key="$1" ipad="" opad="" i c inner
  for ((i = 0; i < 64; i++)); do
    if ((i < ${#key})); then printf -v c '%d' "'${key:i:1}"; else c=0; fi
    printf -v ipad '%s\\x%02x' "$ipad" $((c ^ 0x36))
    printf -v opad '%s\\x%02x' "$opad" $((c ^ 0x5c))
  done
  inner=$({ printf "$ipad"; cat; } | sha256sum | cut -d' ' -f1)
  { printf "$opad"; printf "$(printf '%s' "$inner" | sed 's/../\\x&/g')"; } | sha256sum | cut -d' ' -f1
}

# Percent-encode a value for use inside a URL path.
antpay_urlencode() {
  local s="$1" out="" i c
  for ((i = 0; i < ${#s}; i++)); do
    c="${s:i:1}"
    case "$c" in [A-Za-z0-9._~-]) out+="$c" ;; *) printf -v c '%%%02X' "'$c"; out+="$c" ;; esac
  done
  printf '%s' "$out"
}

# Wait at least 80 seconds for an answer. The API can take up to about 75 s on a create; giving up earlier and
# sending the create again while the first is still running is how one order ends up with two checkouts.
ANTPAY_TIMEOUT_S=80

Part of antpay.sh, the complete tested file

Node.js

const crypto = require('crypto');
const http = require('http');

const API_BASE = process.env.ANTPAY_API_BASE || 'https://api.antpay.network';
const API_KEY = process.env.ANTPAY_API_KEY;                      // your payment key
const POSTBACK_SECRET = process.env.ANTPAY_POSTBACK_SECRET;      // issued with your account, 64 characters
const SIGNING_SECRET = process.env.ANTPAY_SIGNING_SECRET || '';  // only if request signing is on for your account

// A reference you can always read back: letters, digits, dot, underscore, dash; 1 to 120 characters; no "..".
const REFERENCE = /^(?!.*\.\.)[A-Za-z0-9._-]{1,120}$/;

// Wait at least 80 seconds for an answer. The API can take up to about 75 s on a create; giving up earlier and
// sending the create again while the first is still running is how one order ends up with two checkouts.
const TIMEOUT_MS = 80_000;

Part of antpay.js, the complete tested file

PHP

function antpay_env(string $name, string $default = ''): string {
    $v = getenv($name);
    return $v === false ? $default : $v;
}
define('ANTPAY_API_BASE', antpay_env('ANTPAY_API_BASE', 'https://api.antpay.network'));
define('ANTPAY_API_KEY', antpay_env('ANTPAY_API_KEY'));                   // your payment key
define('ANTPAY_POSTBACK_SECRET', antpay_env('ANTPAY_POSTBACK_SECRET'));   // issued with your account, 64 characters
define('ANTPAY_SIGNING_SECRET', antpay_env('ANTPAY_SIGNING_SECRET'));     // only if request signing is on for your account

// A reference you can always read back: letters, digits, dot, underscore, dash; 1 to 120 characters; no "..".
const ANTPAY_REFERENCE = '/^(?!.*[.][.])[A-Za-z0-9._-]{1,120}$/';

// Wait at least 80 seconds for an answer. The API can take up to about 75 s on a create; giving up earlier and
// sending the create again while the first is still running is how one order ends up with two checkouts.
const ANTPAY_TIMEOUT_S = 80;

Part of antpay.php, the complete tested file

Python

import hashlib
import hmac
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import re
from http.server import BaseHTTPRequestHandler

API_BASE = os.environ.get("ANTPAY_API_BASE", "https://api.antpay.network")
API_KEY = os.environ.get("ANTPAY_API_KEY", "")                    # your payment key
POSTBACK_SECRET = os.environ.get("ANTPAY_POSTBACK_SECRET", "")    # issued with your account, 64 characters
SIGNING_SECRET = os.environ.get("ANTPAY_SIGNING_SECRET", "")      # only if request signing is on for your account

# A reference you can always read back: letters, digits, dot, underscore, dash; 1 to 120 characters; no "..".
REFERENCE = re.compile(r"^(?!.*[.][.])[A-Za-z0-9._-]{1,120}$")

# Wait at least 80 seconds for an answer. The API can take up to about 75 s on a create; giving up earlier and
# sending the create again while the first is still running is how one order ends up with two checkouts.
TIMEOUT_S = 80

Part of antpay.py, the complete tested file

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

cURL

antpay_call() {   # antpay_call METHOD PATH [BODY]      prints the answer, then the HTTP status on its own line
  local method="$1" path="$2" body="${3:-}" h
  local config="url = \"${API_BASE}${path}\""$'\n'"request = \"${method}\""$'\n'"max-time = ${ANTPAY_TIMEOUT_S}"$'\n'"header = \"X-Api-Key: ${ANTPAY_API_KEY}\""
  if [ -n "$body" ]; then config+=$'\n''header = "Content-Type: application/json"'; fi
  if [ -n "$SIGNING_SECRET" ]; then
    while IFS= read -r h; do config+=$'\n'"header = \"$h\""; done < <(antpay_sign "$method" "$path" "$body")
  fi
  # the key and the signature go to curl through a config on standard input, the body through a second descriptor
  if [ -n "$body" ]; then curl -sS -K - --data-binary @/dev/fd/3 -w '\n%{http_code}\n' 3< <(printf '%s' "$body") <<< "$config"
  else curl -sS -K - -w '\n%{http_code}\n' <<< "$config"; fi
}

Part of antpay.sh, the complete tested file

Node.js

async function call(method, path, payload) {
  const body = payload === undefined ? '' : JSON.stringify(payload);   // serialise ONCE; sign and send this string
  const headers = { 'X-Api-Key': API_KEY };
  if (body) headers['Content-Type'] = 'application/json';
  if (SIGNING_SECRET) Object.assign(headers, signRequest(method, path, body));
  const res = await fetch(API_BASE + path, { method, headers, body: body || undefined, signal: AbortSignal.timeout(TIMEOUT_MS) });
  const json = await res.json().catch(() => null);
  return { http: res.status, json, retryAfter: res.headers.get('retry-after') };
}

Part of antpay.js, the complete tested file

PHP

function antpay_call(string $method, string $path, ?array $payload = null): array {
    $body = $payload === null ? '' : json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);   // serialise ONCE
    $headers = ['X-Api-Key' => ANTPAY_API_KEY];
    if ($body !== '') $headers['Content-Type'] = 'application/json';
    if (ANTPAY_SIGNING_SECRET !== '') $headers += antpay_sign_request($method, $path, $body);
    $lines = [];
    foreach ($headers as $k => $v) $lines[] = $k . ': ' . $v;
    $ch = curl_init(ANTPAY_API_BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $lines,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => ANTPAY_TIMEOUT_S,
    ]);
    if ($body !== '') curl_setopt($ch, CURLOPT_POSTFIELDS, $body);       // send $body verbatim: do not re-encode
    $text = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return [$status, is_string($text) ? json_decode($text, true) : null];
}

Part of antpay.php, the complete tested file

Python

def call(method, path, payload=None):
    body = "" if payload is None else json.dumps(payload, separators=(",", ":"))   # serialise ONCE
    headers = {"X-Api-Key": API_KEY}
    if body:
        headers["Content-Type"] = "application/json"
    if SIGNING_SECRET:
        headers.update(sign_request(method, path, body))
    req = urllib.request.Request(API_BASE + path, data=body.encode("utf-8") if body else None,
                                 headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT_S) as res:
            return res.status, json.loads(res.read().decode("utf-8"))
    except urllib.error.HTTPError as e:                           # 4xx / 5xx still carry a JSON body
        text = e.read().decode("utf-8")
        try:
            return e.code, json.loads(text)
        except ValueError:
            return e.code, None

Part of antpay.py, the complete tested file

Step 1 · Create a checkout

One call from your server per order. reference is your own order id and must be unique; amount is whole rupees, minimum 300.

cURL

# Step 1: create the checkout. Put the request in a file, so the JSON is exactly what you wrote, for example
#   {"reference":"ORD-10021","amount":1500,"paymentMethod":"qr","paymentType":"onetime","returnUrl":"https://shop.example.com/orders/ORD-10021"}
# The answer carries checkoutUrl: send the customer there.
antpay_create_checkout() {   # antpay_create_checkout FILE_WITH_JSON_BODY
  antpay_call POST /api/v1/checkouts "$(cat "$1")"
}

Part of antpay.sh, the complete tested file

Node.js

// Step 1: create the checkout on your server, store the checkoutId, redirect the customer.
async function createCheckout(order) {
  if (!REFERENCE.test(order.id)) throw new Error('reference: use letters, digits, ".", "_" and "-" only (1 to 120 characters)');
  const { http: status, json } = await call('POST', '/api/v1/checkouts', {
    reference: order.id,                 // your order id, unique per API key
    amount: order.total,                 // whole rupees, minimum 300
    paymentMethod: 'qr',
    paymentType: 'onetime',
    returnUrl: `https://shop.example.com/orders/${encodeURIComponent(order.id)}`,
  });
  if (status === 200 && json && json.success) {
    return { checkoutId: json.checkoutId, checkoutUrl: json.checkoutUrl, expiresAt: json.expiresAt };
  }
  // 4xx: nothing was created (a duplicate reference already exists: look it up).
  // 5xx or no answer: the checkout may exist. Ask by reference before you retry.
  const error = new Error(`checkout not created (HTTP ${status})`);
  error.status = status;
  error.body = json;
  throw error;
}

Part of antpay.js, the complete tested file

PHP

// Step 1: create the checkout on your server, store the checkoutId, redirect the customer.
function antpay_create_checkout(string $orderId, int $total): array {
    if (!preg_match(ANTPAY_REFERENCE, $orderId)) throw new InvalidArgumentException('reference: use letters, digits, ".", "_" and "-" only (1 to 120 characters)');
    [$status, $data] = antpay_call('POST', '/api/v1/checkouts', [
        'reference' => $orderId,            // your order id, unique per API key
        'amount' => $total,                 // whole rupees, minimum 300
        'paymentMethod' => 'qr',
        'paymentType' => 'onetime',
        'returnUrl' => 'https://shop.example.com/orders/' . rawurlencode($orderId),
    ]);
    if ($status === 200 && is_array($data) && !empty($data['success'])) {
        return ['checkoutId' => $data['checkoutId'], 'checkoutUrl' => $data['checkoutUrl'], 'expiresAt' => $data['expiresAt']];
    }
    // 4xx: nothing was created (a duplicate reference already exists: look it up).
    // 5xx or no answer: the checkout may exist. Ask by reference before you retry.
    throw new RuntimeException("checkout not created (HTTP $status): " . json_encode($data));
}

Part of antpay.php, the complete tested file

Python

# Step 1: create the checkout on your server, store the checkoutId, redirect the customer.
def create_checkout(order_id, total):
    if not REFERENCE.match(order_id):
        raise ValueError('reference: use letters, digits, ".", "_" and "-" only (1 to 120 characters)')
    status, data = call("POST", "/api/v1/checkouts", {
        "reference": order_id,              # your order id, unique per API key
        "amount": total,                    # whole rupees, minimum 300
        "paymentMethod": "qr",
        "paymentType": "onetime",
        "returnUrl": "https://shop.example.com/orders/" + urllib.parse.quote(order_id, safe=""),
    })
    if status == 200 and data and data.get("success"):
        return {"checkoutId": data["checkoutId"], "checkoutUrl": data["checkoutUrl"], "expiresAt": data["expiresAt"]}
    # 4xx: nothing was created (a duplicate reference already exists: look it up).
    # 5xx or no answer: the checkout may exist. Ask by reference before you retry.
    raise RuntimeError(f"checkout not created (HTTP {status}): {data}")

Part of antpay.py, the complete tested file

The answer:

{
  "success": true,
  "checkoutUrl": "https://pay.antpay.network/qr/3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90",
  "checkoutId": "3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90",
  "expiresAt": "2026-11-01T10:15:00.000Z"
}

Store checkoutId with your order. It is how you recognise this payment later: the postback and the status calls carry it. It also rebuilds the link.

Give this call time: wait at least 80 seconds for the answer (the samples do). If it still fails or times out, do not simply send it again; see When the create call fails or times out.

Step 2 · Send the customer to the payment page

Redirect the customer to checkoutUrl exactly as returned (a full-page redirect or a link; never inside an iframe). They choose their wallet, enter its mobile number and account name, and the page shows the QR to pay from their wallet app.

AntPay payment page: the customer chooses a wallet and enters the account details
The customer enters their wallet details
AntPay payment page: the QR code with the exact amount and a ten minute timer
The QR, the exact amount and the timer

When the payment finishes while the customer is watching, the page sends them back to your returnUrl. The link also stays their status page: a customer who closed the tab can open it again at any time.

Step 3 · Receive the postback

AntPay sends one POST to your postback URL when the payment is final. Check the signature on the raw body, find your order by the checkoutId you stored, record the result once, and answer 200 within 10 seconds.

cURL

# Check a postback by hand: the header must equal "sha256=" + HMAC-SHA256(raw body, your postback secret).
antpay_postback_signature() {   # antpay_postback_signature FILE_WITH_RAW_BODY
  printf 'sha256=%s\n' "$(antpay_hmac_hex "$POSTBACK_SECRET" < "$1")"
}

Part of antpay.sh, the complete tested file

Node.js

// A postback is genuine when X-Mint-Signature equals "sha256=" + HMAC-SHA256(raw body, your postback secret).
// rawBody must be the bytes exactly as received (a Buffer), not a re-encoded object.
function verifyPostback(rawBody, signatureHeader, secret = POSTBACK_SECRET) {
  if (!secret || typeof signatureHeader !== 'string') return false;
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  const got = Buffer.from(signatureHeader);
  const want = Buffer.from(expected);
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}

Part of antpay.js, the complete tested file

PHP

// A postback is genuine when X-Mint-Signature equals "sha256=" + HMAC-SHA256(raw body, your postback secret).
// $rawBody must be the bytes exactly as received (file_get_contents('php://input')), not a re-encoded array.
function antpay_verify_postback(string $rawBody, ?string $signatureHeader, ?string $secret = null): bool {
    $secret = $secret ?? ANTPAY_POSTBACK_SECRET;
    if ($secret === '' || !is_string($signatureHeader)) return false;
    $expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signatureHeader);
}

Part of antpay.php, the complete tested file

Python

# A postback is genuine when X-Mint-Signature equals "sha256=" + HMAC-SHA256(raw body, your postback secret).
# raw_body must be the bytes exactly as received, not a re-encoded object.
def verify_postback(raw_body, signature_header, secret=None):
    secret = POSTBACK_SECRET if secret is None else secret
    if not secret or not isinstance(signature_header, str):
        return False
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Part of antpay.py, the complete tested file

The full receiver, retries and late payments are on Postbacks.

Step 4 · Confirm with a status call

The postback is the signal to act on. Use a status call when you did not get one (your server was down), or after a create call that timed out.

cURL

# Ask for the payment by YOUR reference, or by checkoutId. A 404 whose "error" is "Transaction not found" means there
# is no such payment. Any other 404 means the address itself was refused: check the reference, do not create again.
antpay_get_by_reference() { antpay_call GET "/api/v1/transactions/$(antpay_urlencode "$1")"; }
antpay_get_checkout()     { antpay_call GET "/api/v1/checkouts/$(antpay_urlencode "$1")"; }

Part of antpay.sh, the complete tested file

Node.js

// Ask for the payment by YOUR reference. Use it after an unclear create, and as a fallback to the postback.
// Returns null ONLY when the API itself says there is no such payment. Any other 404 means the address was refused
// (a wrong path, or a reference with characters that cannot travel in a URL): that is an error, not "no payment".
async function getByReference(reference) {
  const { http: status, json } = await call('GET', '/api/v1/transactions/' + encodeURIComponent(reference));
  if (status === 200 && json && json.success) return json.transaction;     // .status is pending | completed | failed
  if (status === 404 && json && json.error === 'Transaction not found') return null;
  throw new Error(`status call failed (HTTP ${status}${json && json.error ? ', ' + JSON.stringify(json.error) : ''})`);
}

async function getCheckout(checkoutId) {
  const { http: status, json } = await call('GET', '/api/v1/checkouts/' + encodeURIComponent(checkoutId));
  if (status === 200 && json && json.success) return json.checkout;
  if (status === 404 && json && json.error === 'Checkout session not found') return null;
  throw new Error(`status call failed (HTTP ${status}${json && json.error ? ', ' + JSON.stringify(json.error) : ''})`);
}

Part of antpay.js, the complete tested file

PHP

// Ask for the payment by YOUR reference. Use it after an unclear create, and as a fallback to the postback.
// Returns null ONLY when the API itself says there is no such payment. Any other 404 means the address was refused
// (a wrong path, or a reference with characters that cannot travel in a URL): that is an error, not "no payment".
function antpay_get_by_reference(string $reference): ?array {
    [$status, $data] = antpay_call('GET', '/api/v1/transactions/' . rawurlencode($reference));
    if ($status === 200 && is_array($data) && !empty($data['success'])) return $data['transaction'];   // ['status'] is pending | completed | failed
    if ($status === 404 && is_array($data) && ($data['error'] ?? null) === 'Transaction not found') return null;
    throw new RuntimeException("status call failed (HTTP $status, " . json_encode($data['error'] ?? null) . ')');
}

function antpay_get_checkout(string $checkoutId): ?array {
    [$status, $data] = antpay_call('GET', '/api/v1/checkouts/' . rawurlencode($checkoutId));
    if ($status === 200 && is_array($data) && !empty($data['success'])) return $data['checkout'];
    if ($status === 404 && is_array($data) && ($data['error'] ?? null) === 'Checkout session not found') return null;
    throw new RuntimeException("status call failed (HTTP $status, " . json_encode($data['error'] ?? null) . ')');
}

Part of antpay.php, the complete tested file

Python

# Ask for the payment by YOUR reference. Use it after an unclear create, and as a fallback to the postback.
# Returns None ONLY when the API itself says there is no such payment. Any other 404 means the address was refused
# (a wrong path, or a reference with characters that cannot travel in a URL): that is an error, not "no payment".
def get_by_reference(reference):
    status, data = call("GET", "/api/v1/transactions/" + urllib.parse.quote(reference, safe=""))
    if status == 200 and data and data.get("success"):
        return data["transaction"]          # ["status"] is pending | completed | failed
    if status == 404 and data and data.get("error") == "Transaction not found":
        return None
    raise RuntimeError(f"status call failed (HTTP {status}, {data.get('error') if data else None})")


def get_checkout(checkout_id):
    status, data = call("GET", "/api/v1/checkouts/" + urllib.parse.quote(checkout_id, safe=""))
    if status == 200 and data and data.get("success"):
        return data["checkout"]
    if status == 404 and data and data.get("error") == "Checkout session not found":
        return None
    raise RuntimeError(f"status call failed (HTTP {status}, {data.get('error') if data else None})")

Part of antpay.py, the complete tested file

Details and timing: Payment status.

Go-live checklist

Formats today. Payment keys currently begin FDX_pk_ and payment references begin FDX-. AntPay's own formats (ATP_pk_, ATP-) will follow. Treat both as opaque text: store them as they are and do not parse them.