When a withdraw or settlement order completes (success or fail), we send an HTTP POST to the notify_url you provided. Both share the same payload shape — distinguish them by the mode marker (4th character) of platform_order_id:

เมื่อ withdraw หรือ settlement order เสร็จ (สำเร็จหรือล้มเหลว) ระบบเราจะ POST ไปที่ notify_url ของคุณ — ทั้งสองใช้ payload shape เดียวกัน — แยกกันที่ ตัว mode (ตัวที่ 4) ของ platform_order_id:

Origin platform_order_id mode marker mode มาจาก ตัว mode ของ platform_order_id mode
/withdraw/create W (e.g. ABCW20260508abc123XYZ456) "WITHDRAW" /withdraw/create W (เช่น ABCW20260508abc123XYZ456) "WITHDRAW"
/thb-settlement/create M (e.g. ABCM20260509abc123XYZ456) "WITHDRAW" /thb-settlement/create M (เช่น ABCM20260509abc123XYZ456) "WITHDRAW"

Format: 24 characters = <3-letter prefix> + P/W/M (mode marker) + YYYYMMDD + 12 random chars.

รูปแบบ: 24 ตัว = <prefix 3 ตัวอักษร> + P/W/M (ตัว mode) + YYYYMMDD + 12 ตัวสุ่ม

For delivery rules (retry policy, timeout, idempotency), see Webhooks Overview.

รายละเอียดการส่ง (retry, timeout, idempotency) อยู่ที่ Webhooks Overview

Payload

Payload

Example — withdraw success

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

JSON
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCW20260508abc123XYZ456",
  "merchant_order_id": "PAYOUT-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "ลูกค้า ปลายทาง",
  "amount":            1000.00,
  "status":            "SUCCESS",
  "timestamp":         1746694842000
}

Example — settlement success

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

JSON
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCM20260509abc123XYZ456",
  "merchant_order_id": "SETTLE-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "บริษัท เมอร์แชนต์ จำกัด",
  "amount":            50000.00,
  "status":            "SUCCESS",
  "timestamp":         1746780318000
}

Example — failed payout

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

JSON
{
  "merchant_id":       "AA12345678",
  "platform_order_id": "ABCW20260508abc123XYZ456",
  "merchant_order_id": "PAYOUT-2026-001",
  "mode":              "WITHDRAW",
  "bank":              "KBANK",
  "account_no":        "1234567890",
  "account_name":      "ลูกค้า ปลายทาง",
  "amount":            1000.00,
  "status":            "FAIL",
  "timestamp":         1746694842000
}

Field reference

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

FieldTypeDescription ฟิลด์ประเภทคำอธิบาย
merchant_idstring Your merchant identifier (echoed). merchant_idstring รหัส merchant (echo)
platform_order_idstring Order ID assigned by the gateway. Format: 24 chars = <3-letter prefix> + mode marker (W = withdraw, M = settlement) + YYYYMMDD + 12 random chars. Use as your unique key for deduplication. platform_order_idstring Order ID ที่ออกโดย gateway — รูปแบบ 24 ตัว = <prefix 3 ตัวอักษร> + ตัว mode (W = withdraw, M = settlement) + YYYYMMDD + 12 ตัวสุ่ม — ใช้เป็น unique key dedup
merchant_order_idstring Your merchant_order_id from the original create call. merchant_order_idstring merchant_order_id ที่คุณส่งตอนสร้าง
modeenum Always "WITHDRAW" — for both withdraw and settlement callbacks. modeenum เป็น "WITHDRAW" เสมอ — ทั้ง withdraw และ settlement
bank / account_no / account_namestring Echo of the destination bank info from the order. bank / account_no / account_namestring Echo ข้อมูลธนาคารปลายทางจาก order
amountnumber Payout amount (THB). Cross-check against your stored order. amountnumber ยอดถอน (THB) — เช็คกับ order ที่เก็บไว้
statusenum "SUCCESS" on success, "FAIL" on failure (bank rejection, insufficient funds, account closed). Terminal — will not change. statusenum "SUCCESS" ถ้าสำเร็จ — "FAIL" ถ้าไม่สำเร็จ (ธนาคารปฏิเสธ, ยอดไม่พอ, บัญชีปิด) — terminal เปลี่ยนไม่ได้
timestampnumber Unix epoch in milliseconds. timestampnumber Unix epoch หน่วย milliseconds
PAYMENT vs WITHDRAW status values differ
สถานะ PAYMENT vs WITHDRAW ไม่เหมือนกัน

Payment callbacks use PAID/FAIL while withdraw/settlement callbacks use SUCCESS/FAIL. Branch on mode first, then check status.

Payment callback ใช้ PAID/FAIL ส่วน withdraw/settlement ใช้ SUCCESS/FAIL — ให้แยก branch ตาม mode ก่อน แล้วค่อยเช็ค status

Verification — full handler example

ตัวอย่าง Handler ครบ

A reference handler that covers both withdraw and settlement callbacks. The signature verification logic is identical to the payment callback handler — see Webhooks Overview.

Handler ตัวอย่างที่รับทั้ง withdraw และ settlement callback — Logic การตรวจ signature เหมือน payment callback — ดู Webhooks Overview

# Send a fake withdraw callback to your handler for local testing
SECRET='YOUR_SECRET'
BODY='{"merchant_id":"AA12345678","platform_order_id":"ABCW20260508abc123XYZ456","merchant_order_id":"PAYOUT-2026-001","mode":"WITHDRAW","bank":"KBANK","account_no":"1234567890","account_name":"ลูกค้า ปลายทาง","amount":1000,"status":"SUCCESS","timestamp":1746694842000}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://<your-merchant-webhook-URL>/withdraw-callback \
  -H "User-Agent: <gateway-user-agent>" \
  -H "Content-Type: application/json" \
  -H "Connection: close" \
  -H "X-Signature: $SIG" \
  -d "$BODY"
// Express handler — withdraw / settlement callback receiver
import express from 'express';
import crypto from 'node:crypto';

const SECRET = process.env.GATEWAY_SECRET;
const app = express();

app.post('/withdraw-callback',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const rawBody  = req.body;
    const received = req.get('x-signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(rawBody)
                           .digest('hex');

    // 1. Signature
    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 !== 'WITHDRAW') {
      return res.status(400).send('wrong mode');
    }

    // 2. Distinguish withdraw vs settlement by mode marker (4th char of platform_order_id)
    const modeMarker = event.platform_order_id[3];   // 'W' = withdraw, 'M' = settlement
    const kind       = modeMarker === 'M' ? 'settlement' : 'withdraw';

    // 3. Idempotency
    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');

    // 4. Cross-check + update
    const order = await db.findOrder(kind, event.merchant_order_id);
    if (!order || Number(order.amount) !== Number(event.amount)) {
      return res.status(400).send('amount mismatch');
    }

    if (event.status === 'SUCCESS') {
      await db.markOrderSuccess(kind, order.id);
    } else {
      await db.markOrderFailed(kind, order.id);
    }

    res.status(200).send('ok');
  }
);
<?php
// withdraw-callback.php — handles both withdraw and settlement callbacks
$secret = getenv('GATEWAY_SECRET');

$rawBody  = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);

// 1. Signature
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'bad signature';
    exit;
}

$event = json_decode($rawBody, true);
if (($event['mode'] ?? null) !== 'WITHDRAW') {
    http_response_code(400);
    echo 'wrong mode';
    exit;
}

// 2. Distinguish withdraw vs settlement by mode marker (4th char of platform_order_id)
$modeMarker = $event['platform_order_id'][3] ?? '';   // 'W' = withdraw, 'M' = settlement
$kind       = $modeMarker === 'M' ? 'settlement' : 'withdraw';

// 3. Idempotency
$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';
    exit;
}

// 4. Cross-check + update
$table = $kind === 'settlement' ? 'settlements' : 'withdraws';
$lookup = $pdo->prepare("SELECT * FROM {$table} WHERE merchant_order_id = ?");
$lookup->execute([$event['merchant_order_id']]);
$row = $lookup->fetch();

if (!$row || (float)$row['amount'] !== (float)$event['amount']) {
    http_response_code(400);
    echo 'amount mismatch';
    exit;
}

$newStatus = $event['status'] === 'SUCCESS' ? 'success' : 'failed';
$pdo->prepare("UPDATE {$table} 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 "WITHDRAW"
  • ✅ ปฏิเสธถ้า mode ไม่ใช่ "WITHDRAW"
  • ✅ Distinguish withdraw vs settlement by the mode marker (4th char W/M) of platform_order_id — they live in different tables on your side
  • ✅ แยก withdraw vs settlement ด้วยตัว mode (ตัวที่ 4 W/M) ของ platform_order_id — โดยปกติจะอยู่คนละ table
  • ✅ 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 แล้ว