What is a webhook callback?

Webhook callback คืออะไร?

When you create an order with a notify_url, we will POST a JSON payload to that URL once the order reaches a terminal state (paid, success, or failed). This is your asynchronous notification — you do not need to poll.

เมื่อคุณสร้าง order พร้อม notify_url เมื่อ order เข้าสถานะสุดท้าย (paid, success, fail) ระบบจะ POST JSON ไปที่ URL นั้น — ฝั่งคุณไม่ต้อง poll

Trigger Callback page mode in payload เกิดจาก หน้า callback mode ใน payload
Customer pays for a payment order Payment Callback "PAYMENT" ลูกค้าจ่ายเงินสำเร็จสำหรับ payment order Payment Callback "PAYMENT"
Withdraw order completes (success or fail) Withdraw / Settlement Callback "WITHDRAW" Withdraw order จบ (success หรือ fail) Withdraw / Settlement Callback "WITHDRAW"
Settlement order completes Withdraw / Settlement Callback "WITHDRAW" Settlement order จบ Withdraw / Settlement Callback "WITHDRAW"
Settlements use the WITHDRAW callback shape
Settlement ใช้รูปแบบ WITHDRAW callback

A settlement callback is shaped exactly like a withdraw callback — the only difference is that the mode marker (4th character) of platform_order_id is M (settlement) instead of W (withdraw). Use this marker to distinguish in your handler.

Settlement callback มีรูปแบบเหมือน withdraw — ต่างกันเฉพาะ ตัว mode (ตัวที่ 4) ของ platform_order_id ที่เป็น M (settlement) แทน W (withdraw) — ใช้ตัวนี้แยกใน handler

HTTP request shape

รูปแบบ HTTP request

Every callback we send to your notify_url looks like this at the wire level:

ทุก callback ที่ระบบส่งไปยัง notify_url จะมีรูปแบบดังนี้:

HTTP
POST /your-callback-path HTTP/1.1
Host: <your-merchant-webhook-URL>
User-Agent: <gateway-user-agent>
Content-Type: application/json
Connection: close
X-Signature: 7b3d4e8f1a2c5b6e9d0f1a2c3b4e5d6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d

{"merchant_id":"AA12345678","platform_order_id":"...","merchant_order_id":"...",...}
HeaderValue / Note Headerค่า / หมายเหตุ
User-Agent A static identifier set by the gateway. Ask your payment gateway admin for the exact value if you want to filter or log on it. User-Agent ค่าคงที่ที่ตั้งโดย gateway — ติดต่อ Payment gateway admin เพื่อรับค่าจริง ถ้าต้องการนำไป filter หรือ log
Content-Type Always application/json. Content-Type เป็น application/json เสมอ
Connection Always close — no keep-alive. Connection เป็น close เสมอ — ไม่ใช้ keep-alive
X-Signature HMAC-SHA256 hex of the raw JSON body using your secret. Header name is title-cased: X-Signature, not X-SIGNATURE. Most HTTP frameworks normalize header names case-insensitively, but be aware if you're parsing manually. X-Signature HMAC-SHA256 hex ของ raw JSON body โดยใช้ secret ของคุณ — ชื่อ header เป็น X-Signature (Title-Case) ไม่ใช่ X-SIGNATURE — ส่วนใหญ่ HTTP framework จะ normalize case อยู่แล้ว แต่ถ้า parse เอง ควรระวัง

Verifying the signature

การตรวจสอบ Signature

Always verify the X-Signature header. Otherwise an attacker can forge fake callbacks and trick your system into marking unpaid orders as paid. The check is the same HMAC-SHA256 over the raw body, using the same secret you use for outgoing requests.

ต้องตรวจสอบ X-Signature ทุกครั้ง — มิฉะนั้น attacker อาจปลอม callback หลอกให้ระบบคุณ mark order ที่ไม่ได้จ่ายเป็น paid — การตรวจสอบใช้ HMAC-SHA256 บน raw body ด้วย secret เดียวกัน กับที่ใช้ส่ง request

# For testing/debugging only — verify a captured callback:
RAW_BODY=$(cat captured-payload.json)
RECEIVED_SIG="<signature from X-Signature header>"
SECRET='YOUR_SECRET'

EXPECTED=$(printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

if [ "$EXPECTED" = "$RECEIVED_SIG" ]; then
  echo "OK"
else
  echo "FAIL: expected=$EXPECTED received=$RECEIVED_SIG"
fi
// Express handler — read the RAW body, then verify
import express from 'express';
import crypto from 'node:crypto';

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

// IMPORTANT: capture raw body BEFORE JSON parsing
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody  = req.body;                                  // Buffer
    const received = req.get('x-signature') || '';
    const expected = crypto.createHmac('sha256', SECRET)
                           .update(rawBody)
                           .digest('hex');

    // Use timing-safe comparison
    const ok = received.length === expected.length &&
               crypto.timingSafeEqual(
                 Buffer.from(received, 'hex'),
                 Buffer.from(expected, 'hex')
               );

    if (!ok) return res.status(401).send('bad signature');

    const event = JSON.parse(rawBody.toString('utf8'));
    // ... handle event idempotently ...
    res.status(200).send('ok');
  }
);
<?php
$secret = getenv('GATEWAY_SECRET');

// Read raw body BEFORE any framework parsing
$rawBody = file_get_contents('php://input');

$received = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);

// Timing-safe comparison
if (!hash_equals($expected, $received)) {
    http_response_code(401);
    echo 'bad signature';
    exit;
}

$event = json_decode($rawBody, true);
// ... handle $event idempotently ...

http_response_code(200);
echo 'ok';
⚠️ Verify the raw body — not your re-serialized copy
⚠️ ตรวจ raw body — ไม่ใช่ copy ที่ serialize ใหม่

The signature is over the exact bytes we sent. If your framework auto-parses the JSON and you re-stringify before HMAC, key order or whitespace differences will break verification. Capture req.rawBody / php://input first, then parse.

Signature คำนวณจาก bytes ที่เราส่งจริง — ถ้า framework auto-parse JSON แล้วคุณ stringify ใหม่ก่อน HMAC ลำดับ key หรือ whitespace ที่ต่างอาจทำให้ verify ไม่ผ่าน — ให้อ่าน raw body ก่อน (เช่น req.rawBody / php://input) แล้วค่อย parse

How to acknowledge

วิธีตอบรับ

Respond with HTTP 200 to acknowledge receipt. Any other status code (or a timeout) counts as a failure and we will retry.

ตอบ HTTP 200 เพื่อยืนยันว่ารับได้แล้ว — status อื่นใด (หรือ timeout) ถือเป็นล้มเหลว ระบบจะ retry

Your response Outcome Response ของคุณ ผลลัพธ์
HTTP 200 (any body) ✅ Acknowledged. No further attempts. HTTP 200 (body อะไรก็ได้) ✅ รับแล้ว — ไม่มี retry อีก
HTTP 4xx / 5xx 🔄 Retry up to 5 attempts, 60s apart. HTTP 4xx / 5xx 🔄 Retry สูงสุด 5 ครั้ง ห่างละ 60 วินาที
No response within 60s 🔄 Counted as failure → retry. ไม่ตอบภายใน 60 วินาที 🔄 นับเป็นล้มเหลว → retry
All 5 attempts fail ⛔ Final failure logged on our side. We will not retry further. The order's status itself is unaffected — only the notification is dropped. Retry ครบ 5 ครั้งยังไม่สำเร็จ ⛔ ระบบเราจะ log final failure และหยุด retry — สถานะของ order เองไม่กระทบ — แค่ notification ที่หาย

Retry policy

นโยบาย Retry

Max attempts 5 จำนวนครั้งสูงสุด 5
Delay between attempts 60 seconds (constant; no exponential backoff) ระยะห่างระหว่างครั้ง 60 วินาที (คงที่ ไม่มี exponential backoff)
Per-request timeout 60 seconds Timeout ต่อครั้ง 60 วินาที
Success criterion HTTP 200 เงื่อนไขสำเร็จ HTTP 200
Total max delivery window ~5 minutes (5 × 60s + processing) เวลารวมสูงสุด ~5 นาที (5 × 60s + processing)
Concurrency Up to ~50 callbacks delivered in parallel system-wide Concurrency ส่ง callback ขนานกันได้สูงสุด ~50 ตัวพร้อมกัน
No ordering guarantee
ไม่มี ordering guarantee

Callbacks are not guaranteed to arrive in order. Two orders that completed seconds apart may have their callbacks delivered in either sequence (especially after retries).

Callback ไม่รับประกันลำดับการส่ง — order สองตัวที่เสร็จห่างกันไม่กี่วินาที callback อาจมาถึงสลับลำดับกันได้ (โดยเฉพาะหลัง retry)

Idempotency — your handler must be safe to call twice

Idempotency — handler ต้อง process ซ้ำได้อย่างปลอดภัย

Duplicate deliveries are possible — for example if your endpoint times out at second 59 but had already updated state, our retry will send the same payload again. Your handler must be able to receive the same callback multiple times without double-counting.

Callback ส่งซ้ำได้ — เช่นถ้า endpoint ของคุณ timeout วินาทีที่ 59 หลัง update state ไปแล้ว ระบบจะ retry ส่ง payload เดิมอีกรอบ — handler ต้องรับ callback ซ้ำได้โดยไม่บวกซ้ำ

Recommended handler pattern

Pattern ที่แนะนำสำหรับ handler

  1. Verify signature against the raw body. Reject 401 on mismatch.
  2. ตรวจ signature กับ raw body — ถ้าไม่ตรง ตอบ 401
  3. Parse JSON. Extract platform_order_id as the unique key.
  4. Parse JSON — ใช้ platform_order_id เป็น unique key
  5. Insert-or-ignore a row in your callback log table on (platform_order_id, status). If the row already exists with the same status, return 200 immediately — already processed.
  6. Insert-or-ignore log table ด้วย (platform_order_id, status) — ถ้ามี row เดียวกันอยู่แล้ว ตอบ 200 ทันที — ประมวลผลไปแล้ว
  7. Update your order within a transaction.
  8. Update order ภายใน transaction
  9. Return 200 only after the DB transaction commits.
  10. ตอบ 200 หลัง DB transaction commit สำเร็จเท่านั้น
Quick rule of thumb
Rule ง่าย ๆ

If receiving the same callback 5 times produces the same final state in your system as receiving it once — your handler is correct.

ถ้ารับ callback เดิม 5 ครั้งแล้ว state สุดท้ายในระบบคุณยังเหมือนกับรับครั้งเดียว — handler คุณ correct แล้ว

Security checklist

Checklist ด้านความปลอดภัย

  • HTTPS only. Use a TLS-enabled notify_url; we do not enforce TLS at our end but plain HTTP exposes signatures and order data on the wire.
  • HTTPS เท่านั้น — ใช้ notify_url ที่เปิด TLS — ระบบเราไม่บังคับ TLS ฝั่งคุณ แต่ HTTP ธรรมดาจะเปิดเผย signature และข้อมูล order
  • Always verify X-Signature with timing-safe comparison (crypto.timingSafeEqual, hash_equals).
  • ตรวจ X-Signature เสมอ ด้วย timing-safe comparison (crypto.timingSafeEqual, hash_equals)
  • Never trust payload values blindly. Cross-check amount and merchant_order_id against your stored order before marking it paid.
  • อย่าเชื่อค่าใน payload ทันที — เช็ค amount และ merchant_order_id เทียบกับ order ที่เก็บไว้ก่อน mark ว่าจ่ายแล้ว
  • Reject unknown mode values. Currently only PAYMENT and WITHDRAW; unexpected values may indicate a future protocol change you haven't accounted for.
  • ปฏิเสธ mode ที่ไม่รู้จัก — ตอนนี้มีแค่ PAYMENT กับ WITHDRAW — ค่าอื่นอาจเป็น protocol ใหม่ในอนาคตที่คุณยังไม่รองรับ
  • Don't whitelist source IPs as your only check — our egress IPs may change without notice. Signature verification is the contract.
  • อย่าใช้ IP whitelist เป็น check เดียว — IP ออกของระบบเราอาจเปลี่ยน — Signature verification คือหลัก