# Webhooks > Events, payload shape, signature verification, the retry schedule, and the history you can pull. Source: https://casinoapipro.com/docs/webhooks Webhooks tell your systems what happened without polling. Every delivery is signed, retried with exponential backoff, and idempotent on a stable event id. ## Events These seven names are the complete list. Subscribing to a name outside it is rejected when you create the endpoint, so a typo fails immediately rather than silently delivering nothing. Leave the event list empty to receive all of them. | Name | Type | Meaning | | --- | --- | --- | | `game.session.created` | event | A launch session was opened for a player. | | `round.started` | event | A round opened. | | `round.completed` | event | A round reached a terminal state. Carries the payout and the outcome. | | `transaction.bet` | event | A stake settled against your wallet. Refunds also arrive under this name — read `data.type` to tell them apart. | | `transaction.win` | event | A win settled against your wallet. | | `transaction.failed` | event | A movement was refused and will not be retried. | | `transaction.rollback` | event | A movement was voided. | ## Payload Every delivery has the same six-field envelope. Only `data` varies by type. _round.completed_ ```json { "id": "evt_3f81a2c9", "type": "round.completed", "created_at": "2026-08-29T15:07:44.902Z", "environment": "LIVE", "operator_id": "fad629aa-4b17-408f-9fbd-dbad4f8ac1a7", "data": { "round_id": "rnd_7c31ea", "game": "mines", "wallet_round_id": "34f97fee-7e98-43c8-9e6f-4652b6ef9840", "wallet_game_id": "a662fecf-b8b4-42d3-af7f-de0ca998b89d", "player_id": "your_player_42", "stake": "10.00", "payout": "24.00", "currency": "GHS", "free_bet": false, "outcome": { "…": "game-specific" }, "fairness": { "server_seed_hash": "9c1185a5c5e9fc54612808977ee8f548…", "client_seed": "player-chosen", "nonce": 41 } } } ``` `outcome` is the same reveal the player saw, so it can be shown to them without translation — the tiles opened and where the bombs were for `mines`, the roll and the target for `dice`, the angle and the pick for `spin-bottle`. `fairness` carries the triple needed to verify the round, so the event alone is enough; see [provably fair](/docs/provably-fair). A null `client_seed` means the player never set one and is treated as the empty string in the seed string — the round still verifies. > **Joining an event to the wallet calls it caused** > > The event speaks in public handles — `round_id` is `rnd_…` and `game` is a slug — while wallet calls carry uuids. So `wallet_round_id` and `wallet_game_id` are also here: they are exactly the `roundId` and `gameId` we sent on the debit and credit for this round. Join on those rather than pairing by timestamp and amount, which cannot distinguish two identical stakes seconds apart. > **free_bet is the field to branch on** > > A free bet raises no debit — the player is not paying — and a losing one pays nothing. So a losing free-bet round settles without a single call to your wallet, and this event is the only record that it happened. Two things follow for your ledger: a winning free bet is a credit with no matching debit, which is correct rather than a fault; and `stake` is here because on those rounds there is no debit of yours to read it from. _transaction.bet_ ```json { "id": "evt_5c02de71", "type": "transaction.bet", "created_at": "2026-08-29T15:07:44.118Z", "environment": "LIVE", "operator_id": "fad629aa-4b17-408f-9fbd-dbad4f8ac1a7", "data": { "transaction_id": "bet_9f21c4", "reference": "txn_0b41f7", "type": "BET", "status": "SUCCESS", "amount": "10.00", "currency": "GHS", "player_id": "your_player_42", "round_id": "rnd_7c31ea" } } ``` **The envelope** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Stable across redeliveries of the same event. This is what you deduplicate on. | | `type` | string | yes | One of the seven names above. | | `created_at` | ISO 8601 | yes | When the event happened, not when this attempt was sent. Redeliveries keep the original value; the attempt number is in the `x-capro-delivery-attempt` header. | | `environment` | SANDBOX | LIVE | yes | Which environment produced it. Worth asserting on if one endpoint receives both. | | `operator_id` | uuid | yes | Your account id. | | `data` | object | yes | Type-specific. Treat unrecognised fields as additive — we add them without a version bump. | > **data.type is not envelope type** > > On transaction events the envelope `type` is the event name (`transaction.bet`) and `data.type` is the movement kind (`BET`, `WIN`, `REFUND`, `ROLLBACK`). A refund arrives as `transaction.bet` with `data.type: "REFUND"`, so branch on `data.type` when the distinction matters to your ledger. ## Verifying the signature Every delivery carries these headers: ```http x-capro-event-id: evt_3f81a2c9 x-capro-event-type: round.completed x-capro-timestamp: 1788321861 x-capro-signature: v1=4d1b8f0a5c2e9a7b3f6d8c1e0a4b7d2f9c3e6a1b8d5f0c7e2a9b4d6f1c8e3a0b x-capro-delivery-attempt: 1 signature = "v1=" + hex( HMAC_SHA256( secret, "{x-capro-timestamp}" + "." + "{raw body}" ) ) ``` | Name | Type | Meaning | | --- | --- | --- | | `Header names` | x-capro-* | All five are prefixed. There is no bare `X-Signature` on a webhook. | | `The v1= prefix` | must be stripped | The header value is `v1=` followed by the hex. Comparing the whole value against a bare digest never matches. The prefix is a version marker so a future scheme can be added without breaking you. | | `Encoding` | lowercase hex | 64 characters. Not base64. | | `Timestamp` | Unix seconds | A string of ten digits. Not milliseconds, not ISO 8601. | | `Signed string` | {timestamp}.{body} | A literal dot between them, over the exact bytes we sent. | > **Verify against the raw body** > > Parse the JSON only after the signature checks out. Re-serialising the body first will change a byte somewhere and the comparison will fail for reasons that take a day to find. _Express_ ```js import crypto from "node:crypto"; // Raw body, not the parsed object. app.post("/webhooks/casinoapipro", express.raw({ type: "*/*" }), (req, res) => { const header = req.get("x-capro-signature") ?? ""; const timestamp = req.get("x-capro-timestamp"); if (!header || !timestamp) return res.sendStatus(400); // Reject anything old enough to be a replay of a captured request. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.sendStatus(400); } const expected = crypto .createHmac("sha256", process.env.WEBHOOK_SECRET) .update(\ ``` _PHP_ ```php $raw = file_get_contents("php://input"); $header = $_SERVER["HTTP_X_CAPRO_SIGNATURE"] ?? ""; $timestamp = $_SERVER["HTTP_X_CAPRO_TIMESTAMP"] ?? ""; // Strip the "v1=" version prefix before comparing. [$version, $signature] = array_pad(explode("=", $header, 2), 2, ""); if ($version !== "v1" || $signature === "" || $timestamp === "") { http_response_code(400); exit; } if (abs(time() - (int) $timestamp) > 300) { http_response_code(400); exit; } $expected = hash_hmac("sha256", $timestamp . "." . $raw, getenv("WEBHOOK_SECRET")); if (! hash_equals($expected, $signature)) { http_response_code(401); exit; } http_response_code(200); $event = json_decode($raw, true); ``` > **Wallet calls are signed differently** > > If you also verify our calls to your wallet, note the headers there are `x-signature` and `x-timestamp` with no `v1=` prefix and no `capro` in the name. Same algorithm, same signed string, different headers — see [securing the callback](/docs/wallet#security). ## Rotating the signing secret Rotating issues a new secret and keeps the previous one valid for a window you choose — 24 hours by default. While that window is open, every delivery carries both signatures in one `x-capro-signature` header, space separated: ```http x-capro-signature: v1=4d1b8f0a… v1=9e2c7b40… ``` Accept the delivery if either matches. That is what lets you deploy the new secret whenever it suits you instead of racing the rotation: without the overlap, every event between the rotation and your deploy fails verification. > **Write the loop before you need it** > > A receiver that compares the header to a single value works perfectly until the first rotation and then rejects everything. Split on spaces from day one — it costs one line and it is the difference between rotating on a Tuesday afternoon and never rotating. ## Testing an endpoint The dashboard can send a `webhook.test` delivery to any endpoint and show you exactly what came back — status, timing, and the body on failure. It travels the same path as a real event and is signed the same way, so a pass means a real event will arrive. A failed test does not count against the endpoint’s failure streak. A test is a question, and a question answered “no” should not pause the endpoint you are in the middle of setting up. ## Retries Any response outside `2xx`, and any timeout, is retried with exponential backoff over roughly 24 hours. After the final attempt the delivery is marked failed and appears in the dashboard, where you can replay it once your endpoint is healthy. - Answer `200` as soon as you have durably stored the event; do the work afterwards. - Deduplicate on `id` — at-least-once delivery means you will occasionally see one twice. - Order is not guaranteed. Use `created_at` if sequence matters. ## Reading events you were not delivered `GET /v1/events` — auth: operator-api Every event is recorded whether or not an endpoint is subscribed to it, and this is where you read them from. That means a webhook added today can be backfilled over the period before it existed, and that an outage on your side longer than the retry window is recoverable rather than lost. **Query** | Name | Type | Meaning | | --- | --- | --- | | `type` | string | One of the seven names above. Omit for all of them. | | `from / to` | ISO 8601 | Inclusive, UTC. Defaults to the last 7 days. | | `limit` | integer | Page size, 1–200. Defaults to `50`. | | `cursor` | string | The `next_cursor` from the previous page. | ```bash curl "https://api.casinoapipro.com/v1/events?type=round.completed&from=2026-08-01" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Events come back oldest first — a backfill is replayed forward, and paging a newest-first list while new events arrive skips rows. Each one is the same envelope a delivery carries, so one handler serves both. There is no environment parameter: a credential belongs to one environment and the history you get is its own. > **Reading is not delivery** > > Fetching an event here does not mark it delivered, and it does not affect retries. Polling this endpoint and receiving webhooks are two ways to read the same history — use either, or both while you cut over. `GET /v1/events/{eventId}` — auth: operator-api Fetch one by the `id` from its envelope — useful when a delivery arrived but your handler failed to store it. ## Managing endpoints `POST /v1/dashboard/webhooks` — auth: dashboard `GET /v1/dashboard/webhook-deliveries` — auth: dashboard `POST /v1/dashboard/webhook-deliveries/{id}/retry` — auth: dashboard The signing secret is shown once at creation, exactly like an API credential. > **These take a dashboard session, not your API credentials** > > Note the `dashboard` badge above. These routes accept a token issued at dashboard login for a human user; an `operator-api` token minted from your `ck_live_` credentials is rejected on them. In practice you manage webhook endpoints in the dashboard UI, and nothing in your server integration needs to call these. See [token audiences](/docs/authentication#audiences).