Webhooks
This page describes how Open Play forwards game events to your own server with an HTTP POST: endpoint registration, event types, signature verification, retries and coping with repeated deliveries. Read it if you want to trigger your own backend system on a character, store or reward event.
What it does
When an event occurs (for example level_up, store_purchase_completed, reward_granted), Open Play sends a signed POST to your registered endpoints. Delivery is at-least-once: the same event can arrive more than once because of network errors, so your receiver must drop repeats using X-Crx-Event-Id.
If the module is disabled, the endpoints return 404 with {"code":"module_disabled","module":"webhooks"}. Enable the module on the Game settings › Modules page.
Where it lives in the panel
| What you want to do | Panel page |
|---|---|
| See registered endpoints, delivery attempts, failed (DLQ) deliveries and the event queue | Webhooks and event queue |
| Enable the module | Game settings › Modules |
The panel is read-only on this page: endpoint registration is done with REST; there is no button in the panel to resend or to rescue a failed delivery. The panel does not show secrets, the raw body or the raw error text.
Note
Panel help pages are not translated yet; the panel links above point to the Turkish source.
Data model (in plain words)
- Endpoint:
url, an optionalevent_typesfilter,enabledandcreated_at. - Secret: Generated by the server (
whsec_+ 64 lowercase hex characters), returned once only in the registration response and stored encrypted. No endpoint gives it again afterwards. There is no endpoint to rotate the secret: to renew it, delete the endpoint and register it again. - Event envelope: The JSON body that is sent. Every envelope contains
event_id,type,environment_idanddata. - Delivery: The sending of one event to one endpoint. Its statuses are pending, delivered and failed (DLQ).
Selectable event types
If event_types is empty, the endpoint receives all events (see the note below). If it is non-empty, you can choose only from these types:
| Area | Types |
|---|---|
| Character | flag_set, attribute_set, resource_changed, modifier_added, modifiers_removed, xp_added, level_up, ability_unlocked, event_emitted, character_rolled_back |
| Store | store_page_created, store_page_updated, store_page_deleted, store_pages_reordered, store_listing_created, store_listing_updated, store_listing_deleted, store_listings_reordered, store_purchase_initiated, store_payment_started, store_purchase_completed, store_reservation_cancelled |
| Reward | reward_granted |
An unknown type returns 400. Full list and data fields: Webhook events.
Warning
If you leave the filter empty, the endpoint is not limited to the list above: it receives all events written to the outbox (such as item, wallet, clan, party, score and notification queue events). The envelope format of these extra events is not a documented contract and may change without notice. In production, always give the
event_typesfilter explicitly.
Envelope format
Character events:
{
"event_id": "0192f500-1111-7222-8333-944455556666",
"type": "level_up",
"character_id": "0192f0f2-5b1a-7c3e-9d11-4a5b6c7d8e9f",
"environment_id": "0192f000-0000-7000-8000-00000000e001",
"seq": 12,
"data": { "...": "event-specific fields" }
}
Store events have no character_id; together with seq they carry now_ms (the moment the event was processed) and actor_api_key_id. The reward_granted event has player_id, and data contains source_kind, table_key, table_version and what was granted; the draw seed is in no event.
Calls your game makes
All management endpoints are called with a server key; a client key gets 403:
Authorization: Bearer crx_live_<prefix>_<secret>
| Method and path | What it does |
|---|---|
POST /v1/webhooks | Registers an endpoint (201; the secret only here) |
GET /v1/webhooks | Lists endpoints (cursor, limit 1–200, default 50) |
GET /v1/webhooks/{id} | Reads a single endpoint (without the secret) |
DELETE /v1/webhooks/{id} | Deletes an endpoint (soft delete, 204) |
The TypeScript SDK exposes these four endpoints in a typed way: createWebhook, listWebhooks, getWebhook, deleteWebhook (see TypeScript SDK).
Registering an endpoint
POST /v1/webhooks
Authorization: Bearer crx_live_<prefix>_<secret>
Content-Type: application/json
{
"url": "https://hooks.example-game.com/open-play",
"event_types": ["level_up", "store_purchase_completed"]
}
{
"id": "0192f600-…",
"url": "https://hooks.example-game.com/open-play",
"event_types": ["level_up", "store_purchase_completed"],
"enabled": true,
"created_at": "2026-11-03T09:00:00Z",
"secret": "whsec_<64 hex characters>"
}
Write the secret value to a safe place (a secrets management service) right away; it cannot be read again.
Registration rules:
- The URL must be
https; it must not carry user information (user:password@); it must not contain non-letter control characters or whitespace. Loopback, private and link-local IP addresses are rejected at registration (400). - In addition, on every send the IP that the name resolves to is re-checked; a name that resolves to a private or loopback address cannot be sent to. Redirects are not followed.
- A second registration while the same URL is active returns
409.
Delivery
For every event, this request arrives at your endpoint:
POST /open-play
Content-Type: application/json
X-Crx-Signature: t=1790000000,v1=9c1f…e2
X-Crx-Event-Id: 0192f500-1111-7222-8333-944455556666
X-Crx-Event-Type: level_up
{ …envelope… }
| Header | Meaning |
|---|---|
X-Crx-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
X-Crx-Event-Id | The event ID; your dedup key |
X-Crx-Event-Type | The type value in the envelope |
Verifying the signature
The signature is the HMAC-SHA256 digest (lowercase hex) of the text "{t}.{raw body}", computed with the whole secret returned at registration (with the whsec_… prefix) as the key. Verification steps:
- Separate the
tandv1values from theX-Crx-Signatureheader. - Take the raw body bytes. Do not parse the JSON and re-serialize it; the signature is for the exact bytes on the wire.
- Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare it withv1in constant time. - Compare
twith your own clock; reject requests that are too old (for example older than 5 minutes).tis refreshed on every retry, so a genuine retry stays within this window. - If
X-Crx-Event-Idhas already been processed, return2xxwithout repeating the work.
Response and retry rules
- Only
2xxis success. Every other response, a timeout and a connection error trigger a retry. - 5 seconds are waited for the connection and 10 seconds in total, including DNS.
- If the body exceeds 64 KB, it is not sent and counts as an error.
- The delay is
min(1 s × 2^attempt, 5 min)and includes ±20% randomness. At most 10 attempts are made; after the 10th failed attempt the delivery is moved to the failed (DLQ) state and is not repeated automatically. - When you delete the endpoint, pending deliveries are also moved to the failed state.
- There is no ordering guarantee: events can be ordered with
seqfor a character, but the delivery order can change. - Events that occur while there is no endpoint are not sent afterwards; there is no retroactive delivery.
Versioning and replay safety
- Receiver side: dedup with
X-Crx-Event-Idis mandatory. The same event arrives on a retry with the sameevent_id; the signature time (t) is refreshed. - The registration endpoint has no
Idempotency-Key; registering the same URL a second time returns409. - There is no event version field in the envelope; ignore fields you do not recognize and do not reject the body with a strict schema.
Limits
- Body 64 KB; total wait 10 seconds; 10 attempts.
limiton the list endpoint is 1–200.- Redirects are not followed;
httpsonly.
Common errors
| Symptom | Cause | Fix |
|---|---|---|
400 on registration | The URL is not https, is an IP address, has user information, or an unknown event type | Fix the URL and event_types values |
403 on registration | A client key | Use a server key |
409 on registration | The same URL is already active | Delete or use the existing one |
| The signature does not verify | The body was re-serialized; the whsec_ prefix of the secret was dropped; clock skew | Compute with the raw bytes and the whole secret |
| The same event arrives twice | At-least-once delivery | Drop it with X-Crx-Event-Id |
| Deliveries stopped | The 10 attempts were used up (DLQ) or the endpoint was deleted | Fix the receiver; for missed events, read from your own source |
| No delivery ever arrives | The endpoint name resolves to a private/loopback IP, or the response exceeds 10 seconds | Use a public address; respond quickly |
Working example: a receiver with Node
The server below verifies the signature, drops a repeated event and returns 2xx. The secret comes from an environment variable; in production, do the dedup with a durable store.
/** Webhook alıcısı (Node 22): imzayı doğrular, tekrar eden teslimatı `X-Crx-Event-Id` ile eler. */
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env["OPEN_PLAY_WEBHOOK_SECRET"] ?? ""; // uç noktayı kaydederken bir kez dönen sır
const TOLERANCE_SECONDS = 300; // eski teslimatı (replay) reddetme penceresi: alıcının kendi politikası
const seen = new Set<string>(); // örnek amaçlı bellek; üretimde kalıcı depo kullanın
/** `X-Crx-Signature: t=<unix-sn>,v1=<hex>` değerini ham gövdeye karşı doğrular. */
export function verifySignature(secret: string, header: string, rawBody: Buffer, nowSeconds: number): boolean {
const parts = new Map(header.split(",").map((part) => part.split("=", 2) as [string, string]));
const t = parts.get("t");
const v1 = parts.get("v1");
if (t === undefined || v1 === undefined) return false;
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(v1, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks); // imza ham bayt üzerindendir: JSON'u yeniden serileştirmeyin
const signature = req.headers["x-crx-signature"];
if (typeof signature !== "string" || !verifySignature(SECRET, signature, rawBody, Math.floor(Date.now() / 1000))) {
res.writeHead(401).end();
return;
}
const eventId = String(req.headers["x-crx-event-id"] ?? "");
if (!seen.has(eventId)) {
seen.add(eventId);
const event = JSON.parse(rawBody.toString("utf8")) as { type: string; data: unknown };
console.log("olay", event.type, event.data);
}
res.writeHead(200).end(); // yalnız 2xx başarıdır; başka kod yeniden denemeyi tetikler
});
}).listen(8080);