Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 doPanel page
See registered endpoints, delivery attempts, failed (DLQ) deliveries and the event queueWebhooks and event queue
Enable the moduleGame 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 optional event_types filter, enabled and created_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_id and data.
  • 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:

AreaTypes
Characterflag_set, attribute_set, resource_changed, modifier_added, modifiers_removed, xp_added, level_up, ability_unlocked, event_emitted, character_rolled_back
Storestore_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
Rewardreward_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_types filter 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 pathWhat it does
POST /v1/webhooksRegisters an endpoint (201; the secret only here)
GET /v1/webhooksLists 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… }
HeaderMeaning
X-Crx-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
X-Crx-Event-IdThe event ID; your dedup key
X-Crx-Event-TypeThe 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:

  1. Separate the t and v1 values from the X-Crx-Signature header.
  2. Take the raw body bytes. Do not parse the JSON and re-serialize it; the signature is for the exact bytes on the wire.
  3. Compute HMAC-SHA256(secret, t + "." + raw_body) and compare it with v1 in constant time.
  4. Compare t with your own clock; reject requests that are too old (for example older than 5 minutes). t is refreshed on every retry, so a genuine retry stays within this window.
  5. If X-Crx-Event-Id has already been processed, return 2xx without repeating the work.

Response and retry rules

  • Only 2xx is 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 seq for 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-Id is mandatory. The same event arrives on a retry with the same event_id; the signature time (t) is refreshed.
  • The registration endpoint has no Idempotency-Key; registering the same URL a second time returns 409.
  • 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.
  • limit on the list endpoint is 1–200.
  • Redirects are not followed; https only.

Common errors

SymptomCauseFix
400 on registrationThe URL is not https, is an IP address, has user information, or an unknown event typeFix the URL and event_types values
403 on registrationA client keyUse a server key
409 on registrationThe same URL is already activeDelete or use the existing one
The signature does not verifyThe body was re-serialized; the whsec_ prefix of the secret was dropped; clock skewCompute with the raw bytes and the whole secret
The same event arrives twiceAt-least-once deliveryDrop it with X-Crx-Event-Id
Deliveries stoppedThe 10 attempts were used up (DLQ) or the endpoint was deletedFix the receiver; for missed events, read from your own source
No delivery ever arrivesThe endpoint name resolves to a private/loopback IP, or the response exceeds 10 secondsUse 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);