Skip to Content
Webhook guide

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:

HeaderValue
X-Webhook-Signaturesha256=<HMAC-SHA256 of the raw body with webhook_secret, hex>
X-Webhook-EventThe event type, for example position.settled.
X-Webhook-DeliveryThe 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:

  1. 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.
  2. Compute HMAC-SHA256 over the raw body with webhook_secret.
  3. Join sha256= and the hex value, and compare the result with X-Webhook-Signature in constant time.
  4. 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:

  1. Check the balance-sync history of the named user on your side.
  2. If your reported balance was stale or wrong, send a corrected balance sync (POST /v1/balance-sync, with a new Idempotency-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.

FieldTypeNote
idstring (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
eventstringthe event_types name — equal to X-Webhook-Event
occurred_atstring (RFC 3339 UTC)when the reported state change committed; never the send time, which redelivery moves
livemodebooleantrue 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
dataobjectthe 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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringYour own user id, as in PUT /v1/users/{external_user_id}; never our internal user id
market_slugstringThe market slug, the identifier of the market in the API
outcome_idstring (uuid)The id of the outcome the position was opened on
statestring (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)
stakestring (decimal)The stake of the position
pricestring (decimal)The price at open
payoutstring (decimal)The amount credited; '0' for settled_loss
settlement_transaction_idstring (uuid) | nullthe 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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
statestring (const: voided)
stakestring (decimal)The stake of the position
payoutstring (decimal)equal to stake — the full refund
settlement_transaction_idstring (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

FieldTypeNote
market_slugstringThe market slug
resolved_outcome_idstring (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

FieldTypeNote
market_slugstringThe 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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)The id of the outcome the position was opened on
previous_statestring (enum: settled_win | settled_loss)the state before this correction
statestring (enum: settled_loss | settled_win)the state after it; never equal to previous_state
previous_payoutstring (decimal)The payout before the correction; '0' for a former settled_loss
payoutstring (decimal)The payout after it; '0' for settled_loss
reversed_transaction_idstring (uuid) | nullthe settlement_credit that the correction reversed; null when the position was a settled_loss
settlement_transaction_idstring (uuid) | nullthe 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

FieldTypeNote
market_slugstringThe market slug
previous_outcome_idstring (uuid)the resolved_outcome_id that the correction replaced
resolved_outcome_idstring (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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)The id of the outcome the position was opened on
previous_statestring (enum: voided | settled_win | settled_loss)the state before this correction
statestring (enum: settled_win | settled_loss | voided)the state after it; exactly one of previous_state and state is voided
stakestring (decimal)The stake of the position
previous_payoutstring (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
payoutstring (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_idstring (uuid) | nullthe 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_idstring (uuid) | nullthe 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

FieldTypeNote
market_slugstringThe market slug
previous_statusstring (enum: voided | resolved)The market status before the correction
statusstring (enum: resolved | voided)The market status after it; never equal to previous_status
previous_outcome_idstring (uuid) | nullthe resolved_outcome_id that the correction replaced; null when previous_status is voided
resolved_outcome_idstring (uuid) | nullThe 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

FieldTypeNote
discrepancy_idstring (uuid)The id of the discrepancy
report_idstring (uuid)The id of the reconciliation run that found it
external_user_idstringthe mismatched account's user
ledger_balancestring (decimal)our derived balance at check time
operator_balancestring (decimal)the operator's last reported balance; for a would_overdraw rejection that carried no resulting_balance, ledger_balance minus the refused debit
deltastring (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

FieldTypeNote
order_idstring (uuid)The limit order id, as in GET /v1/orders/{id}
position_idstring (uuid)The id of the position that the fill opened
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
stakestring (decimal)The stake, the amount debited
limit_pricestring (decimal)The limit price of the order
fill_pricestring (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

FieldTypeNote
report_idstring (uuid)The {id} of GET /v1/reports/{id} and its /download
report_typestringThe 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)
formatstring (enum: json | csv)the format requested
download_pathstring/v1/reports/{id}/download — a path, never a pre-signed store URL: the artifact is read through the API key and its scope
expires_atstring (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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
stakestring (decimal)The stake, the amount debited
pricestring (decimal)The engine price at open
open_transaction_idstring (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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
payoutstring (decimal)The close proceeds that the user received
close_pricestring (decimal)the close price, proceeds divided by shares; the price at open stays on GET /v1/positions/{id}
settlement_transaction_idstring (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

FieldTypeNote
position_idstring (uuid)The position id, as in GET /v1/positions/{id}
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
statestring (const: voided)
stakestring (decimal)The stake of the position
payoutstring (decimal)equal to stake: the inverted opening is the full refund
reversed_transaction_idstring (uuid)The id of the position_open_debit transaction that was reversed
settlement_transaction_idstring (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" } }
Last updated on