Hosted QR checkout
Postbacks
AntPay tells your server the result of every payment with one signed request. This is the signal you act on.
What you receive
One POST with a JSON body, sent when the payment reaches its final state: completed or failed. You never receive one for pending.
{
"success": true,
"reference": "ORD-10021",
"transactionId": "FDX-1A2B3C4D",
"paymentMethod": "qr",
"mobile": "03001234567",
"amount": 1500,
"status": "completed",
"statusCode": "SUCCESS",
"pspStatusCode": "SUCCESS",
"pspOrderId": "FDX-1A2B3C4D",
"pspTxnId": "01JABCDEF0000000000000000",
"message": "Payment completed successfully",
"failureReason": null,
"createdAt": "2026-11-01T10:00:00.000Z",
"updatedAt": "2026-11-01T10:04:12.000Z",
"attemptCount": 1,
"attempts": [
{
"attemptNumber": 1,
"pspOrderId": "FDX-1A2B3C4D",
"pspTxnId": "01JABCDEF0000000000000000",
"pspStoreId": null,
"pspStatusCode": "pending",
"message": "QR created",
"outcome": "success",
"startedAt": "2026-11-01T10:01:02.000Z",
"completedAt": "2026-11-01T10:01:03.000Z",
"durationMs": 850
}
],
"requestedAmount": 1500,
"qr": {
"payload": "000201010212…6304ABCD",
"imageUrl": null,
"expiresAt": "2026-11-01T10:16:02.000Z",
"payableAmount": "1500.00"
},
"id": "3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90",
"vendorId": "MERCHANT_000123_PK1",
"checkoutId": "3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90",
"acknowledged": false,
"tokenId": "3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90",
"customerMobile": "03001234567",
"user": {
"customerId": "C-778"
}
}
| Field | Use it for |
|---|---|
checkoutId | Finding your order. It is the id we returned when you created the checkout, and you stored it then. Accept the postback only if it matches the id you stored for the order. |
reference | Your own order id, for your logs. Do not match on it alone: see Duplicate references. |
status | The decision: completed = paid, failed = not paid. |
statusCode | The detail: SUCCESS, EXPIRED (nobody paid in time, or the link expired) or FAILED. |
transactionId | Our id for the payment. Quote it to support. Use it, with status, as your once-only key. |
amount, requestedAmount | amount is what was paid and what settles; requestedAmount is your order amount. Reconcile on amount. |
mobile | The number the customer paid from. |
user | The user object you sent with the checkout. |
Where it is sent
To the first address that is set, in this order: the postback URL on your API key; the postback URL on your account; the successUrl you sent with the checkout. If none is set, no postback is sent. Your AntPay account manager sets the first two for you.
Verify the signature
Every postback carries the header X-Mint-Signature: sha256=<hex>. The value is the HMAC-SHA256 of the raw request body, keyed with your postback secret, in lower-case hex, with the prefix sha256=.
- Compute it over the bytes exactly as they arrived. Do not parse the JSON and encode it again first: the bytes would differ and so would the signature.
- Use the secret as the 64-character text it is. Do not decode it from hex.
- Compare with a constant-time function (the samples do).
- If it does not match, answer
401and do nothing else.
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
Check your code against fixed values
Test values only; nothing here is a live secret. If your function returns the header below for this secret and this body, it is right.
Postback secret:
7d3a9f0c5b1e4826a4c7e9d2b6f8031a5c9e7b4d2f6a8c0e1b3d5f7a9c2e4b60
Raw body (one line, exactly these bytes):
{"success":true,"reference":"ORD-10021","transactionId":"FDX-1A2B3C4D","paymentMethod":"qr","mobile":"03001234567","amount":1500,"status":"completed","statusCode":"SUCCESS","pspStatusCode":"SUCCESS","pspOrderId":"FDX-1A2B3C4D","pspTxnId":"01JABCDEF0000000000000000","message":"Payment completed successfully","failureReason":null,"createdAt":"2026-11-01T10:00:00.000Z","updatedAt":"2026-11-01T10:04:12.000Z","attemptCount":1,"attempts":[{"attemptNumber":1,"pspOrderId":"FDX-1A2B3C4D","pspTxnId":"01JABCDEF0000000000000000","pspStoreId":null,"pspStatusCode":"pending","message":"QR created","outcome":"success","startedAt":"2026-11-01T10:01:02.000Z","completedAt":"2026-11-01T10:01:03.000Z","durationMs":850}],"requestedAmount":1500,"qr":{"payload":"000201010212…6304ABCD","imageUrl":null,"expiresAt":"2026-11-01T10:16:02.000Z","payableAmount":"1500.00"},"id":"3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90","vendorId":"MERCHANT_000123_PK1","checkoutId":"3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90","acknowledged":false,"tokenId":"3f6c1d2e-8a4b-4c1e-9d7a-2b5e6f7a8c90","customerMobile":"03001234567","user":{"customerId":"C-778"}}
Expected header value:
sha256=6e3c030d65f43277c4ed93769163a4058d63292b1cbbcdcf6fe60735bcd6fc57
The receiver
Verify, hand the result to your own code, answer 2xx. Your code must give the same outcome when it is called twice with the same transactionId and status.
cURL
# Test YOUR postback URL before go-live: send it a postback signed with your own postback secret.
antpay_send_test_postback() { # antpay_send_test_postback YOUR_URL FILE_WITH_JSON_BODY
curl -sS -X POST "$1" -H 'Content-Type: application/json' \
-H "X-Mint-Signature: $(antpay_postback_signature "$2")" --data-binary "@$2" -w '\n%{http_code}\n'
}Part of antpay.sh, the complete tested file
Node.js
// Step 4: receive the postback. Verify, record once, answer 2xx within 10 seconds.
// onResult(payload) is YOUR function: it must be safe to call twice with the same transactionId + status.
const MAX_POSTBACK_BYTES = 1024 * 1024; // a postback is a few kB; refuse anything huge
function postbackServer(onResult) {
return http.createServer((req, res) => {
const chunks = [];
let size = 0;
req.on('data', (c) => {
size += c.length;
if (size > MAX_POSTBACK_BYTES) { res.writeHead(413).end(); req.destroy(); return; }
chunks.push(c);
});
req.on('end', async () => {
if (size > MAX_POSTBACK_BYTES) return;
const raw = Buffer.concat(chunks);
if (!verifyPostback(raw, req.headers['x-mint-signature'])) {
res.writeHead(401).end(); // not from AntPay, or the body was changed
return;
}
const p = JSON.parse(raw.toString('utf8'));
try {
// p.status is "completed" or "failed" (never "pending"). Find your order by p.checkoutId: the id you
// stored when you created the checkout. Do not match on p.reference alone.
await onResult(p);
res.writeHead(200).end('ok');
} catch (e) {
res.writeHead(500).end(); // we try again: 3 attempts in total, 5 s apart
}
});
});
}Part of antpay.js, the complete tested file
PHP
// Step 4: receive the postback (this is the body of your postback URL, e.g. postback.php).
// Verify, record once, answer 2xx within 10 seconds.
function antpay_handle_postback(callable $onResult): void {
$raw = file_get_contents('php://input');
if (!antpay_verify_postback($raw, $_SERVER['HTTP_X_MINT_SIGNATURE'] ?? null)) {
http_response_code(401); // not from AntPay, or the body was changed
return;
}
$p = json_decode($raw, true);
try {
// $p['status'] is "completed" or "failed" (never "pending"). Find your order by $p['checkoutId']: the id you
// stored when you created the checkout. Do not match on the reference alone.
// $onResult must be safe to call twice with the same transactionId + status.
$onResult($p);
http_response_code(200);
echo 'ok';
} catch (Throwable $e) {
http_response_code(500); // we try again: 3 attempts in total, 5 s apart
}
}Part of antpay.php, the complete tested file
Python
# Step 4: receive the postback. Verify, record once, answer 2xx within 10 seconds.
# on_result(payload) is YOUR function: it must be safe to call twice with the same transactionId + status.
def postback_handler(on_result):
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", "0")))
if not verify_postback(raw, self.headers.get("X-Mint-Signature")):
self.send_response(401) # not from AntPay, or the body was changed
self.end_headers()
return
p = json.loads(raw.decode("utf-8"))
try:
# p["status"] is "completed" or "failed" (never "pending"). Find your order by p["checkoutId"]:
# the id you stored when you created the checkout. Do not match on the reference alone.
on_result(p)
self.send_response(200)
except Exception:
self.send_response(500) # we try again: 3 attempts in total, 5 s apart
self.end_headers()
def log_message(self, *args):
pass
return HandlerPart of antpay.py, the complete tested file
Record each result once
The same postback can reach you more than once (a retry after your server answered slowly, or a replay). Before you fulfil an order, check whether you already recorded this transactionId with this status. A unique key on those two columns in your database is enough: the second insert fails and you answer 200 without doing the work again.
Check which checkout it is for. If the postback's checkoutId is not the one you stored for that order, answer 200 and change nothing: it belongs to another checkout made with the same reference (for example after a retried create), and its failed must not undo an order that was paid on the checkout you did use.
Delivery and retries
| Your server | What AntPay does |
|---|---|
Answers any 2xx within 10 seconds | Done. The postback is acknowledged. |
| Answers anything else, or takes longer than 10 seconds | Tries again: 3 attempts in total, 5 seconds apart. |
| Fails all 3 | No further automatic attempts. Read the result with a status call. |
Answer first, work second: record the result, return 200, then do slow work (emails, stock, shipping) outside the request.
Late payments: act on the latest status
A failed payment normally stays failed, and nothing changes it by itself. In the rare case that the customer did pay: if the payment network confirms that it was paid, AntPay staff correct it to completed and you then get a second postback for the same transactionId (status: "completed", statusCode: "SUCCESS", pspStatusCode: "MANUAL_SUCCESS"). This is not automatic and can take hours or days. Your handler must accept it: a later completed replaces an earlier failed. If a customer says they paid after the page expired, send support the transactionId; that is what starts the check.
When to expect it
| Stage | Duration | Outcome |
|---|---|---|
| Link open, no details yet | expiresIn (default 15 min) | Nobody submits: a failed / EXPIRED postback shortly after the link expires |
| QR window | 10 min from QR creation (the timer) | The customer pays: a completed / SUCCESS postback |
| After the timer | Until at least 30 min after QR creation | The page shows that the payment is being checked. A payment made late can still complete, and you then get completed. No "not paid" verdict is given during this time |
| Final verdict for an unpaid QR | Never earlier than 30 min after QR creation | Not paid: a failed / EXPIRED postback |
So an order that is not paid stays pending for at least half an hour after the customer got the QR, however short the timer on the page is. A paid order is reported as soon as the payment is confirmed.
The failed / EXPIRED verdict normally follows a last check with the payment network, 30 minutes after the QR was created. If that check gives no clear answer, the payment is closed as failed / EXPIRED anyway about 40 minutes after the QR was created, without a further check. So a failed is not proof that no money moved. In the rare case the customer did pay, the correction is made by AntPay staff, not automatically (see Late payments above). Wait for the postback; do not decide by the clock.
Test your receiver before go-live
Send your own endpoint a postback signed with your own secret and check that it answers 200, that a second identical one changes nothing, and that one with a changed body gets 401.
cURL
# Test YOUR postback URL before go-live: send it a postback signed with your own postback secret.
antpay_send_test_postback() { # antpay_send_test_postback YOUR_URL FILE_WITH_JSON_BODY
curl -sS -X POST "$1" -H 'Content-Type: application/json' \
-H "X-Mint-Signature: $(antpay_postback_signature "$2")" --data-binary "@$2" -w '\n%{http_code}\n'
}Part of antpay.sh, the complete tested file