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:
| Message | Body | Meaning |
|---|---|---|
| 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.
- Read the raw body first. Do not parse it and serialize it again, because the signature covers the exact bytes.
- Compute HMAC-SHA256 with your webhook secret over
{EZ2Pay-Timestamp}.{raw body}, then write it as lowercase hex. - Compare
v1=plus that hex with theEZ2Pay-Signatureheader, using a timing-safe comparison. - Reject the message if
EZ2Pay-Timestampis more than 300 seconds from now. - 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:
order_id,skuandbuyermatch the webhook exactly.statusisPAID,NOTIFYING,NOTIFIED,NOTIFY_FAILEDorDELIVERED.paid_atis present and is a valid date.currencyisTHBandamountis 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,NOTIFIEDorDELIVERED. Otherwise409 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.
| Situation | Answer |
|---|---|
| Granted and acknowledged, or already granted | 200 |
| Ping or voided message with a valid signature | 200 |
| Bad signature or timestamp | 401 |
| Malformed message | 400 |
| The order is not confirmed paid | 409 |
| Your server or database failed | 503 |
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.
- Open the game in the Dashboard, press
สร้าง secret ใหม่(create a new secret) and confirm. - Copy the new secret. It is shown once.
- 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