คู่มือร้านค้า

คู่มือเว็บฮุก

หน้านี้สำหรับผู้ที่สร้างเซิร์ฟเวอร์รับเว็บฮุกของร้าน ทุกครั้งที่คำสั่งซื้อของร้านมีความเปลี่ยนแปลง MakerScapes จะส่งคำขอ HTTPS POST ไปยังปลายทางของร้าน พร้อมเนื้อหา JSON ที่อธิบายเหตุการณ์และคำสั่งซื้อนั้น เว็บฮุกมีไว้แจ้งให้ทราบเท่านั้น การรับงาน ตั้งราคา หรือจัดส่งคำสั่งซื้อ ร้านยังต้องทำผ่านแดชบอร์ดของร้านเหมือนเดิม

การรับเว็บฮุก

เมื่อปลายทางของคุณได้รับคำขอ ให้ทำดังนี้ทุกครั้ง

  1. อ่านเนื้อหาคำขอเป็นไบต์ดิบก่อนแปลงเป็น JSON ลายเซ็นครอบคลุมไบต์เหล่านั้นตรงตัว และการแปลง JSON แล้วแปลงกลับจะทำให้ไบต์เปลี่ยนไป
  2. ตรวจส่วนหัว MakerScapes-Signature ด้วยรหัสลับของปลายทาง และปฏิเสธคำขอหากไม่ตรงกัน
  3. หากประเภทเหตุการณ์เป็น ping ให้ตอบกลับโดยใช้รหัสท้าทายเป็นเนื้อหาของคำตอบ ดูหัวข้อการยืนยันปลายทางด้านล่าง
  4. ข้ามเหตุการณ์ที่มี id ซึ่งคุณประมวลผลไปแล้ว เพราะเหตุการณ์เดียวกันอาจมาถึงมากกว่าหนึ่งครั้ง
  5. ตอบกลับด้วยสถานะ 2xx ภายใน 10 วินาที แล้วค่อยทำงานที่ใช้เวลานานหลังตอบกลับ คำตอบแบบอื่นทั้งหมดนับเป็นความล้มเหลว และจะมีการส่งซ้ำ
  6. ตอบ 2xx กับประเภทเหตุการณ์ที่คุณไม่ได้จัดการด้วย ปลายทางที่รับทุกเหตุการณ์จะได้รับเหตุการณ์ที่เพิ่มเข้ามาหลังจากคุณเขียนโค้ดเสร็จแล้วด้วย

การตรวจลายเซ็น

ทุกการส่งมีส่วนหัว MakerScapes-Signature ลักษณะแบบนี้

t=1767225600,v1=a4632c787923f9111cc76c58beea59a7567d4444be1771f47ab0146983afc67c
  1. แยกค่าด้วยเครื่องหมายจุลภาค แล้วแยกแต่ละส่วนที่เครื่องหมายเท่ากับตัวแรก t คือเวลาที่ลงลายเซ็นเป็นวินาทีแบบ Unix ส่วน v1 คือลายเซ็นในรูปเลขฐานสิบหกตัวพิมพ์เล็ก ส่วนอื่นให้ข้ามไป
  2. สร้างข้อความที่ถูกลงลายเซ็น ได้แก่ ค่าของ t ตามด้วยจุด (.) แล้วตามด้วยเนื้อหาดิบ
  3. คำนวณ HMAC-SHA256 ของข้อความนั้น โดยใช้รหัสลับทั้งชุดในรูป UTF-8 เป็นคีย์ รวม whsec_ ด้วย แล้วเขียนผลเป็นเลขฐานสิบหกตัวพิมพ์เล็ก
  4. เปรียบเทียบผลกับ v1 แต่ละค่าด้วยวิธีที่ใช้เวลาคงที่ และยอมรับคำขอหากมีค่าใดตรงกัน การเปรียบเทียบสตริงแบบปกติจะเผยผ่านเวลาที่ใช้ว่าลายเซ็นปลอมถูกต้องไปแล้วกี่ส่วน
  5. ปฏิเสธคำขอหาก 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 ใหม่ การส่งที่ปลายทางปฏิเสธในระหว่างนั้นนับเป็นความล้มเหลว และจะถูกส่งซ้ำโดยลงลายเซ็นใหม่ทุกครั้ง การส่งที่ยังส่งซ้ำไม่ครบจึงจะมาถึงหลังจากคุณอัปเดตรหัสลับแล้ว