Webhook guide
We notify you of state changes with webhooks: position closes, market results, reconciliation discrepancies and ready reports. This page explains how to receive and verify the events.
Delivery
Each event arrives as an HTTPS POST at the webhook_url of its mode. Test mode and live mode each have their own address and their own webhook_secret: test events go only to the test address and live events only to the live one. A member with the settings role sets each address on the settings screen of the tenant portal, with the portal in that mode. If a mode has no address, we make no delivery in that mode.
Delivery is at least once. The same event can reach you more than once. Every delivery carries three headers:
| Header | Value |
|---|---|
X-Webhook-Signature | sha256=<HMAC-SHA256 of the raw body with webhook_secret, hex> |
X-Webhook-Event | The event type, for example position.settled. |
X-Webhook-Delivery | The unique id of the delivery. Remove duplicates with this value. |
The X-Webhook-Delivery value equals the id field of the signed body. For this reason, the de-duplication key is also covered by the signature.
Events have no ordering guarantee. For example, market.resolved can arrive before the positions of that market close, and retries also change the order. For the exact state, use the read endpoints.
Signature verification
Verify the signature before you trust a body:
- Receive the request and keep the raw body as bytes. Do not parse the JSON and write it again. The signature covers the raw body.
- Compute HMAC-SHA256 over the raw body with
webhook_secret. - Join
sha256=and the hex value, and compare the result withX-Webhook-Signaturein constant time. - If they do not match, reject the request and do not process the body.
The signed string has no timestamp, and we send no timestamp header. The X-Webhook-Delivery de-duplication stops a replayed delivery.
Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(rawBody, signatureHeader, webhookSecret) {
const expected = 'sha256=' + createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}Python:
import hashlib
import hmac
def verify_webhook(raw_body: bytes, signature_header: str, webhook_secret: str) -> bool:
expected = "sha256=" + hmac.new(webhook_secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")Test events and live events
Test and live mode explains how the two modes split your data and your endpoints.
Every body carries livemode: false for an event from test mode, true for one from live mode. Give each endpoint a fixed mode, and check livemode after the signature and before you act on the body:
// Set to false on the endpoint that receives test events.
const ENDPOINT_IS_LIVE = true;
export function handleWebhook(rawBody, headers, webhookSecret) {
if (!verifyWebhook(rawBody, headers['x-webhook-signature'], webhookSecret)) {
return 401;
}
const event = JSON.parse(rawBody);
if (event.livemode !== ENDPOINT_IS_LIVE) {
// Acknowledge so that we do not retry it, but do not act on it.
return 200;
}
// Process the event here.
return 200;
}Never let a test event reach the code that moves real money. A test position.settled is not a real win. Keep a separate address and a separate secret for each mode, and keep the livemode check as a second guard behind them.
Response and retry
After you record the event, answer with 2xx. We retry a response that is not 2xx up to five times, after base delays of 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Each delay is random between half and one and a half times its base, so the last retry comes between about 3 hours 36 minutes and 10 hours 49 minutes after the first attempt (7 hours 12 minutes 30 seconds without the spread). After the last failed retry, the delivery moves to the dead-letter queue, and our team can resend it from there. Plan for the short end: a receiver that is down for more than about 3.5 hours can lose deliveries to the dead-letter queue.
If an X-Webhook-Event value arrives that you do not know, answer with 2xx and ignore the body. We can add new event types without notice. Also ignore body fields that you do not know.
Secret change
Each mode has its own webhook_secret. You can generate a new secret for the current mode on the settings screen of the tenant portal, and we show it once. Our team can also generate it and give it to you. The old secret becomes invalid at that moment. Until you put the new secret in production, your receiver rejects the incoming deliveries. No delivery is lost in this interval: we retry the rejected deliveries and sign them with the new secret at the next attempt. When our team makes the change, we agree the time with you.
When a reconciliation discrepancy arrives
reconciliation.discrepancy says that the balance in our ledger and the balance that you reported are different. When this event arrives:
- Check the balance-sync history of the named user on your side.
- If your reported balance was stale or wrong, send a corrected balance sync (
POST /v1/balance-sync, with a newIdempotency-Key).
We correct our side separately. Our team examines the discrepancy in the admin panel and corrects it with a reversing transaction. That correction is not your task.
Reconciliation explains when reconciliation runs and what it compares.
Body structure
Every delivery body is one JSON object with five fields. data carries the object that is specific to the event. Amounts are decimal strings, and times are UTC in RFC 3339 format.
| Field | Type | Note |
|---|---|---|
id | string (uuid) | The unique delivery id, equal to the X-Webhook-Delivery header. It is inside the signed body, so use it as the de-duplication key |
event | string | the event_types name — equal to X-Webhook-Event |
occurred_at | string (RFC 3339 UTC) | when the reported state change committed; never the send time, which redelivery moves |
livemode | boolean | true for an event of live mode, false for one of test mode. Each mode delivers to its own endpoint; check it anyway before acting on an event |
data | object | the per-event object below |
Event types
We generate the tables and sample bodies below from the event schemas of the operator contract. Values such as {{market_slug}} in the samples are placeholders.
position.settled
per position reaching settled_win or settled_loss, with its payout. Once per position: a later move between the two states arrives as position.settlement_corrected, and a later move to voided as position.void_corrected
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | Your own user id, as in PUT /v1/users/{external_user_id}; never our internal user id |
market_slug | string | The market slug, the identifier of the market in the API |
outcome_id | string (uuid) | The id of the outcome the position was opened on |
state | string (enum: settled_win | settled_loss) | settled_win: the outcome of the position won, and payout is credited. settled_loss: it lost, and payout is '0'. A later settlement correction can move the position to the other state (position.settlement_corrected) or to voided (position.void_corrected) |
stake | string (decimal) | The stake of the position |
price | string (decimal) | The price at open |
payout | string (decimal) | The amount credited; '0' for settled_loss |
settlement_transaction_id | string (uuid) | null | the settlement_credit transaction; null for settled_loss |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.settled",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"state": "settled_win",
"stake": "25.5",
"price": "0.62",
"payout": "25.5",
"settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}position.voided
per position refunded by a market void — voided positions push position.voided, never position.settled with state voided. Once per position: a later move to settled_win or settled_loss arrives as position.void_corrected
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | as above |
state | string (const: voided) | |
stake | string (decimal) | The stake of the position |
payout | string (decimal) | equal to stake — the full refund |
settlement_transaction_id | string (uuid) | the market_void_refund transaction |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.voided",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"state": "voided",
"stake": "25.5",
"payout": "25.5",
"settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}market.resolved
per market resolved to an outcome via panel-based settlement, delivered to each tenant with the market enabled
| Field | Type | Note |
|---|---|---|
market_slug | string | The market slug |
resolved_outcome_id | string (uuid) | The id of the winning outcome |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "market.resolved",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"market_slug": "{{market_slug}}",
"resolved_outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}market.voided
per market voided via the panel; every open position is refunded at its original stake
| Field | Type | Note |
|---|---|---|
market_slug | string | The market slug. The event carries no outcome and no reason |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "market.voided",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"market_slug": "{{market_slug}}"
}
}position.settlement_corrected
per position that a settlement correction moves between settled_win and settled_loss. A position the correction leaves in its state sends nothing
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | The id of the outcome the position was opened on |
previous_state | string (enum: settled_win | settled_loss) | the state before this correction |
state | string (enum: settled_loss | settled_win) | the state after it; never equal to previous_state |
previous_payout | string (decimal) | The payout before the correction; '0' for a former settled_loss |
payout | string (decimal) | The payout after it; '0' for settled_loss |
reversed_transaction_id | string (uuid) | null | the settlement_credit that the correction reversed; null when the position was a settled_loss |
settlement_transaction_id | string (uuid) | null | the new settlement_credit; null when the position is now a settled_loss |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.settlement_corrected",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"previous_state": "settled_win",
"state": "settled_loss",
"previous_payout": "25.5",
"payout": "25.5",
"reversed_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"settlement_transaction_id": null
}
}market.resolution_corrected
per market whose resolved outcome a confirmed settlement correction replaced, delivered to each tenant with the market enabled
| Field | Type | Note |
|---|---|---|
market_slug | string | The market slug |
previous_outcome_id | string (uuid) | the resolved_outcome_id that the correction replaced |
resolved_outcome_id | string (uuid) | The id of the winning outcome after the correction |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "market.resolution_corrected",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"market_slug": "{{market_slug}}",
"previous_outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"resolved_outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}position.void_corrected
per position that a void correction moves from voided to settled_win or settled_loss, or from settled_win or settled_loss to voided. Added 5 Oct 2026 as an additive change. A position the correction leaves in its state sends nothing, and the moved position sends no second position.settled or position.voided
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | The id of the outcome the position was opened on |
previous_state | string (enum: voided | settled_win | settled_loss) | the state before this correction |
state | string (enum: settled_win | settled_loss | voided) | the state after it; exactly one of previous_state and state is voided |
stake | string (decimal) | The stake of the position |
previous_payout | string (decimal) | The payout before the correction: the stake for a former voided, stake / price at open for a former settled_win, '0' for a former settled_loss |
payout | string (decimal) | The payout after it: the stake when now voided, stake / price at open for a new settled_win, '0' for settled_loss |
reversed_transaction_id | string (uuid) | null | the transaction that the correction reversed: the market_void_refund of a former voided, the settlement_credit of a former settled_win; null for a former settled_loss |
settlement_transaction_id | string (uuid) | null | the new transaction that ends the position: the settlement_credit of a new settled_win, the market_void_refund of a new voided; null for a new settled_loss |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.void_corrected",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"previous_state": "voided",
"state": "settled_win",
"stake": "25.5",
"previous_payout": "25.5",
"payout": "25.5",
"reversed_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}market.void_corrected
per market that a confirmed void correction moved from voided to resolved or from resolved to voided, delivered to each tenant with the market enabled. Added 5 Oct 2026 as an additive change. The market sends no second market.resolved or market.voided, and no market.resolution_corrected
| Field | Type | Note |
|---|---|---|
market_slug | string | The market slug |
previous_status | string (enum: voided | resolved) | The market status before the correction |
status | string (enum: resolved | voided) | The market status after it; never equal to previous_status |
previous_outcome_id | string (uuid) | null | the resolved_outcome_id that the correction replaced; null when previous_status is voided |
resolved_outcome_id | string (uuid) | null | The id of the winning outcome after the correction; null when status is voided |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "market.void_corrected",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"market_slug": "{{market_slug}}",
"previous_status": "voided",
"status": "resolved",
"previous_outcome_id": null,
"resolved_outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}reconciliation.discrepancy
per detected ledger-vs-operator balance mismatch
| Field | Type | Note |
|---|---|---|
discrepancy_id | string (uuid) | The id of the discrepancy |
report_id | string (uuid) | The id of the reconciliation run that found it |
external_user_id | string | the mismatched account's user |
ledger_balance | string (decimal) | our derived balance at check time |
operator_balance | string (decimal) | the operator's last reported balance; for a would_overdraw rejection that carried no resulting_balance, ledger_balance minus the refused debit |
delta | string (decimal) | ledger_balance minus the expected balance: operator_balance plus the position movement after that report; equals ledger_balance - operator_balance when no position moved |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "reconciliation.discrepancy",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"discrepancy_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"report_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"ledger_balance": "25.5",
"operator_balance": "25.5",
"delta": "25.5"
}
}order.filled
per limit order fill: the engine opened a position without a request from the operator, so the operator learns of the debit here. The other terminal order states send nothing
| Field | Type | Note |
|---|---|---|
order_id | string (uuid) | The limit order id, as in GET /v1/orders/{id} |
position_id | string (uuid) | The id of the position that the fill opened |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | as above |
stake | string (decimal) | The stake, the amount debited |
limit_price | string (decimal) | The limit price of the order |
fill_price | string (decimal) | The execution price, the price at open of the new position; at or below limit_price |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "order.filled",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"order_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"stake": "25.5",
"limit_price": "0.62",
"fill_price": "0.62"
}
}report.ready
per report job reaching status ready; the operator may poll GET /v1/reports/{id} instead
| Field | Type | Note |
|---|---|---|
report_id | string (uuid) | The {id} of GET /v1/reports/{id} and its /download |
report_type | string | The report_type that POST /v1/reports was called with, for example activity_summary (the totals of the period), position_transaction_detail (every position and transaction of the period) or reconciliation_statement (opening balance, entries and closing balance, with the discrepancies of the period) |
format | string (enum: json | csv) | the format requested |
download_path | string | /v1/reports/{id}/download — a path, never a pre-signed store URL: the artifact is read through the API key and its scope |
expires_at | string (RFC 3339 UTC) | After this time the artifact is gone, and the report must be requested again |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "report.ready",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"report_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"report_type": "{{report_type}}",
"format": "json",
"download_path": "{{download_path}}",
"expires_at": "2026-09-26T12:00:00Z"
}
}position.opened
per position that a widget session opened without a request from the operator: POST /v1/positions with a widget session token, so the operator learns of the debit here. A position that the operator opens with its tenant API key sends nothing, because the operator holds the response. A limit order fill sends order.filled, never position.opened
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | as above |
stake | string (decimal) | The stake, the amount debited |
price | string (decimal) | The engine price at open |
open_transaction_id | string (uuid) | The id of the position_open_debit transaction |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.opened",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"stake": "25.5",
"price": "0.62",
"open_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}position.closed
per position that a widget session closed without a request from the operator: POST /v1/positions/{id}/close with a widget session token, so the operator learns of the credit here. A position that the operator closes with its tenant API key sends nothing. Once per position: a repeat of the close sends nothing
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | as above |
payout | string (decimal) | The close proceeds that the user received |
close_price | string (decimal) | the close price, proceeds divided by shares; the price at open stays on GET /v1/positions/{id} |
settlement_transaction_id | string (uuid) | The id of the position_close_credit transaction |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.closed",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"payout": "25.5",
"close_price": "0.62",
"settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}position.opening_reversed
per open position that the reversal of its own opening debit ends as voided (open to voided). The reversal is a panel call (POST /admin/v1/tenants/{id}/ledger/transactions/{transaction_id}/reverse), so the operator learns of the refund only here. It is not position.voided: that event stays scoped to market voids. A replay of the reversal sends nothing, and reversing the opening of a position that is no longer open sends nothing
| Field | Type | Note |
|---|---|---|
position_id | string (uuid) | The position id, as in GET /v1/positions/{id} |
external_user_id | string | as above |
market_slug | string | as above |
outcome_id | string (uuid) | as above |
state | string (const: voided) | |
stake | string (decimal) | The stake of the position |
payout | string (decimal) | equal to stake: the inverted opening is the full refund |
reversed_transaction_id | string (uuid) | The id of the position_open_debit transaction that was reversed |
settlement_transaction_id | string (uuid) | The id of the reversing transaction itself |
{
"id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"event": "position.opening_reversed",
"occurred_at": "2026-09-26T12:00:00Z",
"livemode": false,
"data": {
"position_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"external_user_id": "{{external_user_id}}",
"market_slug": "{{market_slug}}",
"outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"state": "voided",
"stake": "25.5",
"payout": "25.5",
"reversed_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c",
"settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c"
}
}