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 สำเร็จ
{
"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 สำเร็จ
{
"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
ตัวอย่าง — ถอนไม่สำเร็จ
{
"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
รายละเอียดฟิลด์
| Field | Type | Description | ฟิลด์ | ประเภท | คำอธิบาย |
|---|---|---|---|---|---|
| merchant_id | string | Your merchant identifier (echoed). | merchant_id | string | รหัส merchant (echo) |
| platform_order_id | string | 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_id | string | Order ID ที่ออกโดย gateway — รูปแบบ 24 ตัว = <prefix 3 ตัวอักษร> + ตัว mode (W = withdraw, M = settlement) + YYYYMMDD + 12 ตัวสุ่ม — ใช้เป็น unique key dedup |
| merchant_order_id | string | Your merchant_order_id from the original create call. |
merchant_order_id | string | merchant_order_id ที่คุณส่งตอนสร้าง |
| mode | enum | Always "WITHDRAW" — for both withdraw and settlement callbacks. |
mode | enum | เป็น "WITHDRAW" เสมอ — ทั้ง withdraw และ settlement |
| bank / account_no / account_name | string | Echo of the destination bank info from the order. | bank / account_no / account_name | string | Echo ข้อมูลธนาคารปลายทางจาก order |
| amount | number | Payout amount (THB). Cross-check against your stored order. | amount | number | ยอดถอน (THB) — เช็คกับ order ที่เก็บไว้ |
| status | enum | "SUCCESS" on success, "FAIL" on failure (bank rejection, insufficient funds, account closed). Terminal — will not change. |
status | enum | "SUCCESS" ถ้าสำเร็จ — "FAIL" ถ้าไม่สำเร็จ (ธนาคารปฏิเสธ, ยอดไม่พอ, บัญชีปิด) — terminal เปลี่ยนไม่ได้ |
| timestamp | number | Unix epoch in milliseconds. | timestamp | number | Unix epoch หน่วย milliseconds |
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-Signaturewith timing-safe comparison - ✅ ตรวจ
X-Signatureด้วย timing-safe comparison - ✅ Reject if
modeis not"WITHDRAW" - ✅ ปฏิเสธถ้า
modeไม่ใช่"WITHDRAW" - ✅ Distinguish withdraw vs settlement by the mode marker (4th char
W/M) ofplatform_order_id— they live in different tables on your side - ✅ แยก withdraw vs settlement ด้วยตัว mode (ตัวที่ 4
W/M) ของplatform_order_id— โดยปกติจะอยู่คนละ table - ✅ 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 แล้ว