Webhooks
เมื่อ pay-in หรือ payout เปลี่ยนเป็นสถานะสำคัญ ระบบจะส่ง POST เป็น JSON ไปที่ webhookUrl ที่คุณระบุตอนสร้างรายการนั้น (ไม่มีการตั้งค่า URL กลาง แต่ละรายการใช้ URL ของตัวเอง)
Event ที่ส่ง
หัวข้อที่มีชื่อว่า “Event ที่ส่ง”type มีรูปแบบ <object>.<status> ตามสถานะของรายการตอนที่ส่ง
| Event | เกิดเมื่อ |
|---|---|
payin.succeeded | จับคู่การโอนได้ (จาก statement ธนาคาร สลิป หรือเจ้าหน้าที่ยืนยัน) |
payin.expired | pay-in หมดเวลาโดยไม่มีการจ่าย |
payin.canceled | ระบบยกเลิก pay-in |
payout.succeeded | payout โอนสำเร็จ |
payout.canceled | payout ถูกยกเลิกโดยเจ้าหน้าที่ หลังไม่ผ่านการตรวจ หรือโดยระบบ |
payout.pending | payout ที่รอตรวจ (in_review) ได้รับอนุมัติและเข้าคิวดำเนินการ |
- การยกเลิกผ่าน API ไม่ส่ง webhook เพราะคุณได้ผลจาก response แล้ว
- payout ที่ไม่ได้ระบุ
webhookUrlจะไม่มี webhook - อาจมี event ชนิดอื่นเพิ่มในอนาคต ให้ตอบ 2xx กับ event ที่ไม่รู้จัก และตัดสินใจจาก
data.statusเสมอ
Payload
หัวข้อที่มีชื่อว่า “Payload”{ "id": "evt_1d6c0f3a9b2e4c7d8e1f2a3b4c5d6e7f", "type": "payin.succeeded", "createdAt": "2026-01-01T00:05:01.000Z", "data": { "object": "payin", "id": "pin_3f2b1c0d9e8f4a7b8c6d5e4f3a2b1c0d", "reference": "order-10001", "payerId": "payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d", "payer": { "object": "payer", "id": "payer_7c0a4b8e2f3d4a1b9c8d7e6f5a4b3c2d", "...": "..." }, "requestedAmount": "150.00", "amount": "150.37", "fee": "2.25", "currency": "THB", "method": "promptpay_qr", "status": "succeeded", "failureReason": null, "webhookUrl": "https://shop.example.com/webhooks/payments", "returnUrl": "https://shop.example.com/orders/10001", "checkoutUrl": "https://pay.example.com/3f2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "payerAccount": { "bankCode": "SCB", "accountNumber": "1234567890", "accountName": "สมชาย สบายดี" }, "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:05:00.000Z" }}data คือ object ของ pay-in หรือ payout แบบเดียวกับที่ได้จาก API แต่ไม่มี payTo และ paymentImage
| Header | ค่า |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Webhook-Id | ID ของ event (เหมือน id ใน body) |
X-Webhook-Timestamp | Unix time (วินาที) ตอนเซ็น |
X-Webhook-Signature | t=<timestamp>,v1=<hex HMAC-SHA256> |
User-Agent | ชื่อระบบที่ส่ง webhook ไม่ควรใช้ตรวจสอบตัวตน |
ตรวจลายเซ็น
หัวข้อที่มีชื่อว่า “ตรวจลายเซ็น”ลายเซ็นใช้ secret เดียวกับที่ใช้เซ็น API request
v1 = hex( HMAC-SHA256( secret, t + "." + raw_body ) )-
อ่าน body เป็นไบต์ดิบ ก่อน parse JSON (ถ้า framework parse แล้ว serialize ใหม่ ลายเซ็นจะไม่ตรง)
-
แยก
tและv1จาก headerX-Webhook-Signature -
คำนวณ
v1ที่ควรได้ แล้วเทียบแบบ constant-time -
ปฏิเสธ event ที่
tห่างจากเวลาปัจจุบันเกิน 5 นาที เพื่อกัน replay -
ถ้าไม่ผ่าน ตอบ
400และไม่ต้องทำอะไรต่อ
// Express. Mount the raw body parser on this route only.import crypto from 'node:crypto';import express from 'express';
const SECRET = process.env.PARTNER_API_SECRET;
export function verifyWebhook(signatureHeader, rawBody, toleranceSeconds = 300) { const parts = {}; for (const item of String(signatureHeader ?? '').split(',')) { const i = item.indexOf('='); if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim(); } if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSeconds) return false; const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.`).update(rawBody).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b);}
const app = express();app.post('/webhooks/payments', express.raw({ type: '*/*' }), async (req, res) => { if (!verifyWebhook(req.get('X-Webhook-Signature'), req.body)) return res.status(400).end(); const event = JSON.parse(req.body.toString('utf8')); await saveEventForProcessing(event); // e.g. insert into a table or a queue res.status(200).end(); // answer fast, process afterwards});<?phpfunction verify_webhook(string $header, string $rawBody, string $secret, int $tolerance = 300): bool{ $parts = []; foreach (explode(',', $header) as $item) { $kv = explode('=', trim($item), 2); if (count($kv) === 2) { $parts[$kv[0]] = $kv[1]; } } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } if (abs(time() - (int) $parts['t']) > $tolerance) { return false; } $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']);}
$raw = file_get_contents('php://input');if (!verify_webhook($_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '', $raw, getenv('PARTNER_API_SECRET'))) { http_response_code(400); exit;}$event = json_decode($raw, true);save_event_for_processing($event); // e.g. insert into a table or a queuehttp_response_code(200);# Flaskimport hashlib, hmac, json, os, timefrom flask import Flask, request
API_SECRET = os.environ["PARTNER_API_SECRET"]app = Flask(__name__)
def verify_webhook(signature_header, raw_body, tolerance=300): parts = dict(p.strip().split("=", 1) for p in (signature_header or "").split(",") if "=" in p) t, v1 = parts.get("t", ""), parts.get("v1", "") if not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(API_SECRET.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)
@app.post("/webhooks/payments")def payments_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify_webhook(request.headers.get("X-Webhook-Signature"), raw): return "", 400 event = json.loads(raw) save_event_for_processing(event) # e.g. insert into a table or a queue return "", 200Test vector
หัวข้อที่มีชื่อว่า “Test vector”secret 3f9c1a7e5b2d4c6e8f0a1b3c5d7e9f11, body (ไม่มีช่องว่างหรือขึ้นบรรทัด)
{"id":"evt_1d6c0f3a9b2e4c7d8e1f2a3b4c5d6e7f","type":"payin.succeeded","createdAt":"2026-01-01T00:05:01.000Z","data":{"object":"payin","id":"pin_3f2b1c0d9e8f4a7b8c6d5e4f3a2b1c0d","status":"succeeded"}}header ที่ถูกต้อง (ตอนทดสอบให้ปิดการเช็กอายุ 5 นาทีชั่วคราว เพราะ timestamp นี้เก่าแล้ว)
X-Webhook-Signature: t=1767225901,v1=8f10d9e7bbb3bb92adf707670fbce69c9bcb88772b1b92e7cd216d4f032e9275การส่งและการส่งซ้ำ
หัวข้อที่มีชื่อว่า “การส่งและการส่งซ้ำ”- ระบบรอคำตอบไม่เกิน 10 วินาที ให้ตอบ
2xxให้เร็วที่สุด แล้วค่อยประมวลผลงานที่ช้าทีหลัง - ระบบไม่ส่งซ้ำอัตโนมัติ ถ้า endpoint ล่ม ตอบช้า หรือตอบ error ระบบจะไม่ลองใหม่เอง
- ระบบไม่ตาม redirect และส่งเฉพาะ URL
httpหรือhttpsที่เป็น IP สาธารณะ URL ที่ชี้ไปlocalhost, IP ภายใน หรือ link-local จะไม่ถูกเรียก ตอนพัฒนาในเครื่องให้ใช้ tunnel ที่มี URL สาธารณะ - สถานะและข้อความตอบกลับของ endpoint คุณถูกบันทึกไว้ ดูได้ในหน้ารายละเอียดรายการบนหน้าเว็บจัดการ (ส่วน ประวัติ Callback)
ขอส่งแจ้งผลอีกครั้ง
หัวข้อที่มีชื่อว่า “ขอส่งแจ้งผลอีกครั้ง”ถ้าพลาด webhook ไป เปิดรายการนั้นในหน้าเว็บจัดการแล้วกด ส่งแจ้งผลอีกครั้ง (Send callback again) ปุ่มนี้ใช้ได้กับรายการที่จบแล้ว ได้แก่ pay-in ที่ succeeded, expired, canceled และ payout ที่ succeeded, canceled ถ้าต้องการให้ส่งใหม่หลายรายการ ติดต่อทีมงานพร้อมรายการ id
ประมวลผลซ้ำได้อย่างปลอดภัย (idempotent)
หัวข้อที่มีชื่อว่า “ประมวลผลซ้ำได้อย่างปลอดภัย (idempotent)”webhook ของรายการเดียวอาจมาถึงมากกว่าหนึ่งครั้ง หรือมาหลังจากที่คุณ GET สถานะไปแล้ว ให้เขียนโค้ดดังนี้
- กันซ้ำด้วยคีย์
data.id+data.statusเช่น unique index ในตาราง ถ้าเคยประมวลผลแล้ว ให้ตอบ200แล้วจบ - เมื่อคำสั่งซื้อถึงสถานะสิ้นสุดแล้ว (จ่ายแล้ว หรือยกเลิกแล้ว) ห้ามย้อนสถานะจาก event ที่มาช้า
- เติมเงินให้ผู้ใช้ภายใน database transaction เดียวกับการบันทึกว่าประมวลผล event แล้ว
- ใช้
data.referenceหา order ของคุณ และตรวจว่าdata.currencyและยอดตรงกับที่คาดไว้
ตรวจสอบย้อนหลัง (reconcile)
หัวข้อที่มีชื่อว่า “ตรวจสอบย้อนหลัง (reconcile)”เพราะไม่มีการส่งซ้ำอัตโนมัติ ควรมีงานตั้งเวลา (เช่น ทุก 5-10 นาที) ที่
- เรียก
GET /v1/payins/{id}หรือGET /v1/payouts/{id}สำหรับรายการที่ในระบบคุณยังไม่จบนานเกินปกติ - ดึงรายการที่สำเร็จล่าสุดมาเทียบ เพื่อหา webhook ที่พลาดไป
GET /v1/payins?status=succeeded&createdFrom=2026-01-01T00:00:00Z&limit=100