Direct HTTP integration

You do not need an EZ2Pay library. Your server makes three HTTPS calls and receives one webhook, in any language.

Your serverRequestWhen
Creates an orderPOST /v1/ordersThe player taps buy
Receives the webhookPOST to your URLEZ2Pay confirmed a sale
Reads the orderGET /v1/orders/{id}Before every grant
Acknowledges deliveryPOST /v1/orders/{id}/deliveredAfter 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.

FrameworkRaw body
Expressexpress.raw({ type: '*/*' }) on this route, then req.body
Honoawait c.req.arrayBuffer()
Next.js route handlerawait request.text()
Cloudflare Workersawait request.arrayBuffer()
Flaskrequest.get_data()
FastAPIawait request.body()
Djangorequest.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.

Next: Set up with AI through MCP