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

ตัวอย่าง — จ่ายสำเร็จ

JSON
{
  "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

ตัวอย่าง — ไม่สำเร็จ

JSON
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCP20260508abc123XYZ456",
  "merchant_order_id": "ORDER-2026-001",
  "mode":              "PAYMENT",
  "amount":            500.00,
  "status":            "FAIL",
  "timestamp":         1746692400000
}

Example — cancelled payment

ตัวอย่าง — ลูกค้ายกเลิก

JSON
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCP20260508abc123XYZ456",
  "merchant_order_id": "ORDER-2026-001",
  "mode":              "PAYMENT",
  "amount":            500.00,
  "status":            "CANCELLED",
  "timestamp":         1746692400000
}

Field reference

รายละเอียดฟิลด์

FieldTypeDescription ฟิลด์ประเภทคำอธิบาย
merchant_idstring Your merchant identifier (echoed from the order). merchant_idstring รหัส merchant (echo จาก order)
platform_order_idstring 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_idstring Order ID ที่ออกโดย gateway
รูปแบบ 24 ตัว = <prefix 3 ตัวอักษร> + P (ตัว mode สำหรับ payment) + YYYYMMDD + 12 ตัวสุ่ม
ใช้เป็น unique key สำหรับ dedup
merchant_order_idstring Your merchant_order_id from the original /payment/create call. merchant_order_idstring merchant_order_id ที่คุณส่งตอนเรียก /payment/create
modeenum Always "PAYMENT" for this callback. modeenum เป็น "PAYMENT" เสมอสำหรับ callback ประเภทนี้
amountnumber The order's nominal amount (THB). Always cross-check this against your stored order. amountnumber จำนวนเงิน nominal ของ order (THB)
ต้องเช็คกับ order ที่เก็บไว้ฝั่งคุณเสมอ
statusenum "PAID" on success, "FAIL" on failure (expired, rejected), or "CANCELLED" when the customer cancelled the order on the checkout page. All three are terminal — once delivered they will not change. statusenum "PAID" ถ้าสำเร็จ
"FAIL" ถ้าล้มเหลว (หมดอายุ, ปฏิเสธ)
หรือ "CANCELLED" เมื่อลูกค้ายกเลิก order บนหน้า checkout
ทั้งสามค่าเป็นสถานะสุดท้าย ส่งมาแล้วไม่เปลี่ยนอีก
timestampnumber Unix epoch in milliseconds. The moment we marked the order's status (server-side). timestampnumber Unix epoch หน่วย milliseconds — เวลาที่ระบบเรา mark สถานะของ order
Handle "CANCELLED" as a terminal non-payment
จัดการ "CANCELLED" เป็นสถานะสุดท้ายที่ไม่ได้จ่าย

A customer can abandon the order by cancelling on the hosted checkout page. When that happens you receive a callback with status: "CANCELLED" — a terminal state, just like FAIL. Don't treat it as paid: if your handler only checks status === "PAID" and buckets everything else as a failure, cancellations are already covered — but make sure any explicit status switch has a branch for CANCELLED so the order is closed as not-paid rather than left pending.

ลูกค้าอาจทิ้ง order ด้วยการกดยกเลิกบนหน้า checkout
เมื่อเกิดขึ้น คุณจะได้รับ callback ที่ status: "CANCELLED" ซึ่งเป็นสถานะสุดท้ายเช่นเดียวกับ FAIL
อย่าถือว่าจ่ายแล้ว
ถ้า handler ของคุณเช็คแค่ status === "PAID" แล้วเหมารวมค่าที่เหลือเป็น failure อยู่แล้ว กรณียกเลิกก็ถูกครอบคลุมไปด้วย
แต่ถ้าใช้ switch แยกตามค่า status ให้เพิ่ม branch สำหรับ CANCELLED เพื่อปิด order เป็น "ไม่ได้จ่าย" ไม่ใช่ปล่อยค้างที่ pending

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-Signature with timing-safe comparison
  • ✅ ตรวจ X-Signature ด้วย timing-safe comparison
  • ✅ Reject if mode is not "PAYMENT"
  • ✅ ปฏิเสธถ้า mode ไม่ใช่ "PAYMENT"
  • ✅ Use platform_order_id as the unique key for dedup
  • ✅ ใช้ platform_order_id เป็น unique key สำหรับ dedup
  • ✅ Cross-check amount against your stored order
  • ✅ เช็ค amount เทียบกับ order ที่เก็บไว้
  • ✅ Return HTTP 200 only after your DB transaction commits
  • ✅ ตอบ HTTP 200 หลัง DB transaction commit แล้ว