Webhooks and delivery

When a sale succeeds, EZ2Pay sends a signed POST to your game's webhook URL. It says which SKU sold, who bought it, and that the sale succeeded.

What arrives

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"}

Two other messages must never grant goods:

MessageBodyMeaning
Ping{"type":"ping"}A test from the webhook test button. Verify it and answer 200
Voided"status":"voided"The order was cancelled. Verify it, grant nothing, and answer 200

Verify the signature

Anyone can call your URL. Verify every message before you trust it.

  1. Read the raw body first. Do not parse it and serialize it again, because the signature covers the exact bytes.
  2. Compute HMAC-SHA256 with your webhook secret over {EZ2Pay-Timestamp}.{raw body}, then write it as lowercase hex.
  3. Compare v1= plus that hex with the EZ2Pay-Signature header, using a timing-safe comparison.
  4. Reject the message if EZ2Pay-Timestamp is more than 300 seconds from now.
  5. Reject bodies over 16 KiB.

Node

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

// rawBody is a Buffer or string holding the exact request body.
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 is the exact request body as 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)

Check your code in any language

Your code must produce this signature from these exact values. The timestamp is old on purpose, so compute the HMAC directly when you test with it.

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

Ask back before granting

A valid signature only says that EZ2Pay reported the sale. Before granting, read the order:

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

Grant only if all of these hold:

  1. order_id, sku and buyer match the webhook exactly.
  2. status is PAID, NOTIFYING, NOTIFIED, NOTIFY_FAILED or DELIVERED.
  3. paid_at is present and is a valid date.
  4. currency is THB and amount is a string with 2 decimals.

If any check fails, return 409 and do not grant.

Do not compare amount with a price stored in your game. amount is the full price the player pays, and prices can change in the Dashboard after your game is built.

Grant exactly once

Webhooks can repeat. Store the order_id in the same database transaction that grants the goods. Never track this in memory.

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;

-- One row returned: this is the first delivery. Grant the goods here,
-- in this same transaction.
-- No row returned: already granted. Read the stored sku and buyer.
-- If they differ from this event, ROLLBACK and return an error.

COMMIT;

Never delete or expire these rows.

Acknowledge delivery

After granting, including when the order was already granted before, tell 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 '{}'
  • Accepted when the order is NOTIFYING, NOTIFIED or DELIVERED. Otherwise 409 ORDER_NOT_NOTIFIED.
  • Safe to call again.
  • Never call it before the goods are granted. It is the record of delivery.

What to answer

Only HTTP 200 counts as success. 201, 204, a redirect, or no answer within 10 seconds all count as failed.

SituationAnswer
Granted and acknowledged, or already granted200
Ping or voided message with a valid signature200
Bad signature or timestamp401
Malformed message400
The order is not confirmed paid409
Your server or database failed503

After a failed attempt EZ2Pay tries again after 1 minute, 5 minutes, 15 minutes, 1 hour, 6 hours and 24 hours. That is 7 attempts in total. Then the order becomes NOTIFY_FAILED, and you can resend it from the Dashboard. A voided message follows the same schedule, but the order stays VOIDED and the message cannot be resent from the Dashboard.

Answer 200 only when everything truly finished. If acknowledging delivery fails, answer 503. The next attempt finds the order already granted and acknowledges it without granting twice.

Changing the webhook secret

Get a new secret if you lost the old one, it leaked, or an AI created the game through MCP, because then nobody has seen its secret.

  1. Open the game in the Dashboard, press สร้าง secret ใหม่ (create a new secret) and confirm.
  2. Copy the new secret. It is shown once.
  3. Put it in your server's environment variables and restart or redeploy the server.

The old secret stops working the moment you confirm. Every webhook sent after that is signed with the new secret, including pings and retries of earlier sales. Until your server has the new secret it answers 401, and EZ2Pay keeps retrying on the schedule above, so change it right away.

Only a person can do this, in the Dashboard. MCP tools and AI can neither read nor change the secret.

Next: Direct HTTP integration