คู่มือเว็บฮุก
หน้านี้สำหรับผู้ที่สร้างเซิร์ฟเวอร์รับเว็บฮุกของร้าน ทุกครั้งที่คำสั่งซื้อของร้านมีความเปลี่ยนแปลง MakerScapes จะส่งคำขอ HTTPS POST ไปยังปลายทางของร้าน พร้อมเนื้อหา JSON ที่อธิบายเหตุการณ์และคำสั่งซื้อนั้น เว็บฮุกมีไว้แจ้งให้ทราบเท่านั้น การรับงาน ตั้งราคา หรือจัดส่งคำสั่งซื้อ ร้านยังต้องทำผ่านแดชบอร์ดของร้านเหมือนเดิม
การรับเว็บฮุก
เมื่อปลายทางของคุณได้รับคำขอ ให้ทำดังนี้ทุกครั้ง
- อ่านเนื้อหาคำขอเป็นไบต์ดิบก่อนแปลงเป็น JSON ลายเซ็นครอบคลุมไบต์เหล่านั้นตรงตัว และการแปลง JSON แล้วแปลงกลับจะทำให้ไบต์เปลี่ยนไป
- ตรวจส่วนหัว MakerScapes-Signature ด้วยรหัสลับของปลายทาง และปฏิเสธคำขอหากไม่ตรงกัน
- หากประเภทเหตุการณ์เป็น ping ให้ตอบกลับโดยใช้รหัสท้าทายเป็นเนื้อหาของคำตอบ ดูหัวข้อการยืนยันปลายทางด้านล่าง
- ข้ามเหตุการณ์ที่มี id ซึ่งคุณประมวลผลไปแล้ว เพราะเหตุการณ์เดียวกันอาจมาถึงมากกว่าหนึ่งครั้ง
- ตอบกลับด้วยสถานะ 2xx ภายใน 10 วินาที แล้วค่อยทำงานที่ใช้เวลานานหลังตอบกลับ คำตอบแบบอื่นทั้งหมดนับเป็นความล้มเหลว และจะมีการส่งซ้ำ
- ตอบ 2xx กับประเภทเหตุการณ์ที่คุณไม่ได้จัดการด้วย ปลายทางที่รับทุกเหตุการณ์จะได้รับเหตุการณ์ที่เพิ่มเข้ามาหลังจากคุณเขียนโค้ดเสร็จแล้วด้วย
การตรวจลายเซ็น
ทุกการส่งมีส่วนหัว MakerScapes-Signature ลักษณะแบบนี้
t=1767225600,v1=a4632c787923f9111cc76c58beea59a7567d4444be1771f47ab0146983afc67c - แยกค่าด้วยเครื่องหมายจุลภาค แล้วแยกแต่ละส่วนที่เครื่องหมายเท่ากับตัวแรก t คือเวลาที่ลงลายเซ็นเป็นวินาทีแบบ Unix ส่วน v1 คือลายเซ็นในรูปเลขฐานสิบหกตัวพิมพ์เล็ก ส่วนอื่นให้ข้ามไป
- สร้างข้อความที่ถูกลงลายเซ็น ได้แก่ ค่าของ t ตามด้วยจุด (.) แล้วตามด้วยเนื้อหาดิบ
- คำนวณ HMAC-SHA256 ของข้อความนั้น โดยใช้รหัสลับทั้งชุดในรูป UTF-8 เป็นคีย์ รวม whsec_ ด้วย แล้วเขียนผลเป็นเลขฐานสิบหกตัวพิมพ์เล็ก
- เปรียบเทียบผลกับ v1 แต่ละค่าด้วยวิธีที่ใช้เวลาคงที่ และยอมรับคำขอหากมีค่าใดตรงกัน การเปรียบเทียบสตริงแบบปกติจะเผยผ่านเวลาที่ใช้ว่าลายเซ็นปลอมถูกต้องไปแล้วกี่ส่วน
- ปฏิเสธคำขอหาก t ห่างจากนาฬิกาของคุณเกิน 300 วินาที ทุกครั้งที่ส่ง รวมถึงการส่งซ้ำ จะลงลายเซ็นใหม่ตอนส่ง การส่งจริงจึงมีเวลาใกล้ปัจจุบันเสมอ
ส่วนหัวของคำขอ
ทุกการส่งเป็นคำขอ POST ที่มีส่วนหัวต่อไปนี้ ค่าที่แสดงคือค่าของชุดค่าทดสอบ
| ส่วนหัว | ค่า |
|---|---|
| content-type | application/json |
| user-agent | MakerScapes-Webhooks/1 |
| MakerScapes-Event-Id | 00000000-0000-4000-8000-000000000755 |
| MakerScapes-Event | order.paid |
| MakerScapes-Signature | t=1767225600,v1=a4632c787923f9111cc76c58beea59a7567d4444be1771f47ab0146983afc67c |
ตัวอย่างโค้ด
ตัวอย่าง Node.js ถูกรันกับชุดค่าทดสอบด้านล่างในการทดสอบอัตโนมัติของเราเอง ส่วนตัวอย่าง Python และ PHP ทำตามขั้นตอนเดียวกัน
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
/** How far the signature's timestamp may be from your clock, in seconds. */
export const TOLERANCE_SECONDS = 300;
/**
* Whether a webhook delivery really came from MakerScapes.
*
* @param {Buffer | string} rawBody The request body exactly as it arrived, before
* any JSON parsing. With Express: express.raw({ type: 'application/json' }).
* @param {string | undefined} header The value of the MakerScapes-Signature header.
* @param {string} secret The endpoint's signing secret, whsec_ prefix included.
* @param {number} [now] The current time in Unix seconds.
* @returns {boolean}
*/
export function verifyMakerScapesSignature(rawBody, header, secret, now = Date.now() / 1000) {
const fields = String(header ?? '')
.split(',')
.map((field) => {
const at = field.indexOf('=');
return at < 0 ? [field, ''] : [field.slice(0, at), field.slice(at + 1)];
});
const t = fields.find(([key]) => key === 't')?.[1] ?? '';
if (!/^\d+$/.test(t)) return false;
if (Math.abs(now - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return fields
.filter(([key]) => key === 'v1')
.some(([, signature]) => {
if (!/^[0-9a-f]+$/.test(signature)) return false;
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_makerscapes_signature(raw_body, header, secret, now=None):
"""raw_body: the request body as bytes, before any JSON parsing
(Flask: request.get_data()). header: the MakerScapes-Signature header.
secret: the endpoint's signing secret, whsec_ prefix included.
now: the current time in Unix seconds."""
fields = [field.split("=", 1) for field in (header or "").split(",") if "=" in field]
t = next((value for key, value in fields if key == "t"), "")
if not (t.isascii() and t.isdigit()):
return False
if abs((time.time() if now is None else now) - int(t)) > TOLERANCE_SECONDS:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(
hmac.compare_digest(expected.encode(), value.encode()) for key, value in fields if key == "v1"
)
PHP
<?php
const MAKERSCAPES_TOLERANCE_SECONDS = 300;
// $rawBody: file_get_contents('php://input'), before any JSON decoding.
// $header: $_SERVER['HTTP_MAKERSCAPES_SIGNATURE'] ?? ''
// $secret: the endpoint's signing secret, whsec_ prefix included.
// $now: the current time in Unix seconds.
function verify_makerscapes_signature(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
$t = null;
$signatures = [];
foreach (explode(',', $header) as $field) {
[$key, $value] = array_pad(explode('=', $field, 2), 2, '');
if ($key === 't' && $t === null) $t = $value;
if ($key === 'v1') $signatures[] = $value;
}
if ($t === null || !ctype_digit($t)) return false;
if (abs(($now ?? time()) - (int) $t) > MAKERSCAPES_TOLERANCE_SECONDS) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}
ชุดค่าทดสอบ
ใช้ค่าเหล่านี้ตรวจโค้ดของคุณก่อนรับการส่งจริงครั้งแรก ค่าเหล่านี้ลงลายเซ็นด้วยโค้ดเดียวกับที่ลงลายเซ็นการส่งจริง รหัสลับนี้เป็นเพียงตัวอย่าง ไม่มีปลายทางใดใช้รหัสนี้
- รหัสลับ
whsec_example_secret_for_the_test_vector_only- เวลา (t)
1767225600- เนื้อหา
{"id":"00000000-0000-4000-8000-000000000755","type":"order.paid","api_version":"v1","occurred_at":"2026-01-01T00:00:00.000Z","shop_id":"00000000-0000-4000-8000-000000000001","data":{"order":{"id":"00000000-0000-4000-8000-000000000753","kind":"3d-print","status":"awaiting_accept","commission_rate_bp":1200,"setup_satang":0,"payment_received":true,"receipt":{"audience":"seller","total_satang":19000,"commission_satang":2280,"payout_satang":16720,"shop_funded_discount_satang":null,"list_total_satang":null,"processing_fee":null},"delivery":{"name":"Sample courier","kind":"delivery","address":["Sample Recipient (ตัวอย่าง)","0000000000","1 Sample Road (not a real address)","Sample Subdistrict, Sample District","Bangkok 10000","ไทย"],"price_satang":5000,"lead_time_hours":48},"lines":[{"id":"00000000-0000-4000-8000-000000000003","position":0,"quantity":2,"quoted_grams":41.5,"subtotal_satang":12000,"filament":{"name":"Sample PLA Red","family":"PLA","color_hex":"#ff0000"}}]}}}- ส่วนหัว MakerScapes-Signature
t=1767225600,v1=a4632c787923f9111cc76c58beea59a7567d4444be1771f47ab0146983afc67c
เนื้อหานี้คือไบต์ที่ส่งจริงตรงตัวในบรรทัดเดียว และมีข้อความภาษาไทย จึงต้องอ่านเป็น UTF-8 เวลาของชุดค่าทดสอบเป็นเวลาในอดีต ตอนทดสอบให้ส่งเวลานั้นเข้าไปเป็นเวลาปัจจุบัน ไม่เช่นนั้นโค้ดของคุณจะปฏิเสธเพราะเก่าเกินไป ซึ่งก็ถูกต้องแล้ว
การยืนยันปลายทาง
ปลายทางจะยังไม่ได้รับเหตุการณ์ใดจนกว่าจะได้รับการยืนยัน การยืนยันทำได้โดยให้เจ้าของร้านส่งการทดสอบจากหน้านักพัฒนา การทดสอบคือเหตุการณ์ ping ที่มีคำสั่งซื้อจำลองซึ่งไม่มีข้อมูลของผู้ซื้อจริง และมีรหัสท้าทายที่สุ่มขึ้น
ตอบกลับด้วยสถานะ 2xx และเนื้อหาคำตอบต้องเป็นรหัสท้าทายตรงตัว ไม่ห่อเป็น JSON ไม่ใส่เครื่องหมายคำพูด และไม่เติมช่องว่างหรือขึ้นบรรทัดใหม่ การตอบ 2xx อย่างเดียวไม่ถือว่ายืนยันปลายทาง การทดสอบลงลายเซ็นเหมือนการส่งทั่วไป จึงควรตรวจลายเซ็นด้วยเช่นกัน
ตัวอย่างการทดสอบ โดยละส่วนคำสั่งซื้อไว้
{
"id": "00000000-0000-4000-8000-000000000755",
"type": "ping",
"api_version": "v1",
"occurred_at": "2026-01-01T00:00:00.000Z",
"shop_id": "00000000-0000-4000-8000-000000000001",
"challenge": "example-challenge-echo-this-value-exactly"
} การเปลี่ยน URL ของปลายทางจะปิดปลายทางและทำให้กลับไปเป็นยังไม่ได้รับการยืนยัน ต้องผ่านการทดสอบใหม่ก่อนจึงจะเปิดใช้อีกครั้งได้
การส่งซ้ำ เหตุการณ์ซ้ำ และลำดับ
การส่งที่ถือว่าสำเร็จ
การส่งจะสำเร็จก็ต่อเมื่อปลายทางตอบกลับด้วยสถานะ 2xx ภายใน 10 วินาทีเท่านั้น สถานะ 3xx นับเป็นความล้มเหลว เพราะระบบไม่ตามการเปลี่ยนเส้นทาง สถานะ 4xx หรือ 5xx การหมดเวลา และการเชื่อมต่อไม่ได้ ก็นับเป็นความล้มเหลวเช่นกัน ปลายทางต้องเป็น URL แบบ HTTPS ที่เข้าถึงได้จากอินเทอร์เน็ต
การส่งที่ล้มเหลวจะถูกส่งซ้ำ รวมทั้งหมดไม่เกิน 8 ครั้ง โดยเว้นระยะ 1 5 30 60 180 420 และ720 นาที รวมประมาณ 24 ชั่วโมง หลังครั้งสุดท้าย การส่งจะถูกระบุว่าล้มเหลว และเจ้าของร้านสั่งส่งซ้ำได้จากหน้านักพัฒนา ตราบที่ระบบยังเก็บเนื้อหาของการส่งนั้นไว้
ปลายทางที่ล้มเหลวต่อเนื่องจะถูกปิด และเจ้าของร้านจะได้รับแจ้ง เหตุการณ์ที่เกิดขึ้นระหว่างที่ปิดอยู่จะไม่ถูกส่งไปยังปลายทางนั้น ส่วนการส่งที่รออยู่แล้วจะถูกพักไว้จนกว่าจะเปิดใช้อีกครั้ง ก่อนเปิดใช้ เจ้าของร้านต้องผ่านการทดสอบใหม่
แต่ละเหตุการณ์มาถึงอย่างน้อยหนึ่งครั้ง
การส่งซ้ำ หรือคำตอบที่หายระหว่างทางกลับมาหาเรา ทำให้เหตุการณ์เดียวกันอาจมาถึงคุณมากกว่าหนึ่งครั้ง ทุกสำเนามี id เดียวกัน ทั้งในฟิลด์ id ของเนื้อหาและในส่วนหัว MakerScapes-Event-Id และมีเนื้อหาเหมือนกัน ให้บันทึก id ที่ประมวลผลแล้ว และข้ามเหตุการณ์ที่เคยเห็น อย่าใช้ลายเซ็นตัดเหตุการณ์ซ้ำ เพราะลายเซ็นเปลี่ยนทุกครั้งที่ส่ง
เหตุการณ์อาจมาไม่ตามลำดับ
เหตุการณ์อาจมาถึงไม่ตรงกับลำดับที่เกิดขึ้นจริง เช่น เมื่อเหตุการณ์หนึ่งต้องส่งซ้ำ แต่เหตุการณ์ที่เกิดทีหลังส่งสำเร็จตั้งแต่ครั้งแรก ให้เรียงเหตุการณ์ของคำสั่งซื้อเดียวกันตาม occurred_at และหากเวลาตรงกัน ให้ดูจากสถานะที่ data.order ระบุ และอย่าให้เหตุการณ์ที่เกิดก่อนเขียนทับข้อมูลจากเหตุการณ์ที่เกิดทีหลัง
เหตุการณ์
ชื่อเหตุการณ์อยู่ในฟิลด์ type ของเนื้อหาและในส่วนหัว MakerScapes-Event ปลายทางเลือกรับบางเหตุการณ์หรือทุกเหตุการณ์ก็ได้ ทุกเหตุการณ์หมายถึงทุกเหตุการณ์จริง ๆ รวมถึงเหตุการณ์ที่จะเพิ่มเข้ามาในอนาคตด้วย
| เหตุการณ์ | ส่งเมื่อ |
|---|---|
| order.quote_requested | ผู้ซื้อสั่งงานที่ร้านต้องเสนอราคาก่อน ผู้ซื้อจึงจะชำระเงินได้ |
| order.priced | ร้านเสนอราคาให้คำสั่งซื้อที่รอราคาอยู่ |
| order.paid | ผู้ซื้อชำระเงินแล้ว คำสั่งซื้อกำลังรอร้านรับงาน |
| order.accepted | ร้านรับงานคำสั่งซื้อนี้แล้ว |
| order.shipped | ร้านระบุว่าจัดส่งคำสั่งซื้อแล้ว |
| order.delivered | คำสั่งซื้อถูกระบุว่าส่งถึงแล้ว |
| order.declined | ร้านปฏิเสธคำสั่งซื้อ |
| order.abandoned | MakerScapes ยุติคำสั่งซื้อก่อนที่งานจะเสร็จสมบูรณ์ |
| order.expired | คำสั่งซื้อเลยกำหนดเวลา data.window บอกว่าเป็นกำหนดใด: pricing, payment หรือ accept |
| order.cancelled | ผู้ซื้อยกเลิกคำสั่งซื้อ |
| order.dispute_opened | ผู้ซื้อเปิดข้อพิพาทเกี่ยวกับคำสั่งซื้อ |
| order.dispute_resolved | MakerScapes ตัดสินข้อพิพาทของคำสั่งซื้อแล้ว |
| order.refunded | คืนเงินให้ผู้ซื้อแล้ว ทั้งหมดหรือบางส่วน |
เนื้อหาที่ส่ง
ทุกเหตุการณ์ใช้โครงสร้างภายนอกแบบเดียวกัน ดังนี้
| ฟิลด์ | ความหมาย |
|---|---|
| id | รหัสของเหตุการณ์ เหมือนเดิมทุกครั้งที่ส่ง ใช้ค่านี้ตัดเหตุการณ์ซ้ำ |
| type | ชื่อเหตุการณ์ ตามตารางด้านบน |
| api_version | เวอร์ชันของรูปแบบนี้ เมื่อรูปแบบจำเป็นต้องเปลี่ยน จะเปลี่ยนในเวอร์ชันใหม่สำหรับปลายทางที่ขอใช้เท่านั้น ให้ข้ามฟิลด์ที่ไม่รู้จัก |
| occurred_at | เวลาที่เหตุการณ์เกิดขึ้น เป็นเวลา UTC ในรูปแบบ ISO 8601 |
| shop_id | รหัสของร้านเจ้าของคำสั่งซื้อ |
| data.order | คำสั่งซื้อตามที่แดชบอร์ดของร้านแสดง ณ เวลาที่เกิดเหตุการณ์ |
| data.window | มีเฉพาะใน order.expired บอกว่ากำหนดเวลาใดหมดลง |
ฟิลด์ที่ลงท้ายด้วย _satang เป็นจำนวนเต็มในหน่วยสตางค์ 100 สตางค์เท่ากับ 1 บาท ฟิลด์ที่ลงท้ายด้วย _bp เป็นหน่วยเบสิสพอยต์ 100 เท่ากับ 1%
ข้อความในเนื้อหา เช่น ชื่อประเทศในที่อยู่จัดส่ง เขียนเป็นภาษาไทยเสมอ ไม่ว่าร้านจะใช้ภาษาใด
ตัวอย่างเหตุการณ์ order.paid ของคำสั่งซื้อจำลองแบบเดียวกับที่การทดสอบใช้ จัดรูปแบบให้อ่านง่าย
{
"id": "00000000-0000-4000-8000-000000000755",
"type": "order.paid",
"api_version": "v1",
"occurred_at": "2026-01-01T00:00:00.000Z",
"shop_id": "00000000-0000-4000-8000-000000000001",
"data": {
"order": {
"id": "00000000-0000-4000-8000-000000000753",
"kind": "3d-print",
"status": "awaiting_accept",
"commission_rate_bp": 1200,
"setup_satang": 0,
"payment_received": true,
"receipt": {
"audience": "seller",
"total_satang": 19000,
"commission_satang": 2280,
"payout_satang": 16720,
"shop_funded_discount_satang": null,
"list_total_satang": null,
"processing_fee": null
},
"delivery": {
"name": "Sample courier",
"kind": "delivery",
"address": [
"Sample Recipient (ตัวอย่าง)",
"0000000000",
"1 Sample Road (not a real address)",
"Sample Subdistrict, Sample District",
"Bangkok 10000",
"ไทย"
],
"price_satang": 5000,
"lead_time_hours": 48
},
"lines": [
{
"id": "00000000-0000-4000-8000-000000000003",
"position": 0,
"quantity": 2,
"quoted_grams": 41.5,
"subtotal_satang": 12000,
"filament": {
"name": "Sample PLA Red",
"family": "PLA",
"color_hex": "#ff0000"
}
}
]
}
}
} คำแสดงสถานะคำสั่งซื้อ
data.order.status เป็นหนึ่งในคำเหล่านี้ คอลัมน์ที่สองคือสิ่งที่แดชบอร์ดของร้านแสดงสำหรับสถานะนั้น คำเหล่านี้คงที่ การปรับถ้อยคำในแดชบอร์ดจะไม่ทำให้คำเหล่านี้เปลี่ยน
| ค่า status | แสดงในแดชบอร์ดว่า |
|---|---|
| awaiting_payment | รอผู้ซื้อชำระเงิน |
| awaiting_price | รอให้คุณตั้งราคา |
| awaiting_accept | รอให้คุณกดรับงาน |
| accepted | รับงานแล้ว |
| printing | กำลังพิมพ์ |
| shipped | จัดส่งแล้ว |
| delivered | ผู้ซื้อได้รับแล้ว |
| refunded | คืนเงินแล้ว |
| payment_expired | ไม่ได้ชำระเงินในเวลาที่กำหนด |
| cancelled | ผู้ซื้อยกเลิก |
| quote_expired | หมดเวลาตั้งราคา |
| declined | คุณปฏิเสธงานนี้ |
| abandoned | MakerScapes ยุติรายการนี้ |
รหัสลับสำหรับลงลายเซ็น
แต่ละปลายทางมีรหัสลับของตัวเอง ขึ้นต้นด้วย whsec_ เจ้าของร้านคัดลอกได้จากหน้านักพัฒนา ให้เก็บไว้ในการตั้งค่าของเซิร์ฟเวอร์ ไม่ใส่ไว้ในโค้ดหรือในล็อก
เจ้าของร้านเปลี่ยนรหัสลับได้ และการเปลี่ยนมีผลทันที นับจากนั้นการส่งจะลงลายเซ็นด้วยรหัสใหม่เท่านั้น ไม่มีช่วงที่ใช้ได้ทั้งสองรหัส จึงควรอ่านรหัสลับจากการตั้งค่าที่เปลี่ยนได้โดยไม่ต้อง deploy ใหม่ การส่งที่ปลายทางปฏิเสธในระหว่างนั้นนับเป็นความล้มเหลว และจะถูกส่งซ้ำโดยลงลายเซ็นใหม่ทุกครั้ง การส่งที่ยังส่งซ้ำไม่ครบจึงจะมาถึงหลังจากคุณอัปเดตรหัสลับแล้ว