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

ไม่ต้องติดตั้งไลบรารีของ EZ2Pay เซิร์ฟเวอร์ของคุณเรียก HTTPS 3 ครั้ง และรับ Webhook 1 จุด ใช้ภาษาใดก็ได้

เซิร์ฟเวอร์ของคุณRequestเมื่อไหร่
สร้าง OrderPOST /v1/ordersผู้เล่นกดซื้อ
รับ WebhookPOST มาที่ URL ของคุณEZ2Pay ยืนยันการขาย
อ่าน OrderGET /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

Frameworkraw body
Expressexpress.raw({ type: '*/*' }) ที่ route นี้ แล้วใช้ req.body
Honoawait c.req.arrayBuffer()
Next.js route handlerawait request.text()
Cloudflare Workersawait request.arrayBuffer()
Flaskrequest.get_data()
FastAPIawait request.body()
Djangorequest.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