DevelopersAPI referenceSupport

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"
  }
}
FieldUse it for
checkoutIdFinding 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.
referenceYour own order id, for your logs. Do not match on it alone: see Duplicate references.
statusThe decision: completed = paid, failed = not paid.
statusCodeThe detail: SUCCESS, EXPIRED (nobody paid in time, or the link expired) or FAILED.
transactionIdOur id for the payment. Quote it to support. Use it, with status, as your once-only key.
amount, requestedAmountamount is what was paid and what settles; requestedAmount is your order amount. Reconcile on amount.
mobileThe number the customer paid from.
userThe 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=.

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 Handler

Part 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 serverWhat AntPay does
Answers any 2xx within 10 secondsDone. The postback is acknowledged.
Answers anything else, or takes longer than 10 secondsTries again: 3 attempts in total, 5 seconds apart.
Fails all 3No 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

StageDurationOutcome
Link open, no details yetexpiresIn (default 15 min)Nobody submits: a failed / EXPIRED postback shortly after the link expires
QR window10 min from QR creation (the timer)The customer pays: a completed / SUCCESS postback
After the timerUntil at least 30 min after QR creationThe 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 QRNever earlier than 30 min after QR creationNot 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