ข้ามไปยังเนื้อหา

Webhooks

เมื่อ pay-in หรือ payout เปลี่ยนเป็นสถานะสำคัญ ระบบจะส่ง POST เป็น JSON ไปที่ webhookUrl ที่คุณระบุตอนสร้างรายการนั้น (ไม่มีการตั้งค่า URL กลาง แต่ละรายการใช้ URL ของตัวเอง)

type มีรูปแบบ <object>.<status> ตามสถานะของรายการตอนที่ส่ง

Eventเกิดเมื่อ
payin.succeededจับคู่การโอนได้ (จาก statement ธนาคาร สลิป หรือเจ้าหน้าที่ยืนยัน)
payin.expiredpay-in หมดเวลาโดยไม่มีการจ่าย
payin.canceledระบบยกเลิก pay-in
payout.succeededpayout โอนสำเร็จ
payout.canceledpayout ถูกยกเลิกโดยเจ้าหน้าที่ หลังไม่ผ่านการตรวจ หรือโดยระบบ
payout.pendingpayout ที่รอตรวจ (in_review) ได้รับอนุมัติและเข้าคิวดำเนินการ
  • การยกเลิกผ่าน API ไม่ส่ง webhook เพราะคุณได้ผลจาก response แล้ว
  • payout ที่ไม่ได้ระบุ webhookUrl จะไม่มี webhook
  • อาจมี event ชนิดอื่นเพิ่มในอนาคต ให้ตอบ 2xx กับ event ที่ไม่รู้จัก และตัดสินใจจาก data.status เสมอ
{
"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-Typeapplication/json; charset=utf-8
X-Webhook-IdID ของ event (เหมือน id ใน body)
X-Webhook-TimestampUnix time (วินาที) ตอนเซ็น
X-Webhook-Signaturet=<timestamp>,v1=<hex HMAC-SHA256>
User-Agentชื่อระบบที่ส่ง webhook ไม่ควรใช้ตรวจสอบตัวตน

ลายเซ็นใช้ secret เดียวกับที่ใช้เซ็น API request

v1 = hex( HMAC-SHA256( secret, t + "." + raw_body ) )
  1. อ่าน body เป็นไบต์ดิบ ก่อน parse JSON (ถ้า framework parse แล้ว serialize ใหม่ ลายเซ็นจะไม่ตรง)

  2. แยก t และ v1 จาก header X-Webhook-Signature

  3. คำนวณ v1 ที่ควรได้ แล้วเทียบแบบ constant-time

  4. ปฏิเสธ event ที่ t ห่างจากเวลาปัจจุบันเกิน 5 นาที เพื่อกัน replay

  5. ถ้าไม่ผ่าน ตอบ 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
});

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

webhook ของรายการเดียวอาจมาถึงมากกว่าหนึ่งครั้ง หรือมาหลังจากที่คุณ GET สถานะไปแล้ว ให้เขียนโค้ดดังนี้

  • กันซ้ำด้วยคีย์ data.id + data.status เช่น unique index ในตาราง ถ้าเคยประมวลผลแล้ว ให้ตอบ 200 แล้วจบ
  • เมื่อคำสั่งซื้อถึงสถานะสิ้นสุดแล้ว (จ่ายแล้ว หรือยกเลิกแล้ว) ห้ามย้อนสถานะจาก event ที่มาช้า
  • เติมเงินให้ผู้ใช้ภายใน database transaction เดียวกับการบันทึกว่าประมวลผล event แล้ว
  • ใช้ data.reference หา order ของคุณ และตรวจว่า data.currency และยอดตรงกับที่คาดไว้

เพราะไม่มีการส่งซ้ำอัตโนมัติ ควรมีงานตั้งเวลา (เช่น ทุก 5-10 นาที) ที่

  • เรียก GET /v1/payins/{id} หรือ GET /v1/payouts/{id} สำหรับรายการที่ในระบบคุณยังไม่จบนานเกินปกติ
  • ดึงรายการที่สำเร็จล่าสุดมาเทียบ เพื่อหา webhook ที่พลาดไป
GET /v1/payins?status=succeeded&createdFrom=2026-01-01T00:00:00Z&limit=100