When a payment order reaches its terminal state (paid or failed), we send an
HTTP POST to the notify_url you provided when creating the order. This page
documents the payload format and shows verification code.
เมื่อ payment order เข้าสถานะสุดท้าย (จ่ายแล้วหรือล้มเหลว) ระบบเราจะ POST
ไปยัง notify_url ที่คุณส่งตอนสร้าง order — หน้านี้อธิบายรูปแบบ payload
และโชว์ตัวอย่างการตรวจสอบ
For delivery rules (retry policy, timeout, idempotency), see Webhooks Overview.
รายละเอียดการส่ง (retry, timeout, idempotency) อยู่ที่ Webhooks Overview
Payload
Payload
Example — successful payment
ตัวอย่าง — จ่ายสำเร็จ
{
"merchant_id": "AA12345678",
"platform_order_id": "ABCP20260508abc123XYZ456",
"merchant_order_id": "ORDER-2026-001",
"mode": "PAYMENT",
"amount": 500.00,
"status": "PAID",
"timestamp": 1746692400000
}
Example — failed payment
ตัวอย่าง — ไม่สำเร็จ
{
"merchant_id": "AA12345678",
"platform_order_id": "ABCP20260508abc123XYZ456",
"merchant_order_id": "ORDER-2026-001",
"mode": "PAYMENT",
"amount": 500.00,
"status": "FAIL",
"timestamp": 1746692400000
}
Field reference
รายละเอียดฟิลด์
| Field | Type | Description | ฟิลด์ | ประเภท | คำอธิบาย |
|---|---|---|---|---|---|
| merchant_id | string | Your merchant identifier (echoed from the order). | merchant_id | string | รหัส merchant (echo จาก order) |
| platform_order_id | string | Order ID assigned by the gateway. Format: 24 chars = <3-letter prefix> + P (mode marker for payment) + YYYYMMDD + 12 random chars. Use as your unique key for deduplication. |
platform_order_id | string | Order ID ที่ออกโดย gateway — รูปแบบ 24 ตัว = <prefix 3 ตัวอักษร> + P (ตัว mode สำหรับ payment) + YYYYMMDD + 12 ตัวสุ่ม — ใช้เป็น unique key สำหรับ dedup |
| merchant_order_id | string | Your merchant_order_id from the original /payment/create call. |
merchant_order_id | string | merchant_order_id ที่คุณส่งตอนเรียก /payment/create |
| mode | enum | Always "PAYMENT" for this callback. |
mode | enum | เป็น "PAYMENT" เสมอสำหรับ callback ประเภทนี้ |
| amount | number | The order's nominal amount (THB). Always cross-check this against your stored order. | amount | number | จำนวนเงิน nominal ของ order (THB) — ต้องเช็คกับ order ที่เก็บไว้ฝั่งคุณเสมอ |
| status | enum | "PAID" on success, "FAIL" on failure (expired, rejected). Statuses are terminal — once delivered they will not change. |
status | enum | "PAID" ถ้าสำเร็จ — "FAIL" ถ้าล้มเหลว (หมดอายุ, ปฏิเสธ) — เป็นสถานะสุดท้าย ส่งมาแล้วไม่เปลี่ยนอีก |
| timestamp | number | Unix epoch in milliseconds. The moment we marked the order's status (server-side). | timestamp | number | Unix epoch หน่วย milliseconds — เวลาที่ระบบเรา mark สถานะของ order |
Verification — full handler example
ตัวอย่าง Handler ครบ
A complete reference handler that verifies the signature, deduplicates by platform_order_id, cross-checks the amount, and acknowledges with HTTP 200:
ตัวอย่าง handler ที่ตรวจ signature, dedup ด้วย platform_order_id, เช็ค amount และตอบ 200:
# Send a fake payment callback to your handler for local testing
SECRET='YOUR_SECRET'
BODY='{"merchant_id":"AA12345678","platform_order_id":"ABCP20260508abc123XYZ456","merchant_order_id":"ORDER-2026-001","mode":"PAYMENT","amount":500,"status":"PAID","timestamp":1746692400000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST https://<your-merchant-webhook-URL>/payment-callback \
-H "User-Agent: <gateway-user-agent>" \
-H "Content-Type: application/json" \
-H "Connection: close" \
-H "X-Signature: $SIG" \
-d "$BODY"
// Express handler — payment callback receiver
import express from 'express';
import crypto from 'node:crypto';
const SECRET = process.env.GATEWAY_SECRET;
const app = express();
app.post('/payment-callback',
// capture raw body BEFORE JSON parsing
express.raw({ type: 'application/json' }),
async (req, res) => {
const rawBody = req.body; // Buffer
const received = req.get('x-signature') || '';
const expected = crypto.createHmac('sha256', SECRET)
.update(rawBody)
.digest('hex');
// 1. Timing-safe signature check
if (received.length !== expected.length ||
!crypto.timingSafeEqual(
Buffer.from(received, 'hex'),
Buffer.from(expected, 'hex'))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(rawBody.toString('utf8'));
if (event.mode !== 'PAYMENT') {
return res.status(400).send('wrong mode');
}
// 2. Idempotency: insert-or-ignore on platform_order_id
const inserted = await db.insertCallbackLog({
platform_order_id: event.platform_order_id,
status: event.status,
raw_body: rawBody.toString('utf8'),
});
if (!inserted) return res.status(200).send('ok'); // already processed
// 3. Cross-check amount against stored order
const order = await db.findOrder(event.merchant_order_id);
if (!order || Number(order.amount) !== Number(event.amount)) {
return res.status(400).send('amount mismatch');
}
// 4. Update order status atomically
if (event.status === 'PAID') {
await db.markOrderPaid(order.id);
} else {
await db.markOrderFailed(order.id);
}
res.status(200).send('ok');
}
);
<?php
// payment-callback.php
$secret = getenv('GATEWAY_SECRET');
// 1. Read RAW body before any framework parsing
$rawBody = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);
// 2. Timing-safe signature check
if (!hash_equals($expected, $received)) {
http_response_code(401);
echo 'bad signature';
exit;
}
$event = json_decode($rawBody, true);
if (($event['mode'] ?? null) !== 'PAYMENT') {
http_response_code(400);
echo 'wrong mode';
exit;
}
// 3. Idempotency — insert-or-ignore on (platform_order_id, status)
$pdo = new PDO(/* ... */);
$stmt = $pdo->prepare(
'INSERT IGNORE INTO callback_log
(platform_order_id, status, raw_body, received_at)
VALUES (?, ?, ?, NOW())'
);
$stmt->execute([
$event['platform_order_id'],
$event['status'],
$rawBody,
]);
if ($stmt->rowCount() === 0) {
http_response_code(200);
echo 'ok'; // already processed
exit;
}
// 4. Cross-check amount against stored order
$order = $pdo->prepare('SELECT * FROM orders WHERE merchant_order_id = ?');
$order->execute([$event['merchant_order_id']]);
$row = $order->fetch();
if (!$row || (float)$row['amount'] !== (float)$event['amount']) {
http_response_code(400);
echo 'amount mismatch';
exit;
}
// 5. Mark order
$newStatus = $event['status'] === 'PAID' ? 'paid' : 'failed';
$pdo->prepare('UPDATE orders SET status = ? WHERE id = ?')
->execute([$newStatus, $row['id']]);
http_response_code(200);
echo 'ok';
Quick checklist
Checklist สรุป
- ✅ Read the raw body before any JSON parsing
- ✅ อ่าน raw body ก่อน parse JSON
- ✅ Verify
X-Signaturewith timing-safe comparison - ✅ ตรวจ
X-Signatureด้วย timing-safe comparison - ✅ Reject if
modeis not"PAYMENT" - ✅ ปฏิเสธถ้า
modeไม่ใช่"PAYMENT" - ✅ Use
platform_order_idas the unique key for dedup - ✅ ใช้
platform_order_idเป็น unique key สำหรับ dedup - ✅ Cross-check
amountagainst your stored order - ✅ เช็ค
amountเทียบกับ order ที่เก็บไว้ - ✅ Return HTTP 200 only after your DB transaction commits
- ✅ ตอบ HTTP 200 หลัง DB transaction commit แล้ว