Webhook และการส่งมอบของ

เมื่อขายสำเร็จ EZ2Pay จะส่ง POST ที่ลงนามแล้วไปยัง Webhook URL ของเกม บอกว่า SKU ไหนขายได้ ใครซื้อ และสำเร็จแล้ว

สิ่งที่ส่งมา

POST /ez2pay/webhook HTTP/1.1
Host: api.mygame.example
Content-Type: application/json
EZ2Pay-Timestamp: 1794200000
EZ2Pay-Signature: v1=b74045c949f1156897816ae225c88dc346d0c9028743493b164b959d5782f01a

{"order_id":"ord_00000000-0000-4000-8000-000000000001","sku":"gem_pack_100","buyer":"p_123","status":"success"}

มีข้อความอีก 2 แบบที่ห้ามให้ของ:

ข้อความbodyความหมาย
Ping{"type":"ping"}ทดสอบจากปุ่มทดสอบ Webhook ตรวจลายเซ็นแล้วตอบ 200
Voided"status":"voided"Order ถูกยกเลิก ตรวจลายเซ็น ไม่ให้ของ แล้วตอบ 200

ตรวจลายเซ็น

ใครก็เรียก URL ของคุณได้ ต้องตรวจทุกข้อความก่อนเชื่อ

  1. อ่าน raw body ก่อน ห้าม parse แล้วแปลงกลับ เพราะลายเซ็นครอบคลุมทุก byte
  2. คำนวณ HMAC-SHA256 ด้วย Webhook secret จาก {EZ2Pay-Timestamp}.{raw body} แล้วเขียนเป็นเลขฐานสิบหกตัวพิมพ์เล็ก
  3. เทียบ v1= กับค่านั้นกับ header EZ2Pay-Signature ด้วยวิธี timing-safe
  4. ปฏิเสธถ้า EZ2Pay-Timestamp ห่างจากเวลาปัจจุบันเกิน 300 วินาที
  5. ปฏิเสธ body ที่ยาวเกิน 16 KiB

Node

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody คือ Buffer หรือ string ที่เป็น body ดิบของ request
function verifyEz2paySignature(secret, timestamp, signature, rawBody, nowMs = Date.now()) {
  if (!timestamp || !/^[1-9]\d{8,11}$/.test(timestamp)) return false;
  if (!signature || !/^v1=[a-f0-9]{64}$/.test(signature)) return false;
  if (Math.abs(Math.floor(nowMs / 1000) - Number(timestamp)) > 300) return false;
  const digest = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  return timingSafeEqual(Buffer.from(signature), Buffer.from(`v1=${digest}`));
}

Python

import hashlib
import hmac
import re
import time


# raw_body คือ body ดิบของ request เป็น bytes
def verify_ez2pay_signature(secret, timestamp, signature, raw_body, now=None):
    if not timestamp or not re.fullmatch(r"[1-9][0-9]{8,11}", timestamp):
        return False
    if not signature or not re.fullmatch(r"v1=[a-f0-9]{64}", signature):
        return False
    current = int(time.time() if now is None else now)
    if abs(current - int(timestamp)) > 300:
        return False
    signed = timestamp.encode("ascii") + b"." + raw_body
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, "v1=" + digest)

ตรวจโค้ดของคุณในภาษาอื่น

โค้ดของคุณต้องได้ลายเซ็นนี้จากค่าเหล่านี้พอดี timestamp นี้เป็นค่าเก่าโดยตั้งใจ ตอนทดสอบให้คำนวณ HMAC ตรงๆ

secret:    ez2pay-docs-example-secret-not-a-real-one-00
timestamp: 1794200000
body:      {"order_id":"ord_00000000-0000-4000-8000-000000000001","sku":"gem_pack_100","buyer":"p_123","status":"success"}
signature: v1=b74045c949f1156897816ae225c88dc346d0c9028743493b164b959d5782f01a

ถามกลับก่อนให้ของ

ลายเซ็นที่ถูกต้องบอกแค่ว่า EZ2Pay แจ้งว่าขายได้ ก่อนให้ของต้องอ่าน Order:

curl https://api.ez2pay.gg/v1/orders/ord_00000000-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer $EZ2PAY_API_KEY"

ให้ของเมื่อครบทุกข้อนี้เท่านั้น:

  1. order_id, sku และ buyer ตรงกับ Webhook ทุกตัว
  2. status เป็น PAID, NOTIFYING, NOTIFIED, NOTIFY_FAILED หรือ DELIVERED
  3. มี paid_at และเป็นวันที่ที่ถูกต้อง
  4. currency เป็น THB และ amount เป็นสตริงทศนิยม 2 ตำแหน่ง

ถ้าข้อใดไม่ผ่าน ให้ตอบ 409 และห้ามให้ของ

ห้ามเทียบ amount กับราคาที่เก็บไว้ในเกมของคุณ เพราะ amount คือราคาเต็มที่ผู้เล่นจ่าย และราคาเปลี่ยนใน Dashboard ได้หลังจากเกมสร้างเสร็จแล้ว

ให้ของครั้งเดียว

Webhook อาจมาซ้ำ ต้องเก็บ order_id ใน transaction เดียวกับการให้ของ ห้ามเก็บสถานะในหน่วยความจำ

CREATE TABLE fulfillments (
  order_id   text PRIMARY KEY,
  sku        text NOT NULL,
  buyer      text NOT NULL,
  granted_at timestamptz NOT NULL DEFAULT now()
);
BEGIN;

INSERT INTO fulfillments (order_id, sku, buyer)
VALUES ($1, $2, $3)
ON CONFLICT (order_id) DO NOTHING
RETURNING order_id;

-- ได้แถวกลับมา 1 แถว: เป็นการส่งครั้งแรก ให้ของที่นี่ใน transaction เดียวกัน
-- ไม่ได้แถวกลับมา: เคยให้แล้ว อ่าน sku และ buyer ที่เก็บไว้
-- ถ้าไม่ตรงกับ event นี้ ให้ ROLLBACK แล้วตอบ error

COMMIT;

ห้ามลบหรือทำให้แถวเหล่านี้หมดอายุ

แจ้งส่งของแล้ว

หลังให้ของ รวมถึงกรณีที่เคยให้แล้ว ให้แจ้ง EZ2Pay:

curl -X POST https://api.ez2pay.gg/v1/orders/ord_00000000-0000-4000-8000-000000000001/delivered \
  -H "Authorization: Bearer $EZ2PAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
  • ใช้ได้เมื่อ Order เป็น NOTIFYING, NOTIFIED หรือ DELIVERED นอกนั้นได้ 409 ORDER_NOT_NOTIFIED
  • เรียกซ้ำได้
  • ห้ามเรียกก่อนให้ของจริง นี่คือหลักฐานการส่งมอบ

ตอบ HTTP อย่างไร

นับว่าสำเร็จเฉพาะ HTTP 200 ส่วน 201, 204, redirect หรือไม่ตอบภายใน 10 วินาที นับว่าไม่สำเร็จ

สถานการณ์ตอบ
ให้ของและแจ้งส่งของแล้ว หรือเคยให้แล้ว200
Ping หรือ voided ที่ลายเซ็นถูกต้อง200
ลายเซ็นหรือเวลาผิด401
ข้อความผิดรูปแบบ400
Order ยังไม่ยืนยันว่าจ่ายแล้ว409
เซิร์ฟเวอร์หรือฐานข้อมูลของคุณล้มเหลว503

เมื่อไม่สำเร็จ EZ2Pay จะลองใหม่หลัง 1 นาที 5 นาที 15 นาที 1 ชั่วโมง 6 ชั่วโมง และ 24 ชั่วโมง รวม 7 ครั้ง จากนั้น Order เป็น NOTIFY_FAILED และกดส่งซ้ำใน Dashboard ได้ ข้อความ voided ลองใหม่ตามรอบเดียวกัน แต่ Order ยังเป็น VOIDED และกดส่งซ้ำใน Dashboard ไม่ได้

ตอบ 200 เมื่อทุกอย่างเสร็จจริงเท่านั้น ถ้าแจ้งส่งของแล้วไม่สำเร็จ ให้ตอบ 503 รอบถัดไปจะเห็นว่าเคยให้ของแล้วและแจ้งซ้ำโดยไม่ให้ของเพิ่ม

เปลี่ยน Webhook secret

สร้าง secret ใหม่เมื่อทำหาย secret รั่ว หรือให้ AI สร้างเกมผ่าน MCP เพราะกรณีนี้ไม่มีใครเห็น secret

  1. เปิดหน้าเกมใน Dashboard กด สร้าง secret ใหม่ แล้วยืนยัน
  2. คัดลอก secret ใหม่ ระบบแสดงครั้งเดียว
  3. ใส่ในตัวแปรสภาพแวดล้อมของเซิร์ฟเวอร์ แล้วรีสตาร์ตหรือ deploy ใหม่

secret เดิมใช้ไม่ได้ทันทีที่กดยืนยัน Webhook ทุกครั้งหลังจากนั้นจะลงนามด้วย secret ใหม่ รวมถึง ping และการส่งซ้ำของการขายก่อนหน้า ระหว่างที่เซิร์ฟเวอร์ยังไม่เปลี่ยน เซิร์ฟเวอร์จะตอบ 401 และ EZ2Pay จะลองใหม่ตามรอบด้านบน จึงควรเปลี่ยนทันที

ทำได้เฉพาะคนใน Dashboard เครื่องมือ MCP และ AI อ่านหรือเปลี่ยน secret ไม่ได้

ต่อไป: เชื่อมต่อด้วย HTTP