Direct HTTP integration
You do not need an EZ2Pay library. Your server makes three HTTPS calls and receives one webhook, in any language.
| Your server | Request | When |
|---|---|---|
| Creates an order | POST /v1/orders | The player taps buy |
| Receives the webhook | POST to your URL | EZ2Pay confirmed a sale |
| Reads the order | GET /v1/orders/{id} | Before every grant |
| Acknowledges delivery | POST /v1/orders/{id}/delivered | After the grant |
Base URL: https://api.ez2pay.gg. Authenticate with Authorization: Bearer ez2_live_....
Set these on your server:
export EZ2PAY_API_KEY=ez2_live_...
export EZ2PAY_WEBHOOK_SECRET=...
The two programs below do the whole webhook flow. Each one is a single file with no EZ2Pay dependency. Write grantOnce yourself, using the transaction on the webhooks page.
Node
Node 18 or newer. Save as 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();
}
// In one database transaction: record order_id and grant the goods.
// Return true for a new grant and false if it was already granted.
// Throw if the stored sku or buyer differ, or if anything fails.
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' }];
// Do not compare order.amount with a price stored here. Prices can change in the 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 {
// Any failure answers 503, so EZ2Pay sends the webhook again.
}
response.writeHead(status, { 'Content-Type': 'application/json' });
response.end(JSON.stringify(result));
}).listen(Number(process.env.PORT ?? 3000));
Python
Python 3.10 or newer, standard library only. Save as 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)
# In one database transaction: record order_id and grant the goods.
# Return True for a new grant and False if it was already granted.
# Raise if the stored sku or buyer differ, or if anything fails.
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"}
# Do not compare order["amount"] with a price stored here. Prices can change in the 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:
# Any failure answers 503, so EZ2Pay sends the webhook again.
status, payload = 503, {"error": "TEMPORARILY_UNAVAILABLE"}
self.answer(status, payload)
ThreadingHTTPServer(("0.0.0.0", int(os.environ.get("PORT", "3000"))), WebhookHandler).serve_forever()
Other frameworks
The important part is getting the raw body before anything parses it.
| Framework | Raw body |
|---|---|
| Express | express.raw({ type: '*/*' }) on this route, then 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 |
Do not put express.json() or a similar parser in front of the webhook route.
Create an order from your server
Your game client asks your server to start a purchase. Your server calls the API, then returns checkout_url to the 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();
Use a purchaseId that is unique for each purchase and stays the same when your server retries that purchase. Full field rules are on the orders page.