AffanPay shall not be held responsible for any loss of funds due to improper or incorrect use of the API
Token
Every request to the AffanPay API must carry a token in its Authorization header. You generate that token from your AffanPay account.
- Log in to your AffanPay account.
- Go to Settings → Developer.
- Under API tokens, give the token a name — for example Production store — then click Generate token.
- Copy it straight away. The token is shown once and cannot be retrieved again.
POST Create Bill https://app.affanpay.my/api/v1/bill
This API is used to create a bill. The generated bill can be directly used to initiate and receive payment collections.
<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => 'https://app.affanpay.my/api/v1/bill',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS =>'{
"name": "",
"amount": "",
"customer_name": "",
"customer_email": ""
}',
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'Content-Type: application/json',
'Authorization: Bearer [token from Settings > Developer]'
),
));
$response = curl_exec($curl);
curl_close($curl);
echo $response;
{
"success": true,
"id": "pujsSUNLeIYo",
"url": "https://app.affanpay.my/pujsSUNLeIYo"
}
GET Requery Payment https://app.affanpay.my/api/v1/requery?query=value
AffanPay sends a payment status callback to your system for every transaction, whether successful or not. However, in some cases, the callback may fail or not be received as intended. This API allows you to query the latest status of a payment transaction directly from AffanPay’s system.
<?php
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => 'https://app.affanpay.my/api/v1/requery?bill_id=wxUyT47ko3v',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'Authorization: Bearer [token from Settings > Developer]'
),
));
$response = curl_exec($curl);
curl_close($curl);
echo $response;
{
"data": {
"id": "wxUyT47ko3v",
"name": "Multi open amount",
"payments": [
{
"payment_ref": "AP01384263015313050521",
"status_code": 4,
"status": "No Action",
"nett_amount": " 0.00",
"payment_channel": null
},
{
"payment_ref": "AP01384355115313050521",
"status_code": 3,
"status": "Failed",
"nett_amount": " 0.00",
"payment_channel": "fpx"
},
{
"payment_ref": "AP01384088585413050521",
"status_code": 1,
"status": "Success",
"nett_amount": " 31.20",
"payment_channel": "fpx"
}
]
}
}
POST Callback to your callback_url
When a payment reaches a final state, AffanPay sends a POST request to the callback_url you supplied when creating the bill. This is how your system learns that an order has been paid.
Only for a SUCCESS or a FAILED payment — pending and no-action payments never trigger one. Each payment is sent TWICE, roughly 5 and 10 seconds apart, so your handler must be safe to run more than once. Nothing is sent if the bill has no callback_url.
{
"payment_ref": "AP01384355115313050521",
"status_code": 1,
"status": "Success",
"paid_amount": "324.76",
"payer": {
"name": "Ahmad bin Ali",
"phone": "0123456789",
"email": "ahmad@example.my"
},
"payment_channel": "fpx",
"bank_reference": "2609011012340000",
"bank_response_datetime": "2026-09-01 10:12:34",
"bill": {
"id": "k3jh2x",
"name": "Invoice 12/2026",
"amount": "320.75",
"external_ref": "ORDER-1001",
"created_at": "2026-09-01 09:00:00"
}
}
| Field | Description |
|---|---|
| payment_ref | Our transaction reference. Unique per payment attempt — use it as your idempotency key. |
| status_code | 1 Success · 3 Failed. (2 Pending and 4 No Action exist but never trigger a callback.) Decide from this field, never from status. |
| status | Human-readable label for the code above. For display only. |
| paid_amount | What the payer actually paid. If the bill passes the service charge to the payer this is higher than bill.amount. Cast it before comparing — do not compare it as a string. |
| payer | Name, phone and email as entered by the payer. Any of them can be null. |
| payment_channel | The channel used, e.g. fpx, duitnow_qr, grabpay. Can be null. |
| bank_reference | Reference returned by the bank or wallet. Can be null. |
| bill.id | The bill code, the same value that appears in the payment page URL. |
| bill.amount | The amount the bill was created for, before any service charge. |
| bill.external_ref | Your own reference, exactly as you sent it when creating the bill. null if you did not send one. |
Verifying the signature
Every callback carries an X-AFFANPAY-HMAC header so you can prove the request came from us and not from someone who guessed your callback URL. Verifying is optional — an integration that ignores these headers keeps working exactly as before — but it is strongly recommended, because your callback URL is reachable by anyone.
Log in and open Settings → Developer. The key is shown there. It is never sent to you in a callback and it must stay on your server: anyone holding it can forge a callback that looks like ours.
The signature is a lowercase hex SHA-256 HMAC of timestamp + "." + raw request body, keyed with your secret.
Read the body exactly as it arrives (php://input in PHP, express.raw in Node). Decoding the JSON and re-encoding it produces a different string, and the signature will then never match.
$secret = 'YOUR_SECRET_KEY'; // Settings -> Developer
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_AFFANPAY_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_X_AFFANPAY_HMAC'] ?? '';
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
// hash_equals, not ===, so a wrong signature cannot be found by timing
if (!hash_equals($expected, $received)) {
http_response_code(400);
exit('invalid signature');
}
// reject a callback that was captured and replayed later
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit('stale callback');
}
$payload = json_decode($body, true);
if ((int) $payload['status_code'] === 1) {
// mark your order paid here - safely, this may run twice
}
http_response_code(200);
A 200 tells us the callback was delivered and processed. If your handler crashes but your server still returns 200, we record the callback as successful and you will never be told. Return a 4xx or 5xx when processing fails so the delivery is retried and flagged.