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
}

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). Statuses are terminal — once delivered they will not change. statusenum "PAID" ถ้าสำเร็จ — "FAIL" ถ้าล้มเหลว (หมดอายุ, ปฏิเสธ) — เป็นสถานะสุดท้าย ส่งมาแล้วไม่เปลี่ยนอีก
timestampnumber Unix epoch in milliseconds. The moment we marked the order's status (server-side). timestampnumber 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-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 แล้ว