Callback ที่เราส่งไปหาคุณ

POSThttps://<your-endpoint>

เมื่อออเดอร์เดินมาถึงสถานะสุดท้าย (สำเร็จ · ล้มเหลว · ถูกคืนเงิน) เราจะยิง POST ไปที่ URL ที่คุณลงทะเบียนไว้ พร้อมข้อมูลออเดอร์เต็ม ๆ และลายเซ็นให้คุณตรวจว่าคำขอนั้นมาจากเราจริง · ปลายทางฝั่งคุณต้องทำสามอย่างตามลำดับนี้: (1) ตรวจลายเซ็นจากเนื้อคำขอดิบ ก่อนแกะ JSON → (2) ตอบ 2xx กลับมาให้เร็วที่สุด → (3) ค่อยเอาไปทำงานต่อ โดยกันการทำซ้ำด้วย order_id · ⚠️ callback เป็นตัวช่วยแจ้งเตือน ไม่ใช่แหล่งความจริง ระบบฝั่งคุณต้องทำงานถูกต้องได้แม้ callback ส่งไม่ถึงสักใบ (อ่านคำเตือนท้ายหน้า)

ตรวจลายเซ็นจากเนื้อคำขอดิบก่อน parse เสมอ

ข้อความที่ถูกเซ็นคือ t ต่อด้วยจุดหนึ่งตัว ต่อด้วย เนื้อคำขอทั้งก้อนตามไบต์ที่ได้รับมาห้าม parse แล้ว serialize ใหม่ JSON.parse() แล้ว JSON.stringify() กลับได้สตริงที่ต่างจากเดิม (ลำดับคีย์ ช่องว่าง การหนีอักขระ) แล้วลายเซ็นจะไม่ตรง ทุกคำขอ โดยที่ฝั่งเราไม่มีอะไรผิดเลย · เฟรมเวิร์กส่วนใหญ่ต้องตั้งค่าเพิ่มเพื่อเก็บ body ดิบไว้ (เช่นตัวเลือกที่ชื่อทำนอง raw body) ให้ทำก่อนเป็นอย่างแรก · เทียบ HMAC แบบ constant-time เสมอ ห้ามเทียบด้วยตัวดำเนินการเท่ากับ เพราะเวลาที่ใช้เทียบรั่วข้อมูลของกุญแจออกไป · อย่าเชื่อฟิลด์ในเนื้อ event ก่อนตรวจลายเซ็นผ่าน ใครก็ยิง POST ใส่ URL ของคุณได้

callback ไม่ใช่แหล่งความจริง — การอ่านออเดอร์ต่างหากที่ใช่

ระบบฝั่งคุณต้องทำงานถูกต้องได้ แม้ไม่เคยได้รับ callback เลยสักใบ · เราไม่รับประกันว่าจะส่งถึง ปลายทางที่ตอบไม่ได้จะถูกยิงซ้ำจำนวนจำกัดแล้วเลิก แล้ว event นั้นหายไปเลย และเราไม่มีเอนด์พอยต์ให้สั่งส่งซ้ำ · ได้รับซ้ำได้ event เดียวกันของออเดอร์เดียวกันมาถึงคุณได้มากกว่าหนึ่งครั้ง ให้ตัวรับฝั่งคุณกันการทำงานซ้ำด้วย order_id เสมอ — คำขอที่ซ้ำต้องไม่ทำให้คุณส่งของให้ลูกค้าปลายทางสองรอบ · ไม่รับประกันลำดับ และไม่ได้ส่งให้ทุกก้าว เราเก็บ event ค้างส่งได้ออเดอร์ละหนึ่งใบ ออเดอร์ที่สถานะเปลี่ยนซ้อนกันเร็ว ๆ (เช่นปิดจบแล้วถูกคืนเงินในนาทีเดียวกัน) คุณจะได้แค่ event ล่าสุดใบเดียว · วิธีใช้ที่ถูกคือ ใช้ callback เป็นสัญญาณกระตุ้นให้ไปอ่านออเดอร์ และมีตัวไล่อ่านออเดอร์ที่ยังไม่ปิดของตัวเองไว้เป็นตาข่ายรองเสมอ

ออเดอร์ที่จบในคำขอเดียว ก็ยังได้ callback ตามมาอีกหนึ่งใบ

ออเดอร์ที่ถูกสร้างและปิดจบภายในคำขอสั่งซื้อเดียว (ตอบ 201) ยังได้ callback ตามมาอีกหนึ่งใบอยู่ดี เป็นพฤติกรรมที่ตั้งใจ ไม่ใช่การส่งซ้ำโดยผิดพลาด · อย่าเขียนโค้ดที่ถือว่ามันผิดปกติ และอย่าตีความว่ามันเป็นสัญญาณว่ามีอะไรเปลี่ยนไปจากที่คำตอบตอนสั่งซื้อบอกไว้ · ตัวรับที่กันการทำงานซ้ำด้วย order_id อยู่แล้ว จัดการเคสนี้ได้เองโดยไม่ต้องทำอะไรเพิ่ม

ข้อมูลที่เราส่งมา

Request body

ชื่อชนิดวิธีใช้
eventบังคับสตริง
เหตุการณ์ที่ทำให้เรายิงคำขอนี้มา
รายละเอียดและข้อควรระวัง
  • วันนี้มีสามค่า: order.completed ปิดเรียบร้อย
  • order.failed ปิดด้วยความล้มเหลว
  • order.refunded ถูกคืนเงินภายหลัง
  • เราแจ้งเฉพาะตอนออเดอร์เข้าสู่สถานะสุดท้ายเท่านั้น สถานะระหว่างทางไม่มี event
  • ชื่อใหม่ถูกเพิ่มได้ในอนาคต ให้ปลายทางตอบ 2xx แล้วข้ามค่าที่ยังไม่รู้จักไป ไม่ใช่ตอบ error กลับมา
dataบังคับอ็อบเจกต์
ข้อมูลออเดอร์เต็ม ๆ ก้อนเดียวกันและรูปเดียวกันเป๊ะ กับที่ GET /api/v1/orders/{order_id} คืนมา
รายละเอียดและข้อควรระวัง
  • ฟิลด์ที่ต้องใช้ตัดสินใจอยู่ในตารางถัดไป ส่วนฟิลด์ที่เหลืออธิบายไว้ที่หน้าอ่านออเดอร์รายตัว (/developers/orders/get) จงใจไม่คัดลอกมาไว้สองที่ เพราะสำเนาที่สองคือที่ที่ลืมแก้ตาม

Header

ชื่อชนิดวิธีใช้
Content-Typeบังคับstring
application/json เสมอ — เนื้อคำขอเป็นก้อน JSON ก้อนเดียว ไม่ใช่ฟอร์มและไม่มีไฟล์แนบ
User-Agentบังคับstring
naxset-webhook/1 — ใช้แยกคำขอของเราออกจากทราฟฟิกอื่นในล็อกของคุณได้ · ห้ามใช้ยืนยันว่าคำขอมาจากเรา header ใครก็ปลอมได้ สิ่งเดียวที่ยืนยันตัวผู้ส่งได้คือลายเซ็น
X-Naxset-Signatureบังคับstring
ลายเซ็นของคำขอใบนี้ รูปแบบ t=<วินาที>,v1=<hex 64 ตัว>
รายละเอียดและข้อควรระวัง
  • ต้องตรวจให้ผ่านก่อนอ่านเนื้อข้างในทุกครั้ง เพราะใครก็ยิง POST ใส่ URL ของคุณได้
  • วิธีตรวจ โค้ดตัวอย่าง และชุดตัวเลขไว้ทดสอบอยู่ในหัวข้อ การตรวจลายเซ็น ท้ายหน้านี้

ฟิลด์ในออเดอร์ที่ต้องอ่าน

ชื่อชนิดวิธีใช้
data.order_idสตริง
รหัสออเดอร์ฝั่งเรา · ใช้เป็นกุญแจกันทำงานซ้ำของตัวรับฝั่งคุณเสมอ — event เดียวกันของออเดอร์เดียวกันมาถึงคุณได้มากกว่าหนึ่งครั้ง ถ้าไม่กันไว้ คุณอาจส่งของให้ลูกค้าปลายทางสองรอบ
data.statusสตริง
สถานะสุดท้ายของออเดอร์
รายละเอียดและข้อควรระวัง
  • เป็นคู่กับ event เสมอ (order.completed คู่กับ completed
  • order.failed คู่กับ failed
  • order.refunded คู่กับ refunded) จะแตกสาขาด้วยตัวไหนก็ได้ ขอให้เลือกอย่างเดียวแล้วใช้ตัวนั้นตลอด
data.typeสตริง
ประเภทของสินค้าในออเดอร์นี้ · เป็นตัวเดียวที่บอกว่า delivery จะมาในรูปไหน ห้ามเดาจากเนื้อข้างใน
data.deliveryอ็อบเจกต์ หรือ null
ของที่ส่งมอบ
รายละเอียดและข้อควรระวัง
  • มีค่าเฉพาะตอน status เป็น completed เท่านั้น นอกนั้นเป็น null เสมอ
  • มีสองรูปที่ไม่มีฟิลด์ร่วมกันเลยสักตัว
  • สินค้าประเภทคีย์ได้รูปที่มี keys ส่วนบัญชีมือสองได้รูปที่มี login/password/email/email_password/extra
data.errorอ็อบเจกต์ หรือ null
สาเหตุที่ออเดอร์ล้มเหลว มี code กับ message
รายละเอียดและข้อควรระวัง
  • มีค่าเฉพาะ event order.failed เท่านั้น
  • ให้แตกสาขาด้วย code ห้ามแมตช์ข้อความใน message ถ้อยคำเปลี่ยนได้ตลอดโดยที่รหัสยังเหมือนเดิม
data.chargedอ็อบเจกต์ หรือ null
ยอดที่ถูกหักจากกระเป๋าของคุณจริง มี amount กับ currency
รายละเอียดและข้อควรระวัง
  • นี่คือคำตอบเดียวของคำถามว่าออเดอร์นี้จ่ายไปหรือยัง อย่าเดาจากชื่อ event หรือจากรหัสใน error
  • ออเดอร์ที่ล้มเหลวมี charged เป็นตัวเลขได้ และออเดอร์ที่ถูกคืนเงินก็ยังรายงานยอดที่เคยถูกหักไปเหมือนเดิม เพราะมันตอบว่า "ถูกหักไปเท่าไหร่" ไม่ได้ตอบว่า "ตอนนี้คุณเสียเงินอยู่เท่าไหร่"

คำตอบที่ปลายทางของคุณต้องให้

สถานะรหัสความหมาย
200
2xx — เราถือว่าส่งสำเร็จ จบแค่นี้ ไม่ยิงซ้ำ
เรานับว่าสำเร็จทันทีที่ได้สถานะในช่วง 200-299 และ ไม่อ่านเนื้อคำตอบของคุณเลยสักไบต์ จะตอบ body ว่าอะไรก็ได้
รายละเอียดและข้อควรระวัง
  • ให้ตอบกลับให้เร็วที่สุด แล้วค่อยไปทำงานหนักทีหลังแบบเบื้องหลัง เพราะเรารอคำตอบไม่เกิน 10 วินาทีต่อการยิงหนึ่งครั้ง
  • ตอบช้ากว่านั้นเราจะนับเป็นล้มเหลวแล้วยิงซ้ำ ทั้งที่คุณรับ event ไปเรียบร้อยแล้ว
302
3xx — นับเป็นความล้มเหลว แล้วลองใหม่
เราไม่ตาม redirect ไม่ว่ามันจะชี้ไปที่ไหนก็ตาม
รายละเอียดและข้อควรระวัง
  • ปลายทางจริงจึงยังไม่ได้รับ event นั้น และเราจะยิงซ้ำเหมือนความล้มเหลวแบบอื่น (ดูหัวข้อ เมื่อยิงไม่สำเร็จ ท้ายหน้า)
  • ให้ลงทะเบียน URL ตัวจริงไปตั้งแต่แรก อย่าลงทะเบียน URL ที่เด้งต่อ
408
408 หรือ 429 — ล้มเหลวชั่วคราว แล้วลองใหม่
สองตัวนี้เป็นข้อยกเว้นเดียวของกฎ 4xx แถวถัดไป เพราะเป็นคำตอบที่ปลายทาง ตั้งใจ บอกให้เรากลับมาใหม่ ไม่ใช่คำตอบว่าเราตั้งค่าผิด
404
4xx อื่น ๆ — เลิกทันที ไม่ยิงซ้ำอีกเลย
เช่น 400
รายละเอียดและข้อควรระวัง
  • 401
  • 403
  • 404
  • 410
  • ปลายทางที่ตอบแบบนี้คือปลายทางที่ตั้งค่าผิด ยิงซ้ำอีกกี่ครั้งก็ไม่ทำให้ถูกขึ้น (และถ้า URL พิมพ์ผิด ก็เท่ากับเรายิงใส่เซิร์ฟเวอร์ของคนอื่นฟรี ๆ)
  • ⚠️ event นั้นหายไปเลย ไม่มีอะไรพากลับมา และเราไม่มีเอนด์พอยต์ให้สั่งส่งซ้ำ ทางเดียวที่เหลือคือคุณไปอ่านออเดอร์เอง
500
5xx — ล้มเหลว แล้วลองใหม่
เราถือว่าเป็นปัญหาชั่วคราวฝั่งคุณ แล้วยิงซ้ำให้ตามนโยบายในหัวข้อ เมื่อยิงไม่สำเร็จ ท้ายหน้า เหมือนกับความล้มเหลวแบบอื่น

รูปของ 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 อยู่ ใน ข้อความที่เซ็น ไม่ได้แค่แปะมาข้าง ๆ คุณจึงปฏิเสธของเก่าหรือของที่ถูกสลับเวลาได้เอง โดยไม่ต้องเชื่อค่าที่ไม่ได้ถูกเซ็น

ขั้นตอนที่แนะนำ

  1. อ่าน body ดิบ และค่า header ออกมา
  2. แกะ t กับ v1 ออกจาก header
  3. ปฏิเสธถ้า t ต่างจากเวลาปัจจุบันของคุณเกิน 5 นาที — ตรวจทั้งสองทิศ ทั้งเก่ากว่าและ ใหม่กว่า เพราะการส่งของเก่าซ้ำกับการสวมรอยส่งเวลาอนาคตเป็นการโจมตีแบบเดียวกันคนละทิศ
  4. คำนวณ HMAC เองแล้วเทียบกับ v1 แบบ constant-time (crypto.timingSafeEqual หรือ hmac.compare_digest) ห้ามเทียบด้วย === เพราะเวลาที่ใช้เทียบรั่วข้อมูลของกุญแจออกไป
  5. ผ่านแล้วค่อย 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 ในตัวอย่างถูกย่อให้สั้นเพื่อให้พิมพ์ตามได้ ของจริงเป็นข้อมูลออเดอร์เต็ม ๆ)

ส่วนค่า
secretexample_secret
body{"event":"order.completed","data":{"order_id":"ord_0123456789abcdef01234567"}}
t1788753600
v1c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e

ต้องเทียบเวลาด้วย t ของชุดทดสอบนี้ ไม่ใช่เวลาปัจจุบัน ไม่งั้นด่านหน้าต่างเวลาจะปฏิเสธก่อน

เมื่อยิงไม่สำเร็จ

นอกจากคำตอบที่ตารางสถานะไล่ไว้แล้ว ยังมีอีกกลุ่มหนึ่งที่นับเป็นความล้มเหลวเหมือนกัน คือ เราไม่ได้คำตอบเลย — ต่อไม่ติด · TLS พัง · หรือปลายทางไม่ตอบภายในเพดาน 10 วินาที

เรายิงซ้ำสูงสุด 5 ครั้ง ต่อหนึ่ง event เมื่อปลายทางไม่ตอบหรือตอบด้วยสถานะที่นับเป็นความ ล้มเหลว ระยะห่างระหว่างแต่ละครั้งเพิ่มขึ้นเรื่อย ๆ แต่ ⚠️ เราไม่รับประกันตารางเวลาที่ แน่นอน — ตัวเลขนาที/ชั่วโมงของแต่ละครั้งเปลี่ยนแปลงได้โดยไม่แจ้งล่วงหน้า อย่าเขียนโค้ดที่ คำนวณเวลาที่ event จะมาถึงจากจำนวนครั้งที่ผ่านมา

  • เมื่อครบ 5 ครั้งแล้ว ไม่มีอะไรพา event นั้นกลับมาอีก และเราไม่มีเอนด์พอยต์ให้ยิงซ้ำเอง — ทางเดียวที่เหลือคืออ่านออเดอร์ตรง ๆ
  • event ใหม่ของออเดอร์เดียวกันที่เกิดขึ้นทีหลัง เริ่มนับจำนวนครั้งใหม่ตั้งแต่ต้นเสมอ

⚠️ webhook ไม่ใช่แหล่งความจริง — ใบที่ยิงไม่ถึงจนครบจำนวนจะไม่ถูกยิงอีก ให้ poll GET /api/v1/orders/{order_id} เป็นทางหลักเสมอ (ดูหัวข้อคำเตือนด้านบนเรื่องนี้)

อัปเดตล่าสุด 2026-09-08