รูปของ payload อีกสามแบบ
แผงตัวอย่างข้างบนเป็นแบบแรก คือ order.completed ของสินค้าประเภทคีย์ (type เป็น cdkey
หรือ account1) ที่เหลืออีกสามแบบเป็นแบบนี้
order.completed ของบัญชีมือสอง
type เป็น account2 · delivery จึงเป็นรูป บัญชี แทน · สองรูปนี้ไม่มีฟิลด์ร่วมกันเลย
สักตัว ให้ดู type เพื่อรู้ว่ากำลังถือรูปไหนอยู่ ห้ามเดาจากเนื้อข้อมูล
{
"event": "order.completed",
"data": {
"order_id": "ord_89abcdef0123456789abcdef",
"status": "completed",
"type": "account2",
"product_id": "exampleproduct02",
"price": 1290,
"charged": { "amount": 1290, "currency": "THB" },
"delivery": {
"login": "example_account",
"password": "<รหัสผ่านของบัญชีเกม>",
"email": "[email protected]",
"email_password": "<รหัสผ่านของกล่องอีเมล>",
"extra": {
"item_id": "<รหัสรายการฝั่งผู้ขายส่ง>",
"steam_level": 8,
"steam_game_count": 24,
"steam_country": "TH",
"steam_mfa": false,
"canChangePassword": true
}
},
"error": null,
"created_at": "2026-09-07T04:20:41.000Z",
"updated_at": "2026-09-07T04:22:03.000Z"
}
}
ฟิลด์ล็อกอินสี่ตัวมีอยู่เสมอในรูปนี้ แต่แต่ละตัวเป็น null ได้เมื่อเราไม่มีค่านั้นอยู่ ·
extra บอกว่า ในบัญชีมีอะไรอยู่บ้าง (เลเวล เกม ประเทศ สถานะแบน ฯลฯ) ไม่ได้บอกว่าล็อกอิน
อย่างไร · extra ในตัวอย่างข้างบนถูกย่อให้สั้นเพื่อให้อ่านง่าย ของจริงมีฟิลด์ครบทั้งชุดที่
นิยามไว้ของแพลตฟอร์มนั้น ซึ่งของ Steam อยู่ราวสองโหลคีย์ · คีย์ชุดไหนก็ขึ้นกับแพลตฟอร์มของ
สินค้า และแพลตฟอร์มที่เรายังไม่ได้นิยามชุดฟิลด์ไว้จะได้ extra ที่มีแค่ item_id ตัวเดียว ·
ให้มอง extra เป็นก้อนอิสระ อ่านเฉพาะคีย์ที่คุณรู้จักแล้วข้ามที่เหลือไป
order.failed
delivery เป็น null เพราะไม่มีอะไรถูกส่งมอบ ส่วน error มีค่า
{
"event": "order.failed",
"data": {
"order_id": "ord_456789abcdef0123456789ab",
"status": "failed",
"type": "account2",
"product_id": "exampleproduct02",
"price": 1290,
"charged": null,
"delivery": null,
"error": {
"code": "supplier_failed",
"message": "The order could not be placed with our supplier. Check the charged field on this order to see whether any payment was taken and refunded."
},
"created_at": "2026-09-07T05:01:12.000Z",
"updated_at": "2026-09-07T05:04:47.000Z"
}
}
⚠️ charged เป็น null เฉพาะในตัวอย่างนี้เท่านั้น ไม่ใช่คุณสมบัติของ event order.failed —
ออเดอร์ที่ล้มเหลวมี charged เป็นตัวเลขได้เหมือนกัน (เงินออกไปแล้วแล้วคืนกลับมา)
ให้อ่านจากฟิลด์เสมอ อย่าเดาจากชื่อ event หรือจากรหัส error
order.refunded
ออเดอร์ที่ส่งมอบไปแล้ว แล้วถูกคืนเงินภายหลัง
{
"event": "order.refunded",
"data": {
"order_id": "ord_cdef0123456789abcdef0123",
"status": "refunded",
"type": "cdkey",
"product_id": "exampleproduct01",
"price": 349,
"charged": { "amount": 349, "currency": "THB" },
"delivery": null,
"error": null,
"created_at": "2026-09-07T04:15:02.000Z",
"updated_at": "2026-09-08T09:12:30.000Z"
}
}
⚠️ delivery กลับไปเป็น null เมื่อออเดอร์ถูกคืนเงิน แม้ของจะเคยถูกส่งมอบไปจริงก่อนหน้านั้น —
ถ้าคุณต้องเก็บข้อมูลของที่ส่งมอบไว้ ให้บันทึกไว้ตั้งแต่ตอนได้รับ event order.completed
เพราะ event นี้ไม่ส่งของซ้ำมาให้ และการไปอ่านออเดอร์เองทีหลังก็ไม่เห็นเหมือนกัน
การตรวจลายเซ็น
ทุกคำขอมี header ชื่อ X-Naxset-Signature หน้าตาแบบนี้
X-Naxset-Signature: t=1788753600,v1=c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e
tคือเวลาที่เซ็น เป็น Unix timestamp หน่วยวินาทีv1คือhmac_sha256("<t>.<body>", secret)เข้ารหัสเป็น hex ตัวพิมพ์เล็ก 64 ตัวsecretคือกุญแจที่คุณได้รับตอนลงทะเบียน URL ที่หน้าตั้งค่า Webhook
t อยู่ ใน ข้อความที่เซ็น ไม่ได้แค่แปะมาข้าง ๆ คุณจึงปฏิเสธของเก่าหรือของที่ถูกสลับเวลาได้เอง
โดยไม่ต้องเชื่อค่าที่ไม่ได้ถูกเซ็น
ขั้นตอนที่แนะนำ
- อ่าน body ดิบ และค่า header ออกมา
- แกะ
tกับv1ออกจาก header - ปฏิเสธถ้า
tต่างจากเวลาปัจจุบันของคุณเกิน 5 นาที — ตรวจทั้งสองทิศ ทั้งเก่ากว่าและ ใหม่กว่า เพราะการส่งของเก่าซ้ำกับการสวมรอยส่งเวลาอนาคตเป็นการโจมตีแบบเดียวกันคนละทิศ - คำนวณ HMAC เองแล้วเทียบกับ
v1แบบ constant-time (crypto.timingSafeEqualหรือhmac.compare_digest) ห้ามเทียบด้วย===เพราะเวลาที่ใช้เทียบรั่วข้อมูลของกุญแจออกไป - ผ่านแล้วค่อย parse JSON — ไม่ผ่านให้ทิ้งคำขอนั้นไปเลย
ตัวอย่าง (Node.js)
import { createHmac, timingSafeEqual } from 'node:crypto'
const MAX_SKEW_MS = 5 * 60 * 1000
// rawBody = ไบต์ที่ได้รับมาดิบ ๆ (สตริงหรือ Buffer) ไม่ใช่ผลของ JSON.stringify()
export function verifyNaxsetSignature(rawBody, header, secret, nowMs = Date.now()) {
const fields = new Map()
for (const part of String(header ?? '').split(',')) {
const eq = part.indexOf('=')
if (eq !== -1) fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim())
}
const t = Number(fields.get('t'))
const v1 = fields.get('v1')
if (!Number.isFinite(t) || !v1) return false
if (Math.abs(nowMs - t * 1000) > MAX_SKEW_MS) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest()
const provided = Buffer.from(v1, 'hex')
// ความยาวต้องเท่ากันก่อนเสมอ — timingSafeEqual โยน error เมื่อยาวไม่เท่ากัน
return expected.length === provided.length && timingSafeEqual(expected, provided)
}
ชุดตัวเลขไว้ทดสอบโค้ดของคุณ
ค่าชุดนี้คำนวณด้วยสูตรเดียวกับของจริง ใช้ยืนยันว่าตัวตรวจฝั่งคุณทำงานถูกก่อนขึ้นระบบได้เลย
(body ในตัวอย่างถูกย่อให้สั้นเพื่อให้พิมพ์ตามได้ ของจริงเป็นข้อมูลออเดอร์เต็ม ๆ)
| ส่วน | ค่า |
|---|---|
secret | example_secret |
body | {"event":"order.completed","data":{"order_id":"ord_0123456789abcdef01234567"}} |
t | 1788753600 |
v1 | c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e |
ต้องเทียบเวลาด้วย t ของชุดทดสอบนี้ ไม่ใช่เวลาปัจจุบัน ไม่งั้นด่านหน้าต่างเวลาจะปฏิเสธก่อน
เมื่อยิงไม่สำเร็จ
นอกจากคำตอบที่ตารางสถานะไล่ไว้แล้ว ยังมีอีกกลุ่มหนึ่งที่นับเป็นความล้มเหลวเหมือนกัน คือ เราไม่ได้คำตอบเลย — ต่อไม่ติด · TLS พัง · หรือปลายทางไม่ตอบภายในเพดาน 10 วินาที
เรายิงซ้ำสูงสุด 5 ครั้ง ต่อหนึ่ง event เมื่อปลายทางไม่ตอบหรือตอบด้วยสถานะที่นับเป็นความ ล้มเหลว ระยะห่างระหว่างแต่ละครั้งเพิ่มขึ้นเรื่อย ๆ แต่ ⚠️ เราไม่รับประกันตารางเวลาที่ แน่นอน — ตัวเลขนาที/ชั่วโมงของแต่ละครั้งเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า อย่าเขียนโค้ดที่ คำนวณเวลาที่ event จะมาถึงจากจำนวนครั้งที่ผ่านมา
- เมื่อครบ 5 ครั้งแล้ว ไม่มีอะไรพา event นั้นกลับมาอีก และเราไม่มีเอนด์พอยต์ให้ยิงซ้ำเอง — ทางเดียวที่เหลือคืออ่านออเดอร์ตรง ๆ
- event ใหม่ของออเดอร์เดียวกันที่เกิดขึ้นทีหลัง เริ่มนับจำนวนครั้งใหม่ตั้งแต่ต้นเสมอ
⚠️ webhook ไม่ใช่แหล่งความจริง — ใบที่ยิงไม่ถึงจนครบจำนวนจะไม่ถูกยิงอีก ให้ poll
GET /api/v1/orders/{order_id} เป็นทางหลักเสมอ (ดูหัวข้อคำเตือนด้านบนเรื่องนี้)