Webhooks — Overview & Retry
Webhooks — ภาพรวมและ Retry
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" |
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 จะมีรูปแบบดังนี้:
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":"...",...}
| Header | Value / 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';
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 ตัวพร้อมกัน |
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
- Verify signature against the raw body. Reject 401 on mismatch.
- ตรวจ signature กับ raw body — ถ้าไม่ตรง ตอบ 401
- Parse JSON. Extract
platform_order_idas the unique key. - Parse JSON — ใช้
platform_order_idเป็น unique key - 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. - Insert-or-ignore log table ด้วย
(platform_order_id, status)— ถ้ามี row เดียวกันอยู่แล้ว ตอบ 200 ทันที — ประมวลผลไปแล้ว - Update your order within a transaction.
- Update order ภายใน transaction
- Return 200 only after the DB transaction commits.
- ตอบ 200 หลัง DB transaction commit สำเร็จเท่านั้น
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
amountandmerchant_order_idagainst your stored order before marking it paid. - อย่าเชื่อค่าใน payload ทันที — เช็ค
amountและmerchant_order_idเทียบกับ order ที่เก็บไว้ก่อน mark ว่าจ่ายแล้ว - Reject unknown
modevalues. Currently onlyPAYMENTandWITHDRAW; 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 คือหลัก