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 that you gave us during onboarding. If no address is set, we make no delivery.

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 "")

Response and retry

After you record the event, answer with 2xx. We retry a response that is not 2xx. The retries last about 7 hours 45 minutes. After that, the delivery moves to the dead-letter queue, and our team can resend it from there.

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 tenant has one webhook_secret. Our team generates the new secret and gives 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. We make the change at a time that we agree 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 four 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)webhook_deliveries.id — equal to X-Webhook-Delivery, so the de-duplication key is under the signature
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
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 (12-position-transaction-api). 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)positions.id
external_user_idstringusers.external_user_id, never the internal uuid (12-position-transaction-api)
market_slugstringmarkets.slug, the operator-facing market identifier
outcome_idstring (uuid)market_outcomes.id the position was opened on
statestring (enum: settled_win | settled_loss)12-position-transaction-api api_states
stakestring (decimal)positions.stake
pricestring (decimal)positions.price at open
payoutstring (decimal)positions.payout; '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", "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.00", "price": "25.00", "payout": "25.00", "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 (12-position-transaction-api). Once per position: a later move to settled_win or settled_loss arrives as position.void_corrected

FieldTypeNote
position_idstring (uuid)positions.id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
statestring (const: voided)
stakestring (decimal)positions.stake
payoutstring (decimal)equal to stake — the full refund (12-position-transaction-api voided_markets)
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", "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.00", "payout": "25.00", "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 (tenant_markets, 15-database-schema)

FieldTypeNote
market_slugstringmarkets.slug
resolved_outcome_idstring (uuid)markets.resolved_outcome_id
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "market.resolved", "occurred_at": "2026-09-26T12:00:00Z", "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 (12-position-transaction-api)

FieldTypeNote
market_slugstringmarkets.slug; no outcome, no reason — the typed reason is panel audit data
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "market.voided", "occurred_at": "2026-09-26T12:00:00Z", "data": { "market_slug": "{{market_slug}}" } }

position.settlement_corrected

per position that a settlement correction moves between settled_win and settled_loss (12-position-transaction-api state_machine). A position the correction leaves in its state sends nothing

FieldTypeNote
position_idstring (uuid)positions.id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)market_outcomes.id 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)positions.payout before the correction; '0' for a former settled_loss
payoutstring (decimal)positions.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", "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.00", "payout": "25.00", "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 (41-admin-panel settlement_correction)

FieldTypeNote
market_slugstringmarkets.slug
previous_outcome_idstring (uuid)the resolved_outcome_id that the correction replaced
resolved_outcome_idstring (uuid)markets.resolved_outcome_id after the correction
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "market.resolution_corrected", "occurred_at": "2026-09-26T12:00:00Z", "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 (12-position-transaction-api state_machine). Added 5 Oct 2026 (PRE-636) 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)positions.id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)market_outcomes.id 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)positions.stake
previous_payoutstring (decimal)positions.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)positions.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", "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.00", "previous_payout": "25.00", "payout": "25.00", "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 (41-admin-panel settlement_correction). Added 5 Oct 2026 (PRE-636) as an additive change. The market sends no second market.resolved or market.voided, and no market.resolution_corrected

FieldTypeNote
market_slugstringmarkets.slug
previous_statusstring (enum: voided | resolved)markets.status before the correction
statusstring (enum: resolved | voided)markets.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) | nullmarkets.resolved_outcome_id 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", "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 (FM-03); lifecycle owned by 45-reconciliation-and-dlq

FieldTypeNote
discrepancy_idstring (uuid)reconciliation_discrepancies.id
report_idstring (uuid)reconciliation_reports.id of the 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 (45-reconciliation-and-dlq comparison_basis)
deltastring (decimal)ledger_balance - operator_balance
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "reconciliation.discrepancy", "occurred_at": "2026-09-26T12:00:00Z", "data": { "discrepancy_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "report_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "external_user_id": "{{external_user_id}}", "ledger_balance": "25.00", "operator_balance": "25.00", "delta": "25.00" } }

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 (12-position-transaction-api limit_orders.operator_notice). The other terminal order states send nothing

FieldTypeNote
order_idstring (uuid)limit_orders.id
position_idstring (uuid)the position the fill opened; limit_orders.position_id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
stakestring (decimal)limit_orders.stake, the amount debited
limit_pricestring (decimal)limit_orders.limit_price
fill_pricestring (decimal)the execution price, positions.price of the opened position; at or below limit_price
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "order.filled", "occurred_at": "2026-09-26T12:00:00Z", "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.00", "limit_price": "25.00", "fill_price": "25.00" } }

report.ready

per report job reaching status ready (report_jobs, 15-database-schema); the operator may poll GET /v1/reports/{id} instead (47-operator-reporting generation)

FieldTypeNote
report_idstring (uuid)report_jobs.id — the {id} of GET /v1/reports/{id} and its /download
report_typestring47-operator-reporting report_types name
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)report_jobs.expires_at — after it the artifact is gone and the job must be requested again
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "report.ready", "occurred_at": "2026-09-26T12:00:00Z", "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 (12-position-transaction-api opening_positions.operator_notice, decision 6 Oct 2026, PRE-703). 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)positions.id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
stakestring (decimal)positions.stake, the amount debited
pricestring (decimal)positions.price, the engine price at open
open_transaction_idstring (uuid)positions.open_transaction_id, the position_open_debit transaction
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "position.opened", "occurred_at": "2026-09-26T12:00:00Z", "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.00", "price": "25.00", "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 (12-position-transaction-api closing_positions.operator_notice, decision 6 Oct 2026, PRE-703). 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)positions.id
external_user_idstringas above
market_slugstringas above
outcome_idstring (uuid)as above
payoutstring (decimal)positions.payout, the close proceeds that the user received (12-position-transaction-api closing_positions.pricing)
close_pricestring (decimal)the close price, proceeds divided by shares (12-position-transaction-api closing_positions.pricing); the price at open stays on GET /v1/positions/{id}
settlement_transaction_idstring (uuid)positions.settlement_transaction_id, the position_close_credit transaction
{ "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "event": "position.closed", "occurred_at": "2026-09-26T12:00:00Z", "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.00", "close_price": "25.00", "settlement_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c" } }
Last updated on