Casino Api Pro

Integrate

Webhooks

Events, payload shape, signature verification, the retry schedule, and the history you can pull.

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.

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
{
  "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. 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.

iJoining 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
{
  "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

id
stringrequired
Stable across redeliveries of the same event. This is what you deduplicate on.
type
stringrequired
One of the seven names above.
created_at
ISO 8601required
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 | LIVErequired
Which environment produced it. Worth asserting on if one endpoint receives both.
operator_id
uuidrequired
Your account id.
data
objectrequired
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}" ) )
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
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(`${timestamp}.${req.body}`)
    .digest("hex");

  // SPLIT ON SPACES. During a secret rotation the header carries TWO "v1="
  // values — one per valid secret — and you accept the delivery if either
  // matches. Write this loop now, not on the morning you rotate.
  const ok = header.split(" ").some((entry) => {
    const [version, signature] = entry.split("=");
    if (version !== "v1" || !signature) return false;
    // Length first: timingSafeEqual THROWS on a length mismatch rather than
    // returning false, so a short signature would give a 500, not a 401.
    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(signature, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body);

  // Acknowledge immediately, then do the work. A slow handler becomes a retry.
  res.sendStatus(200);
  queue.push(event);
});
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.

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/eventsoperator-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

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}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/webhooksdashboard
GET/v1/dashboard/webhook-deliveriesdashboard
GET/v1/webhook-deliveriesapi key
POST/v1/dashboard/webhook-deliveries/{id}/retrydashboard
POST/v1/webhook-deliveries/{id}/retryapi key

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.