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
- 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.
- Create a checkout from your server:
POST /api/v1/checkouts. You get back acheckoutUrl. - 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. - Receive the postback. AntPay sends one signed request to your server with
completedorfailed. - Confirm with a status call if you need to:
GET /api/v1/transactions/{reference}.
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 need | What it is | Where it comes from |
|---|---|---|
| Payment key | Sent as the X-Api-Key header on every call. Server-side only. | Issued by your AntPay account manager |
| IP allow-list | The public IP address(es) your server calls from. Calls from any other address are refused. | You tell us the addresses; we add them |
| Postback URL | The https address on your server that receives the payment result. | You tell us the URL; we set it on your account |
| Postback secret | 64 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.
| Language | File | Needs |
|---|---|---|
| cURL | antpay.sh | bash, curl, sha256sum |
| Node.js | antpay.js | Node.js 18 or newer |
| PHP | antpay.php | PHP 7.4 or newer with curl |
| Python | antpay.py | Python 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=80Part 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 = 80Part 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 STRINGPart 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, NonePart 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.


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
- Your key is only on your server, never in a browser or a mobile app.
- Every order uses a new
referenceand a new checkout, and you store thecheckoutIdyou were given. - You accept a result only for the
checkoutIdyou stored for that order, never byreferencealone. - Your create call waits at least 80 seconds, and after a timeout you ask by reference before creating again.
- You mark an order paid only on
status: "completed", from a postback whose signature you verified. - Your postback handler gives the same result when the same postback arrives twice.
- Your
returnUrlpage reads the order status from your own system; AntPay adds no parameters to it. - You tested one real payment end to end.
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.