Skip to main content

Webhooks

ByteFederal sends an HTTP POST to your endpoint when something happens to a deposit: the payment arrives, it confirms, a screening result lands, or a decision is made. Most casino integrations are driven by these events.

This page covers where events go, how to verify them, and what delivery guarantees you get. The event reference lists every event and its payload.

Where events are sent​

ByteFederal configures webhook destinations. You cannot set them through the API. For each deposit, the destination is chosen in this order:

OrderDestinationTypical use
1The casino's own webhook URL, if ByteFederal configured one for that casino (entity).The casino receives its events directly.
2Otherwise, the webhook URL on BurraPay's API key. An active, unexpired key scoped to that casino wins over an affiliate-wide key.BurraPay receives the events for all its casinos.

The URL and the signing secret always come from the same place. A casino's own URL is always paired with the casino's own secret, never with the secret on BurraPay's key. If the winning destination has no secret, requests are sent unsigned (see Unsigned deliveries).

An endpoint that receives events for several casinos should route on entity_id or entity_external_code, which every event carries.

To see where events for each casino currently go, call the key-check endpoint. It never returns secrets.

curl https://quinix.byteconnect.us/casino/integration/key \
-H "X-API-Key: $API_KEY"
{
"key": {
"id": 42,
"name": "Production key",
"mode": "live",
"scope": "admin",
"expires_at": null
},
"affiliate": { "id": 5678, "name": "Example Affiliate" },
"entity_scope": null,
"entities": [
{
"id": 1234,
"name": "Your Casino",
"external_code": "your-casino-code",
"status": "enabled",
"webhook": {
"url": "https://casino.example.com/webhooks/bytefederal",
"signed": true,
"source": "entity_override"
}
}
]
}

webhook.source is entity_override for the casino's own URL and api_key for the URL on BurraPay's key. webhook is null when no destination is configured for that casino.

Configure the webhook before the first deposit

Events that happen while no destination is configured are not queued. Some of them are sent once a URL is configured. Others, including all decision events, are never sent. Check the key-check response before you create deposits for a casino.

Changing a URL or secret does not affect deliveries that are already queued. Each delivery stores its URL, body and signature when it is queued, and every retry reuses them. When a secret is rotated, keep accepting the old secret until the retry window (about 7.5 hours) has passed.

Request format​

Every event is one POST request with a JSON object as the body.

HeaderWhenValue
Content-TypeAlwaysapplication/json
X-Webhook-SignatureWhen a secret is configured for the destinationLowercase hex HMAC-SHA256 of the raw body. See Verifying signatures.
X-Webhook-TestTest events onlytrue. See Test events.

The body is compact JSON: object keys are sorted alphabetically at every level, with no whitespace between tokens. This is the exact byte string that was signed. The event reference shows payloads pretty-printed for reading; on the wire they are compact.

Casino events carry an event field with the event name, such as deposit.confirmed or kyt_complete. The generic transaction.<status> events have no event field; see the event reference.

Verifying signatures​

X-Webhook-Signature is the HMAC-SHA256 of the raw request body, keyed with your webhook secret, encoded as 64 lowercase hex characters.

To verify a request:

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 of those bytes with your secret.
  3. Compare the result with the header in constant time.
  4. If they differ, or the header is missing, respond 401 and do not process the event.
Verify the raw bytes, never re-serialized JSON

Compute the HMAC over the bytes you received. Do not parse the body and serialize it again before verifying: number formatting, key order and escaping can change, and the signature will no longer match. In Express this means express.raw(), not express.json(). In Flask this means request.get_data(), not request.json.

Node.js (Express)​

const crypto = require("node:crypto");
const express = require("express");

const WEBHOOK_SECRET = process.env.BYTEFEDERAL_WEBHOOK_SECRET;

// True when signatureHex is the hex HMAC-SHA256 of rawBody (a Buffer) under secret.
function isValidSignature(rawBody, signatureHex, secret) {
if (typeof signatureHex !== "string" || !/^[0-9a-fA-F]{64}$/.test(signatureHex)) {
return false;
}
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(signatureHex, "hex"));
}

// Replace with a durable write (a database row or a queue message).
function storeEvent(event) {
console.log("stored", event.event ?? `transaction.${event.status.toLowerCase()}`);
}

const app = express();

// express.raw() hands you the exact bytes that were signed. Do not let
// express.json() or any other body parser run on this route first.
app.post("/webhooks/bytefederal", express.raw({ type: "application/json" }), (req, res) => {
if (!Buffer.isBuffer(req.body)) {
return res.sendStatus(400);
}
if (!isValidSignature(req.body, req.get("X-Webhook-Signature"), WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.test === true) {
// A test event from POST /casino/integration/test-webhook. Never credit it.
return res.sendStatus(200);
}
storeEvent(event);
res.sendStatus(200);
});

app.listen(3000);

isValidSignature does not depend on Express. With another framework, or plain node:http, pass it a Buffer of the raw body.

Python (Flask)​

import hashlib
import hmac
import json
import os

from flask import Flask, request

WEBHOOK_SECRET = os.environ["BYTEFEDERAL_WEBHOOK_SECRET"].encode("utf-8")


def is_valid_signature(raw_body, signature_hex, secret):
"""True when signature_hex is the hex HMAC-SHA256 of raw_body (bytes) under secret."""
if not signature_hex:
return False
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode("ascii"), signature_hex.lower().encode("utf-8"))


def store_event(event):
# Replace with a durable write (a database row or a queue message).
print("stored", event.get("event") or "transaction." + event["status"].lower())


app = Flask(__name__)


@app.post("/webhooks/bytefederal")
def bytefederal_webhook():
raw_body = request.get_data() # the exact bytes that were signed
if not is_valid_signature(raw_body, request.headers.get("X-Webhook-Signature"), WEBHOOK_SECRET):
return "", 401
event = json.loads(raw_body)
if event.get("test") is True:
# A test event from POST /casino/integration/test-webhook. Never credit it.
return "", 200
store_event(event)
return "", 200

is_valid_signature does not depend on Flask. With another framework, pass it the raw body as bytes.

Test vector​

Use this to unit-test your verification code. With the secret whsec_docs_test, this exact body:

{"acceptable":true,"confirmed_crypto":0.00104359,"crypto_amount":0.00104359,"currency":"BTC","decision":"pending","deposit_id":0,"entity_external_code":"your-casino-code","entity_id":1234,"event":"ofac_complete","gambler_ref":"sample-player-001","instant":false,"instant_downgraded":false,"ofac_result":"clear","test":true,"timestamp":1790172000,"transaction_id":0}

has this signature:

6a099b6ac35ac17c403fa119ed46da3cdf35ee4735adbd37029d0fc4cc93705c

You can reproduce it with OpenSSL:

printf '%s' '{"acceptable":true,"confirmed_crypto":0.00104359,"crypto_amount":0.00104359,"currency":"BTC","decision":"pending","deposit_id":0,"entity_external_code":"your-casino-code","entity_id":1234,"event":"ofac_complete","gambler_ref":"sample-player-001","instant":false,"instant_downgraded":false,"ofac_result":"clear","test":true,"timestamp":1790172000,"transaction_id":0}' \
| openssl dgst -sha256 -hmac whsec_docs_test

Changing any single byte of the body must make your code reject it.

Unsigned deliveries​

If no secret is configured for the destination, requests have no X-Webhook-Signature header. The key-check endpoint shows this as "signed": false. Anyone who knows your URL can then send you fake events. Ask ByteFederal to configure a secret before you go live, and reject unsigned requests in production.

Responding​

A delivery succeeds only when your endpoint answers with a 2xx status within 30 seconds. Everything else is a failed attempt and is retried:

  • any status outside 200–299,
  • redirects (3xx); they are never followed,
  • no response within 30 seconds,
  • connection and TLS errors.

The response body is ignored.

Store the event durably, respond 2xx, and then do the work asynchronously. Do not credit players, call other services or wait on locks before responding. A slow handler causes timeouts, and a timeout is retried even if your handler finished.

Respond 2xx to events you do not use or do not recognize. A non-2xx response only causes the same event to be retried for about 7.5 hours.

Retries and dead-letter​

A failed attempt is retried with exponential backoff: 10 seconds × 2n after the n-th failed attempt, capped at 1 hour. An event is attempted at most 15 times.

AttemptEarliest time after the first attempt
10
220 s
31 min
42 min 20 s
55 min
610 min 20 s
721 min
842 min 20 s
91 h 25 min
102 h 25 min
113 h 25 min
124 h 25 min
135 h 25 min
146 h 25 min
157 h 25 min

Real times are later: each failed attempt can take up to 30 seconds, and the retry worker checks for due deliveries every 5 seconds.

Every retry sends the same body and the same signature as the first attempt. The payload's timestamp is the time the event was built, not the time of the attempt.

After the 15th failed attempt the event is dead-lettered and not sent again. There is no API to request a redelivery. To recover, read the deposit's current state with GET /casino/deposit/{id} or the list endpoint; see Reading deposits.

Delivery guarantees​

Each event is produced once per deposit, or once per result for the screening events:

EventProducedIdempotency key
deposit.received, deposit.confirmedAt most once per depositdeposit_id + event
deposit.accepted, deposit.returned, deposit.frozenAt most once per depositdeposit_id + event
double_spend_alertAt most once per depositdeposit_id + event
kyt_completeAt most once per kyt_result value per depositdeposit_id + event + kyt_result
ofac_completeAt most once per ofac_result value per depositdeposit_id + event + ofac_result
transaction.<status>At most once per status per transactionid + status

"Produced once" does not mean "received once". The same delivery can reach you more than once: after a timeout on a request you did process, or when attempts overlap. Duplicates are byte-identical, signature included. Make your handler idempotent: record the idempotency key with a unique constraint, and treat a duplicate as success (2xx, no further action). Use a unique constraint, not a "check, then insert" sequence, because duplicates can arrive at the same time.

Events can arrive out of order​

Each event is delivered and retried on its own. Events are also detected by separate background workers that poll every 10 to 30 seconds, and decision events are sent as soon as the decision API call completes. So a later event can reach you before an earlier one; for example, kyt_complete can arrive while deposit.confirmed is still being retried.

Do not rely on arrival order:

  • Treat each event as a statement about one part of the deposit, not as a step in a sequence.
  • For kyt_complete and ofac_complete, the result can change (for example review then passed). Keep the result with the highest timestamp for each deposit.
  • Before you move money, such as crediting or reversing a player, read the current state with GET /casino/deposit/{id} if you have API access.

Some events are not guaranteed​

  • transaction.<status> events report the status seen when a worker polls. If a deposit passes through a status between two polls, no event is produced for that status. The deposit.received and deposit.confirmed events are not skipped this way; build on those.
  • Decision events (deposit.accepted, deposit.returned, deposit.frozen) are sent once, right after the API call that made the decision. The API response to that call is the authoritative record of the decision.
  • A dead-lettered event is not sent again.

Test events​

POST /casino/integration/test-webhook sends one sample event to the destination configured for a casino, signed exactly like a real event. See Send a test webhook for the endpoint and its response.

curl -X POST https://quinix.byteconnect.us/casino/integration/test-webhook \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"entity_id": 1234, "event": "deposit.confirmed"}'

A test event differs from a real one in three ways:

  • the request has the header X-Webhook-Test: true,
  • the payload has "test": true,
  • deposit_id, transaction_id and return_id are 0 (for transaction.<status> events, id is "0"). No real deposit or transaction has id 0.

A test event is not stored and is never retried. The endpoint allows 10 calls per minute per API key.

Never credit a test event

Test events look like real events and pass signature verification. Check test before any business logic. If it is true, respond 2xx and stop.

Known limitations​

  • No replay protection in the signature. The signature covers the body only, not a timestamp or a nonce, so a captured request can be sent again and will verify. Idempotent handling (see Delivery guarantees) makes a replayed event a no-op. The payload's timestamp lets you refuse very old events, but legitimate retries keep their original timestamp and can arrive about 7.5 hours later, so any age limit must be longer than that.
  • kyt_complete with kyt_result: "review" carries decision: "pending", which looks actionable, but POST /casino/deposit/{id}/accept rejects a deposit under review with 409. Decide whether to accept from the acceptable field, never from decision. See kyt_complete.
  • Unsigned when no secret is configured. See Unsigned deliveries.
  • Two naming styles. Casino events use snake_case fields and an event field. The generic transaction.<status> events use camelCase fields (except entity_external_code) and have no event field.