Webhooks
Get notified the moment something happens on the platform — registrations, payments, bonuses, KYC decisions and compliance alerts — with signed HTTPS callbacks to your servers.
Overview
Staff add webhook endpoints in the backoffice (Tools → API & Webhooks → Webhooks): an HTTPS URL, the events it subscribes to, and a signing secret shown once. A "Send test event" button posts a ping, and the delivery log shows every attempt with the response, ready to retry.
Events fire for everything that happens on your operator account — whether it came through the API, your KinetX-hosted website, the backoffice or a scheduled job.
Every delivery is a POST with a JSON body and these headers:
| X-KinetX-Event | Event type, e.g. deposit.paid. |
| X-KinetX-Event-Id | Unique event id (also in the body); use it to process each event once. |
| X-KinetX-Delivery | Delivery id, the same for every retry of this delivery. |
| X-KinetX-Timestamp | Unix time of this attempt, part of the signature. |
| X-KinetX-Signature | Hex HMAC-SHA256 of "{timestamp}.{raw body}" keyed with the endpoint secret. |
Events
The body always has the same envelope — id, type, api_version, created_at and the event data. Data objects are the same resources the API returns.
player.registered
A player registered (website, backoffice or API)
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "player.registered",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe",
"email": "jane@example.com",
"first_name": "Jane",
"middle_name": null,
"last_name": "Doe",
"gender": "female",
"birth_date": "1990-04-12",
"country": "MT",
"city": "Valletta",
"address": "12 Republic Street",
"zip_code": "VLT 1110",
"phone": "+35699123456",
"personal_id": null,
"document_number": "P1234567",
"language": "en",
"currency": "EUR",
"status": "active",
"category": "new_user",
"kyc_status": "not_started",
"is_verified": false,
"risk_level": "low",
"is_test": false,
"restrictions": {
"can_login": true,
"can_deposit": true,
"can_withdraw": true,
"can_bet": true,
"excluded_from_bonuses": false
},
"self_exclusion": null,
"marketing": {
"email": true,
"sms": false,
"newsletter": false
},
"registration": {
"source": "api",
"ip": "203.0.113.10",
"promo_code": "WELCOME100",
"affiliate_id": "aff-204",
"btag": null
},
"last_login_at": null,
"created_at": "2026-10-15T09:30:00Z",
"updated_at": "2026-10-15T09:30:00Z"
}
}
}
deposit.paid
A deposit was paid and credited to the wallet
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "deposit.paid",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"payment": {
"id": 5120,
"type": "deposit",
"status": "paid",
"player_id": 1042,
"amount": 10000,
"currency": "EUR",
"payment_method": "visa-mastercard",
"external_reference": "psp_tx_7781",
"info": null,
"reject_reason": null,
"is_reversed": false,
"created_at": "2026-10-15T09:30:00Z",
"paid_at": "2026-10-15T09:30:00Z",
"rejected_at": null,
"cancelled_at": null,
"updated_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
withdrawal.requested
A player requested a withdrawal (the amount is held)
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "withdrawal.requested",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"payment": {
"id": 5121,
"type": "withdrawal",
"status": "new",
"player_id": 1042,
"amount": 5000,
"currency": "EUR",
"payment_method": "visa-mastercard",
"external_reference": "wd_20261015_001",
"info": "Visa **** 4242",
"reject_reason": null,
"is_reversed": false,
"created_at": "2026-10-15T09:30:00Z",
"paid_at": null,
"rejected_at": null,
"cancelled_at": null,
"updated_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
withdrawal.paid
A withdrawal was paid out
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "withdrawal.paid",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"payment": {
"id": 5121,
"type": "withdrawal",
"status": "paid",
"player_id": 1042,
"amount": 5000,
"currency": "EUR",
"payment_method": "visa-mastercard",
"external_reference": "wd_20261015_001",
"info": "Visa **** 4242",
"reject_reason": null,
"is_reversed": false,
"created_at": "2026-10-15T09:30:00Z",
"paid_at": "2026-10-15T09:30:00Z",
"rejected_at": null,
"cancelled_at": null,
"updated_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
withdrawal.rejected
A withdrawal was rejected and the amount refunded
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "withdrawal.rejected",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"payment": {
"id": 5121,
"type": "withdrawal",
"status": "rejected",
"player_id": 1042,
"amount": 5000,
"currency": "EUR",
"payment_method": "visa-mastercard",
"external_reference": "wd_20261015_001",
"info": "Visa **** 4242",
"reject_reason": "Proof of address required",
"is_reversed": false,
"created_at": "2026-10-15T09:30:00Z",
"paid_at": null,
"rejected_at": "2026-10-15T09:30:00Z",
"cancelled_at": null,
"updated_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
bonus.granted
A bonus was granted to a player
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "bonus.granted",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"bonus": {
"id": 9001,
"player_id": 1042,
"bonus_id": 12,
"name": "Welcome Bonus 100%",
"type": "wagering_bonus",
"state": "activated",
"result": "none",
"amount": 20000,
"currency": "EUR",
"wager_requirement": 700000,
"wagered_amount": 175000,
"wagering_progress": 25,
"converted_amount": 0,
"forfeited_amount": 0,
"max_bet": 500,
"started_at": "2026-10-15T09:30:00Z",
"expires_at": "2026-11-14T09:30:00Z",
"finished_at": null,
"created_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
bonus.completed
A bonus finished (converted, lost, expired or cancelled)
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "bonus.completed",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"bonus": {
"id": 9001,
"player_id": 1042,
"bonus_id": 12,
"name": "Welcome Bonus 100%",
"type": "wagering_bonus",
"state": "finished",
"result": "paid",
"amount": 20000,
"currency": "EUR",
"wager_requirement": 700000,
"wagered_amount": 700000,
"wagering_progress": 100,
"converted_amount": 18250,
"forfeited_amount": 0,
"max_bet": 500,
"started_at": "2026-10-15T09:30:00Z",
"expires_at": "2026-11-14T09:30:00Z",
"finished_at": "2026-10-20T18:02:11Z",
"created_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
kyc.status_changed
The KYC status of a player changed
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "kyc.status_changed",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
},
"previous_status": "pending_review",
"status": "verified",
"is_verified": true
}
}
compliance.alert_created
The risk engine raised a compliance (AML) alert
{
"id": "evt_01jaq3w8z6x4t2v9r7p5n3m1k0",
"type": "compliance.alert_created",
"api_version": "v1",
"created_at": "2026-10-15T09:30:00Z",
"data": {
"alert": {
"id": 77,
"rule": "large_deposit",
"severity": "high",
"status": "open",
"created_at": "2026-10-15T09:30:00Z"
},
"player": {
"id": 1042,
"external_id": "op-58213",
"username": "jane_doe"
}
}
}
Delivery & retries
- Deliveries are sent right after the change is committed. Answer with any 2xx status within 5 seconds; redirects are not followed.
- Any other answer, a timeout or a connection error is retried with exponential backoff — after 1, 2, 4, 8, 16, 32 and 64 minutes — up to 8 attempts in total, then the delivery is marked failed. Staff can retry any delivery from the log.
- Events of a change that was rolled back are never sent.
- Deliveries can arrive more than once and out of order. Deduplicate on the event id and use created_at or the resource state to order them.
Verifying signatures
Compute the HMAC over the raw request body exactly as received, compare it in constant time, and reject timestamps older than five minutes:
<?php
$secret = getenv('KINETX_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input'); // the raw body, before any JSON parsing
$timestamp = $_SERVER['HTTP_X_KINETX_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_KINETX_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Deliveries can repeat: process each event id once.
if (! already_processed($event['id'])) {
handle($event['type'], $event['data']);
}
http_response_code(204);
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the raw body: the signature covers the exact bytes received.
app.post('/webhooks/kinetx', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-KinetX-Timestamp') ?? '';
const signature = req.get('X-KinetX-Signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.KINETX_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!valid) return res.sendStatus(400);
const event = JSON.parse(req.body);
queueForProcessing(event); // dedupe on event.id, answer fast
res.sendStatus(204);
});
Best practices
- Acknowledge fast and process asynchronously (queue the event, answer 204).
- Treat webhooks as notifications: when in doubt, fetch the current state from the API (e.g. GET /api/v1/operator/payments/{payment}).
- Subscribe each endpoint only to the events it needs, and rotate the secret if it may have leaked.