เชื่อมต่อด้วย HTTP
ไม่ต้องติดตั้งไลบรารีของ EZ2Pay เซิร์ฟเวอร์ของคุณเรียก HTTPS 3 ครั้ง และรับ Webhook 1 จุด ใช้ภาษาใดก็ได้
| เซิร์ฟเวอร์ของคุณ | Request | เมื่อไหร่ |
|---|---|---|
| สร้าง Order | POST /v1/orders | ผู้เล่นกดซื้อ |
| รับ Webhook | POST มาที่ URL ของคุณ | EZ2Pay ยืนยันการขาย |
| อ่าน Order | GET /v1/orders/{id} | ก่อนให้ของทุกครั้ง |
| แจ้งส่งของแล้ว | POST /v1/orders/{id}/delivered | หลังให้ของ |
Base URL: https://api.ez2pay.gg ยืนยันตัวด้วย Authorization: Bearer ez2_live_...
ตั้งค่าบนเซิร์ฟเวอร์:
export EZ2PAY_API_KEY=ez2_live_...
export EZ2PAY_WEBHOOK_SECRET=...
โปรแกรมสองตัวด้านล่างทำ Webhook ครบขั้นตอน แต่ละตัวเป็นไฟล์เดียว ไม่พึ่งไลบรารีของ EZ2Pay ให้เขียน grantOnce เองตาม transaction ในหน้า Webhook
Node
Node 18 ขึ้นไป บันทึกเป็น webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';
const API = 'https://api.ez2pay.gg';
const API_KEY = process.env.EZ2PAY_API_KEY;
const WEBHOOK_SECRET = process.env.EZ2PAY_WEBHOOK_SECRET;
const PAID_STATUSES = new Set(['PAID', 'NOTIFYING', 'NOTIFIED', 'NOTIFY_FAILED', 'DELIVERED']);
const MAX_BODY_BYTES = 16_384;
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}`));
}
async function callApi(path, method = 'GET', body) {
const response = await fetch(`${API}${path}`, {
method,
redirect: 'error',
signal: AbortSignal.timeout(10_000),
headers: {
Authorization: `Bearer ${API_KEY}`,
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) throw new Error(`EZ2PAY_API_${response.status}`);
return response.json();
}
// ใน transaction เดียว: บันทึก order_id และให้ของ
// คืน true เมื่อให้ของใหม่ คืน false เมื่อเคยให้แล้ว
// throw เมื่อ sku หรือ buyer ที่เก็บไว้ไม่ตรง หรือเมื่อมีอะไรล้มเหลว
async function grantOnce(event) {
throw new Error('Write grantOnce with your database');
}
function isEvent(value) {
return Boolean(value) && typeof value === 'object'
&& typeof value.order_id === 'string' && /^ord_[0-9a-f-]{36}$/.test(value.order_id)
&& typeof value.sku === 'string' && value.sku.length > 0
&& typeof value.buyer === 'string' && value.buyer.length > 0
&& (value.status === 'success' || value.status === 'voided');
}
function isConfirmed(order, event) {
return order.order_id === event.order_id && order.sku === event.sku && order.buyer === event.buyer
&& PAID_STATUSES.has(order.status)
&& typeof order.paid_at === 'string' && Number.isFinite(Date.parse(order.paid_at))
&& order.currency === 'THB'
&& typeof order.amount === 'string' && /^\d+\.\d{2}$/.test(order.amount);
}
async function handleWebhook(rawBody, timestamp, signature) {
if (!verifyEz2paySignature(WEBHOOK_SECRET, timestamp, signature, rawBody)) {
return [401, { error: 'INVALID_SIGNATURE' }];
}
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return [400, { error: 'INVALID_EVENT' }];
}
if (event?.type === 'ping') return [200, { accepted: true, type: 'ping' }];
if (!isEvent(event)) return [400, { error: 'INVALID_EVENT' }];
if (event.status === 'voided') return [200, { accepted: true, type: 'voided' }];
const order = await callApi(`/v1/orders/${event.order_id}`);
if (!isConfirmed(order, event)) return [409, { error: 'ORDER_NOT_CONFIRMED' }];
// ห้ามเทียบ order.amount กับราคาที่เก็บไว้ในระบบของคุณ เพราะราคาเปลี่ยนใน Dashboard ได้
const granted = await grantOnce({ order_id: event.order_id, sku: event.sku, buyer: event.buyer });
await callApi(`/v1/orders/${event.order_id}/delivered`, 'POST', {});
return [200, { accepted: true, duplicate: !granted }];
}
async function readRawBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) return null;
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
createServer(async (request, response) => {
let status = 503;
let result = { error: 'TEMPORARILY_UNAVAILABLE' };
try {
if (request.method !== 'POST' || request.url !== '/ez2pay/webhook') {
[status, result] = [404, { error: 'NOT_FOUND' }];
} else {
const rawBody = await readRawBody(request);
[status, result] = rawBody === null
? [413, { error: 'PAYLOAD_TOO_LARGE' }]
: await handleWebhook(rawBody, request.headers['ez2pay-timestamp'], request.headers['ez2pay-signature']);
}
} catch {
// ล้มเหลวทุกกรณีตอบ 503 เพื่อให้ EZ2Pay ส่ง Webhook มาใหม่
}
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(result));
}).listen(Number(process.env.PORT ?? 3000));
Python
Python 3.10 ขึ้นไป ใช้แค่ standard library บันทึกเป็น webhook.py
import hashlib
import hmac
import json
import os
import re
import time
import urllib.request
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
API = "https://api.ez2pay.gg"
API_KEY = os.environ["EZ2PAY_API_KEY"]
WEBHOOK_SECRET = os.environ["EZ2PAY_WEBHOOK_SECRET"]
PAID_STATUSES = {"PAID", "NOTIFYING", "NOTIFIED", "NOTIFY_FAILED", "DELIVERED"}
MAX_BODY_BYTES = 16384
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)
def call_api(path, method="GET", body=None):
data = None if body is None else json.dumps(body).encode("utf-8")
headers = {"Authorization": f"Bearer {API_KEY}", "User-Agent": "my-game-server/1.0"}
if data is not None:
headers["Content-Type"] = "application/json"
request = urllib.request.Request(API + path, data=data, method=method, headers=headers)
with urllib.request.urlopen(request, timeout=10) as response:
return json.load(response)
# ใน transaction เดียว: บันทึก order_id และให้ของ
# คืน True เมื่อให้ของใหม่ คืน False เมื่อเคยให้แล้ว
# raise เมื่อ sku หรือ buyer ที่เก็บไว้ไม่ตรง หรือเมื่อมีอะไรล้มเหลว
def grant_once(event):
raise NotImplementedError("Write grant_once with your database")
def is_event(value):
return (
isinstance(value, dict)
and isinstance(value.get("order_id"), str)
and re.fullmatch(r"ord_[0-9a-f-]{36}", value["order_id"]) is not None
and isinstance(value.get("sku"), str) and len(value["sku"]) > 0
and isinstance(value.get("buyer"), str) and len(value["buyer"]) > 0
and value.get("status") in ("success", "voided")
)
def is_date(value):
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
return True
except (AttributeError, ValueError):
return False
def is_confirmed(order, event):
return (
order.get("order_id") == event["order_id"]
and order.get("sku") == event["sku"]
and order.get("buyer") == event["buyer"]
and order.get("status") in PAID_STATUSES
and is_date(order.get("paid_at"))
and order.get("currency") == "THB"
and isinstance(order.get("amount"), str)
and re.fullmatch(r"[0-9]+\.[0-9]{2}", order["amount"]) is not None
)
def handle_webhook(raw_body, timestamp, signature):
if not verify_ez2pay_signature(WEBHOOK_SECRET, timestamp, signature, raw_body):
return 401, {"error": "INVALID_SIGNATURE"}
try:
event = json.loads(raw_body.decode("utf-8"))
except ValueError:
return 400, {"error": "INVALID_EVENT"}
if isinstance(event, dict) and event.get("type") == "ping":
return 200, {"accepted": True, "type": "ping"}
if not is_event(event):
return 400, {"error": "INVALID_EVENT"}
if event["status"] == "voided":
return 200, {"accepted": True, "type": "voided"}
order = call_api(f"/v1/orders/{event['order_id']}")
if not is_confirmed(order, event):
return 409, {"error": "ORDER_NOT_CONFIRMED"}
# ห้ามเทียบ order["amount"] กับราคาที่เก็บไว้ในระบบของคุณ เพราะราคาเปลี่ยนใน Dashboard ได้
granted = grant_once({"order_id": event["order_id"], "sku": event["sku"], "buyer": event["buyer"]})
call_api(f"/v1/orders/{event['order_id']}/delivered", "POST", {})
return 200, {"accepted": True, "duplicate": not granted}
class WebhookHandler(BaseHTTPRequestHandler):
def answer(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
if self.path != "/ez2pay/webhook":
return self.answer(404, {"error": "NOT_FOUND"})
try:
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY_BYTES:
return self.answer(413, {"error": "PAYLOAD_TOO_LARGE"})
raw_body = self.rfile.read(length)
status, payload = handle_webhook(
raw_body,
self.headers.get("EZ2Pay-Timestamp"),
self.headers.get("EZ2Pay-Signature"),
)
except Exception:
# ล้มเหลวทุกกรณีตอบ 503 เพื่อให้ EZ2Pay ส่ง Webhook มาใหม่
status, payload = 503, {"error": "TEMPORARILY_UNAVAILABLE"}
self.answer(status, payload)
ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("PORT", "3000"))), WebhookHandler).serve_forever()
Framework อื่น
สำคัญคือต้องได้ raw body ก่อนที่อะไรจะ parse
| Framework | raw body |
|---|---|
| Express | express.raw({ type: '*/*' }) ที่ route นี้ แล้วใช้ req.body |
| Hono | await c.req.arrayBuffer() |
| Next.js route handler | await request.text() |
| Cloudflare Workers | await request.arrayBuffer() |
| Flask | request.get_data() |
| FastAPI | await request.body() |
| Django | request.body |
ห้ามวาง express.json() หรือ parser แบบเดียวกันไว้หน้า route ของ Webhook
สร้าง Order จากเซิร์ฟเวอร์
client ของเกมขอให้เซิร์ฟเวอร์เริ่มการซื้อ เซิร์ฟเวอร์เรียก API แล้วส่ง checkout_url กลับไปให้ client
const response = await fetch('https://api.ez2pay.gg/v1/orders', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EZ2PAY_API_KEY}`,
'Idempotency-Key': purchaseId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sku: 'gem_pack_100',
buyer: playerId,
customer_email: playerEmail,
return_url: 'https://game.example/topup/result',
}),
});
const order = await response.json();
purchaseId ต้องไม่ซ้ำกันในแต่ละการซื้อ และต้องคงเดิมเมื่อเซิร์ฟเวอร์ลองส่งการซื้อเดิมอีกครั้ง กติกาของแต่ละช่องอยู่ที่หน้า Order
ต่อไป: ให้ AI ช่วยตั้งค่าผ่าน MCP