Skip to Content
Integration guide

Integration guide

This page is the whole integration in one place: the widget on your site, the calls that your server makes, the events that we send you, the order of the steps and the errors. It describes the contract that is live today. Each section links to the page of this portal that holds the full detail.

This page follows the widget way. If you build your own UI on the JSON data of the Market Feed instead, skip section 1 and step 3, and read the markets with the Market Feed guide. Your server then opens positions with POST /v1/positions, so we send no position.opened for them. User registration, balance sync, the webhooks for settled and voided positions, the errors and the go-live checklist are the same for both ways. Two ways to integrate compares them.

To keep a copy, print this page from the browser or save it as a PDF.

How the money moves

No real money moves through us. For each of your users we keep a virtual balance in our ledger, and that balance follows your wallet:

  1. You register the user and report every balance change on your side with a balance sync. The virtual balance moves by the same amount.
  2. The user opens a position in the widget. We take the stake from the virtual balance and send you position.opened.
  3. The market resolves. We settle the position and send you position.settled with the payout. If the market is voided, we refund the stake and send position.voided. If the user closes the position before the market resolves, we pay the close amount into the virtual balance and send position.closed.
  4. Reconciliation compares our balance with the balance that you report, and tells you when the two differ.

We never call your wallet and we never pull data from your system. You learn of every change to the virtual balance from the response to your own request or from a webhook.

1. Web widget integration

The widget shows the markets and the trading screens on your page. Your server opens a session for the user (step 3) and your page passes the session token to mount().

Once you have a token, this is the whole embed:

<script src="{{CDN_URL}}/widget/v1/widget.iife.js"></script> <div id="prediction-market"></div> <script> const widget = PredictionMarketWidget.mount({ target: document.getElementById('prediction-market'), sessionToken: '{{SESSION_TOKEN}}', apiBaseUrl: '{{API_BASE_URL}}', onSessionExpired: () => { // ask your backend for a new session token, then call widget.setSession(token) }, // optional locale: 'en', scheme: 'dark', theme: { accent: '#7c5cff', }, }); </script>

{{SESSION_TOKEN}} is the token that your server minted for this page load. {{API_BASE_URL}} is the API address from the table on the Getting started page. Test keys and live keys use the same address.

FieldRequiredNotes
targetyesThe element that the widget mounts into.
sessionTokenyesThe per-user token from above. Never your tenant API key.
onSessionExpiredyesCalled when the session can no longer be refreshed. Mint a new token and pass it to setSession().
apiBaseUrlnoThe address of our API: http or https, the host and an optional port, with no path, query or hash. The widget sends every request there. If you leave it out, the widget calls your page’s own origin, which suits a page that proxies /v1 on its own origin. For a direct call, the allowed origins apply.
localenoA BCP 47 tag. See Locale.
schemenolight or dark. It wins over the scheme of your tenant setting. See Theme.
themenoValues for the theme tokens. See Theme.
onLoginRequestwith an anonymous tokenCalled when a visitor who has not logged in presses the log-in button in the trade panel. See Anonymous visitors.
onReady, onDepositRequest, onPositionOpened, onPositionClosednoFunctions the widget calls when something happens inside it. See Callbacks.
routenoThe view the widget opens on: { view: 'list' }, { view: 'market', slug }, { view: 'portfolio' } or { view: 'leaderboard' }. The list when you leave it out. See Routes.
onRouteChangenoCalled with the new route after the player moves between views inside the widget. See Routes.
hostNavigationnotrue when your page offers its own way to the portfolio and the leaderboard. The widget then leaves out its own Portfolio and Leaderboard buttons in the list header. false when you leave it out.

mount() returns a handle with these methods:

MethodWhat it does
unmount()Removes what the widget added to your element, stops its requests and timers, and drops the token from memory. A second call does nothing.
navigate(route)Switches the widget to route, for your page’s back and forward buttons. It does not read the config or the session again, and it does not call onRouteChange. A malformed route throws.
refreshBalance()Reads the player’s balance again. See Callbacks.
refreshConfig()Reads GET /v1/widget/config now instead of at the next timed read, and applies a theme change. It returns a promise that settles when the read is done. See Theme.
applyTheme({ theme, card, scheme })Applies theme values, card options and a scheme on top of your tenant setting, without a new mount. Each call replaces the previous one, so applyTheme({}) returns to the tenant setting. A value the widget cannot accept is skipped.
setTheme(theme)Replaces only the theme values you set at run time. The card options and the scheme from earlier calls stay.
setScheme(scheme)Replaces only the scheme you set at run time, for example when your page switches between light and dark. The theme values and card options from earlier calls stay.
setLocale(locale)Switches the widget to another locale, for example when the player changes the language of your site. It resolves the locale as the locale option does and redraws the view in it. It does not read the config again. While a stake or a sale is being sent, the view stays as it is and the next view uses the new locale.
setSession(token)Gives the widget a new token for the same player, for example from onSessionExpired. A request already on its way finishes with the old token; every later request and the live updates use the new one. A widget on its session-expired screen opens again. On a widget mounted with an anonymous token, a player’s token is the login, and the same widget trades from then on. See Anonymous visitors. It throws on an empty token or a tenant API key, as mount() does, and on an anonymous token after a player’s token.

When the player logs out of your site, call unmount(). When another player logs in, call unmount() and then mount() with that player’s token; setSession() is not for a different player. The one exception is the first login on a widget mounted with an anonymous token. After unmount(), every method does nothing, but navigate() and setSession() still throw on a malformed argument.

Do not paste your tenant API key (the pmk_ key) into sessionToken. The widget checks for that prefix on every mount. If it matches, or if any other option is malformed, mount() writes the reason to the browser console and throws a synchronous Error, and nothing renders. Code after the call stops running unless you wrap the call in a try/catch. This way your key does not reach every visitor of the page.

Theme

The widget uses a fixed set of theme tokens. You can set a value for each of them. You cannot add a token, and you cannot inject your own CSS.

TokenWhat it sets
background, surface, textThe page, card and text colors.
accentThe accent color of buttons and highlights. The widget derives the text color on an accent button, so the text stays readable.
positive, negativeThe colors for gains and losses.
mutedThe color of secondary text. If you do not set it, the widget uses text at 60%.
card-borderThe border color of cards. If you do not set it, the widget uses text at 14%.
border-widthThe border width, a px length from 0 to 8px. The default is 1px.
radiusThe corner radius of cards and panels.
button-radiusThe corner radius of every button and control. If you do not set it, each control keeps its own default corner, most of them taken from radius.
font-familyA comma-separated list of font family names. Each name may contain letters, digits, spaces, underscores and hyphens, and may be quoted. The whole value has at most 200 characters. Any other punctuation is refused.
font-sizeThe base text size. The default is 14px. Every other text size scales with it. A form field keeps its font at 16 px or more.
spacingThe unit that every gap and padding is a multiple of. The default is 4px. A control keeps its 44 px touch target.

font-size and spacing take a px or rem length, or 0. The widget ships no web font. It uses the system fonts by default, and a font-family value names a font that your page already loads.

Two places set the tokens, and the second wins:

  1. The tenant default. Our team sets it in the admin panel, and you can edit all of the tokens in the card theme editor of the tenant portal. The widget reads it from GET /v1/widget/config before its first paint, and again every 15 minutes while its tab is visible. Call refreshConfig() to read it at once.
  2. The theme option of mount(). It sets the values for one embed only, for example on a dark section of your page. A token that you leave out keeps the tenant default, and then the widget’s own default.

The scheme option follows the same rule. It is not a token, so it has its own option. Two embeds on one page can use two schemes.

The Widget guide also covers the bundle paths, the token refresh, the allowed origins, the locale and the phone layout.

Each entry gives a symptom, its cause and the fix. Open the browser console first. The widget writes the reason for a failed mount there, and the browser reports CORS and Content-Security-Policy blocks there too.

A CORS error in the console, and the widget stays empty

Cause: the origin of your page is not on your allowed list. Our API then sends no CORS headers for that origin, and the browser blocks every response.

Fix: open a support ticket with the exact origin, with its scheme and any port that is not the default. https://shop.example.com and http://shop.example.com are two origins, and so are http://localhost:3000 and http://localhost:5173. See Allowed origins.

If you leave out apiBaseUrl, the widget calls your own origin, and no CORS applies. Your page must then proxy /v1 and the realtime path /connection/websocket to our API.

The widget stays empty in a native app

Cause: the WebView loads the page from a local file or from an app-specific scheme such as capacitor://localhost. We register only http and https origins, so the browser blocks the calls.

Fix: let the WebView open a page of yours over https, and register the origin of that page.

A Content-Security-Policy violation in the console

Cause: your page sends a Content-Security-Policy that does not allow a source the widget uses. A page without a policy needs none of the changes below.

Fix: add these sources to your policy. The widget needs no other source.

DirectiveSource to allow
script-srcThe origin of {{CDN_URL}}. The locale files load from the same path prefix, and this source covers them too.
connect-srcThe origin of apiBaseUrl, for every feed, session, position and config call. Also the WebSocket on the same host with the wss scheme, or ws for an http address: the widget connects to wss://{host}/connection/websocket. Without apiBaseUrl, both are your own origin.
style-src'unsafe-inline'. The widget puts one <style> element in its shadow root, and a policy checks it as it checks any inline style. Without the keyword, the widget draws with no styles. There is no nonce option.
img-srcThe hosts of the market images and of your brand assets. For an image that we host, this is the host of its URL. A blocked image does not break the widget: the card draws without it.

The widget needs no 'unsafe-eval', no blob: or data: source, no frame-src and no font-src. It runs no eval, creates no frame and loads no font file. The widget is not an iframe, so your frame-ancestors and X-Frame-Options settings do not affect it.

The widget shows English text instead of your locale

Cause: the locale file did not load. The widget loads it with import(), which your script-src must allow, and which needs a CORS header from our CDN. The widget then falls back to English and writes one error to the console.

Fix: check that script-src allows the origin of {{CDN_URL}}. If the console shows a CORS error for a file under /widget/v1/locales/, open a support ticket, because the fix is on our CDN.

mount() throws, and nothing renders

Cause: an option failed the check at boot. Common cases are a tenant API key (a pmk_ value) in sessionToken, a callback that is not a function, and an apiBaseUrl with a path, a query or a hash. The widget shows nothing to the end user. This state is widget_config_invalid.

Fix: read the reason in the console, and correct the option. Pass the token that your server minted for this page load.

The widget draws as a narrow strip

Cause: the target element has no width of its own, for example an inline-block, a float, or a flex item with no width set.

Fix: give the target element a width, or use a plain block <div>. See Width and phones.

The player sees “Your session has expired” after some time

Cause: the token expired, and the automatic refresh failed. A refresh answers 401 invalid_session_token when the session is past its 24-hour cap, when the token already expired, or when we suspended your tenant. This state is widget_session_expired.

Fix: in onSessionExpired, mint a new token for the same player on your server and pass it to widget.setSession(token). The widget opens again where the player was. If a different player logs in, call unmount() and then mount() with that player’s token instead. See Refresh and expiry.

Error codes the widget shows

These codes are states of the widget in the browser. Four of them never appear in an HTTP response. The error-code registry lists every code.

CodeWhat the player seesWhat to do
widget_config_invalidNothing. The reason is in the console only.Fix the embed option that the console names.
widget_session_expired“Your session has expired. Please refresh the page.”Mint a new token in onSessionExpired and mount again.
widget_paused“The widget is temporarily unavailable. Please check back shortly.”Nothing. We paused session mint, refresh and the config read. The mint route answers 503 widget_paused to your server meanwhile. Retry after we clear the pause.
realtime_unavailable“Live updates are unavailable right now. Prices may be out of date.”Nothing. The widget used all its reconnect attempts, reads by polling, and keeps trying to reconnect. If it stays, check connect-src for the WebSocket.
widget_runtime_errorDepends on the error.Nothing. The widget reports the error to us.

2. Operator API integration

Your server calls our API over HTTPS. Every request carries your tenant API key in an Authorization: Bearer header. Use the key from your server only, never in a browser or in the widget. The key format, test and live mode and the scope of each key are on the Getting started page.

Endpoints

These are the endpoints that your server calls. We generate the table from the same OpenAPI document as the API reference, which has every request and response schema.

AreaEndpointWhat it does
usersGET /v1/users/{external_user_id}Get a user
usersPUT /v1/users/{external_user_id}Save a user
balance-syncPOST /v1/balance-syncApply a balance movement
balance-syncPOST /v1/balance-sync/batchApply balance movements in a batch
positionsGET /v1/position-attemptsList rejected position attempts
positionsGET /v1/positionsList positions
positionsPOST /v1/positionsOpen a position
positionsGET /v1/positions/{id}Get a position
positionsPOST /v1/positions/{id}/closeClose a position
positionsGET /v1/positions/{id}/close-quoteGet a close quote
positionsGET /v1/transactionsList ledger transactions
ordersGET /v1/ordersList limit orders
ordersPOST /v1/ordersPlace a limit order
ordersGET /v1/orders/{id}Get a limit order
ordersDELETE /v1/orders/{id}Cancel a limit order
widgetPOST /v1/widget/sessionsOpen a widget session
realtimePOST /v1/realtime/tokensGet a realtime connection token
reportsPOST /v1/reportsRequest a report
reportsGET /v1/reports/{id}Get a report status
reportsGET /v1/reports/{id}/downloadDownload a report
support-ticketsPOST /v1/support-ticketsOpen a support ticket

The examples below come from our contract test fixtures, the same ones that we run against the API. A value in double braces, such as {{api_key}}, is a placeholder.

Step 1: register the user

Register a user before any other call for that user. external_user_id is your own id for the user. Do not use an email address or other personal data as the id. The call needs the write:sync scope, and it is safe to repeat.

PUT /v1/users/{{external_user_id}} Authorization: Bearer {{api_key}} Content-Type: application/json { "status": "active" }
HTTP 200 { "external_user_id": "player-1", "status": "active" }

The rules for the id and the status are in Getting started.

Step 2: sync the balance

Each time the balance of a user changes on your side, send us the change. direction is credit or debit, amount is positive, and amounts are JSON strings. Send resulting_balance, the balance that your system shows after the change: reconciliation compares against it. The example leaves it out to show the smallest valid body. The call needs the write:sync scope and an Idempotency-Key.

POST /v1/balance-sync Authorization: Bearer {{api_key}} Content-Type: application/json Idempotency-Key: {{idempotency_key}} { "external_user_id": "{{external_user_id}}", "direction": "credit", "amount": "100.00" }
HTTP 200 { "transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "balance": "142.500000" }

Compare the balance in each response with your own record. To send up to 500 changes in one request, use POST /v1/balance-sync/batch. The replay rules of Idempotency-Key are in Getting started.

Step 3: open a widget session

When the user loads your page, your server opens a session for that user and passes the token to your page for mount(). Open a new session on every page load. The call needs the write:positions scope, and the user must be registered.

POST /v1/widget/sessions Authorization: Bearer {{api_key}} Content-Type: application/json { "external_user_id": "{{external_user_id}}" }
HTTP 200 { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.example.signature" }

The widget refreshes the token by itself. When it can no longer do so, it calls onSessionExpired (Widget guide).

Step 4: read positions

Read the positions of a user with GET /v1/positions, or one position by its id. The calls need the write:positions scope.

GET /v1/positions/{{position_id}} Authorization: Bearer {{api_key}}
HTTP 200 { "id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0c", "external_user_id": "player-1", "market_slug": "will-it-rain-tomorrow", "outcome_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0d", "state": "open", "stake": "30.000000", "price": "0.400000", "payout": null, "open_transaction_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0e", "settlement_transaction_id": null, "created_at": "2026-09-01T12:00:00Z", "settled_at": null }

Your server can also open and close positions itself, with POST /v1/positions and POST /v1/positions/{id}/close. For a position that your server opens or closes, we send no position.opened or position.closed webhook, because you already hold the response.

Rate limits

Each endpoint belongs to a limit tier. Over the limit, we answer 429 rate_limited with a Retry-After header and apply nothing. The limits are per minute:

TierEndpointsPer end userPer tenant
balance_sync
POST /v1/balance-sync
POST /v1/balance-sync/batch
None450/min
user_sync
PUT /v1/users/{external_user_id}
None3,000/min
positions
POST /v1/positions
POST /v1/positions/{id}/close
POST /v1/orders
DELETE /v1/orders/{id}
10/min4,500/min
default
every other /v1 route
60/min600/min
feed_read
GET /v1/markets
GET /v1/markets/{slug}
GET /v1/markets/{slug}/snapshot
GET /v1/markets/{slug}/history
60/min600/min
widget_session
POST /v1/widget/sessions
POST /v1/widget/sessions/refresh
GET /v1/widget/config
POST /v1/widget/error-reports
GET/PUT/DELETE /v1/widget/profile
GET /v1/widget/leaderboard
10/min13,500/min
users_me
GET /v1/users/me
60/min4,500/min
saved_markets
GET /v1/widget/saved-markets
PUT /v1/widget/saved-markets/{market_slug}
DELETE /v1/widget/saved-markets/{market_slug}
20/min5,400/min

The Rate limits page explains the tenant and end-user limits.

3. Webhooks

We send each event as an HTTPS POST to the webhook_url of your tenant. Every delivery carries three headers:

  • X-Webhook-Signature: sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your webhook_secret.
  • X-Webhook-Event: the event type.
  • X-Webhook-Delivery: the id of the delivery, also in the id field of the body.

Handle each delivery in this order:

  1. Compute the HMAC over the raw body bytes and compare it with X-Webhook-Signature in constant time. On a mismatch, reject the request.
  2. Delivery is at least once. If you already processed the X-Webhook-Delivery id, answer 2xx and stop.
  3. Record the event and answer 2xx. We retry any other answer five times. The last retry comes between about 3 hours 36 minutes and 10 hours 49 minutes after the first attempt.

Events arrive in no fixed order. For an event type that you do not know, answer 2xx and ignore the body. Code samples for the signature check in Node.js and Python are in the Webhook guide.

Body

Every body is one JSON object with these fields:

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

Events

EventWhen it fires
position.settledper 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
position.voidedper 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
market.resolvedper market resolved to an outcome via panel-based settlement, delivered to each tenant with the market enabled
market.voidedper market voided via the panel; every open position is refunded at its original stake
position.settlement_correctedper position that a settlement correction moves between settled_win and settled_loss. A position the correction leaves in its state sends nothing
market.resolution_correctedper market whose resolved outcome a confirmed settlement correction replaced, delivered to each tenant with the market enabled
position.void_correctedper 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
market.void_correctedper 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
reconciliation.discrepancyper detected ledger-vs-operator balance mismatch
order.filledper 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
report.readyper report job reaching status ready; the operator may poll GET /v1/reports/{id} instead
position.openedper 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
position.closedper 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
position.opening_reversedper 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

The fields of each event and a sample body are in the Webhook guide.

4. Integration flow

The order of the calls and the events, from user registration to reconciliation:

5. Errors

Every error response has the same JSON envelope. This one comes from a balance sync that used an Idempotency-Key again with a different body:

POST /v1/balance-sync Authorization: Bearer {{api_key}} Content-Type: application/json Idempotency-Key: {{idempotency_key}} { "external_user_id": "{{external_user_id}}", "direction": "credit", "amount": "50.00" }
HTTP 422 { "error": "idempotency_key_reused", "message": "Idempotency-Key was already used with a different request body", "details": null, "request_id": "0192a0b1-7c2e-7d3f-8a4b-5c6d7e8f9a0b", "ts": "2026-09-27T10:00:00.000000Z", "source": "lg" }

Branch on the error code, never on the message text. In a support request, quote request_id and source; request_id is also in the X-Request-Id response header. These are the codes that you can receive:

StatusCodeWhen it firesRetry and action
400validation_failedThe request body or query is malformed. On the position routes it also fires for an unknown market or outcome (a market the tenant has not enabled reads as unknown), for a market that is not open, and for a request outside the trading window of the market. A suspended market returns market_suspended instead.No. Fix the request and send it again. In the widget the market may have moved, so read it again before the next try.
400idempotency_key_requiredA write request has no Idempotency-Key header.No. Add the Idempotency-Key header and send the request again.
401invalid_api_keyThe API key is unknown, revoked or expired. The response does not say which.No. Get a valid key first: rotate it in the tenant portal or ask the platform team for a new one. Then send the request with it.
401invalid_session_tokenThe widget session token is missing, malformed, expired or revoked. The response does not say which.Refresh the session with POST /v1/widget/sessions/refresh, then retry the request with the same Idempotency-Key. If the refresh fails with this code too, the host page mints a new session.
403insufficient_scopeThe API key does not carry the scope that the endpoint needs. It also fires when an anonymous widget session, minted for a visitor who has not logged in, calls a route that acts for a user.No. Get a key with the scope this endpoint needs, then retry. For an anonymous widget session, mint a session for the user once they log in and pass it to setSession.
403tenant_suspendedThe tenant is suspended. A suspension also revokes the API keys, so you usually see invalid_api_key instead.No. The suspension is contractual, not technical. Contact the platform team.
403user_blockedThe end user has status blocked. A blocked user cannot open a position and cannot place a limit order. Closing a position, quoting a close and cancelling an order still work.No. Lift the block with a user upsert (PUT /v1/users), then retry.
404not_foundThe resource id in the request does not exist.No. Check the id in the request.
404unknown_userThe external_user_id has not been registered with a user upsert.No. Register the user first with PUT /v1/users, then retry the original request.
409would_overdrawA debit would take the virtual balance of the user below zero. Nothing is applied.Only after the stake is lowered or the balance of the user changes.
409position_terminalA close was requested for a position that already settled or was voided, or a close quote for one that already settled, was voided or was closed. Nothing is applied.No. The position is already over, and a retry does not change that. A single closed position has no correction flow.
409price_movedThe market price moved past the limit in the request. An open fails above max_price and a close fails below min_price. Nothing is applied.Quote again from the current price and confirm again. A new open is a new intent with a new Idempotency-Key and a new max_price; a new close carries a new min_price.
409market_suspendedThe market exists but is suspended. It can reopen. Opening, closing and quoting a position and placing a limit order fail while it is suspended.Yes, once the market reopens. The lifecycle events and the Market Feed tell you when it does.
409stake_limit_exceededThe stake is above the per-position cap of the market, or it would take the total open stake of the user on the market above the per-user cap. Nothing is applied.Only with a lower stake: details.remaining or less. The per-user allowance frees again at settlement, at a void, or when the user closes a position on that market.
409stake_below_minimumThe stake is below the per-position minimum of the market. Nothing is applied. The market detail of the feed serves the minimum in stake_limits.Only with a higher stake. Raise it to stake_limits.min_stake_per_position or more.
409tenant_exposure_limitThe open would take the model worst-case loss of your positions on this market above the market's limit. Nothing is applied. Closing a position is never refused for this reason.Only with a lower stake, or later: the allowance grows when positions on this market close or when the price moves.
409state_conflictA request to cancel a limit order found the order already filled, expired or rejected.No. Read the resource again and decide on what it is now.
409report_limit_reachedThe tenant already has the maximum number of report jobs in status generating. No job is created.Yes, once one of your report jobs has left status generating. Poll GET /v1/reports/{id} or wait for the report.ready webhook; there is no Retry-After.
409saved_markets_limit_reachedThe end user already has the maximum number of saved markets, and the market in the request is not one of them. Nothing is saved. Saving a market that is already saved still succeeds.Only after the user removes a saved market with DELETE /v1/widget/saved-markets/{market_slug}. The widget does not retry on its own.
409idempotency_key_in_progressAn earlier request with the same Idempotency-Key is still being processed. Nothing is applied by this request. The response carries a Retry-After header.Yes. Wait for Retry-After and retry with the same Idempotency-Key and the same body. A new key would be a second intent and can open a second position.
413request_too_largeThe request body is larger than the size limit of the route.No. Make the body smaller or split the batch.
422idempotency_key_reusedThe Idempotency-Key was seen before with a different request body. Nothing is applied.No. A retry must send the original body byte for byte. A new intent needs a new key.
429rate_limitedThe tenant or the end user exceeded the rate limit of the route. The response carries a Retry-After header.Yes. Wait for Retry-After and send the same request with the same Idempotency-Key, so a retried write never applies twice.
500internal_errorAn unexpected failure on our side.Yes for a write that carries the same Idempotency-Key. If it keeps failing, send us the request_id and source.
503sync_pausedWe paused the balance-sync endpoints with a kill switch.Yes, after we clear the pause. Queued syncs replay safely with their Idempotency-Key.
503positions_pausedWe paused the opening of positions with a kill switch.Yes, after we clear the pause. The widget shows a paused state meanwhile.
503feed_pausedWe paused the Market Feed reads with a kill switch.Yes, after we clear the pause.
503widget_pausedWe paused widget session mint, widget session refresh and the widget config read with a kill switch.Yes, after we clear the pause.
503market_not_readyThe state of the market is still rebuilding after a restart on our side. The response carries a Retry-After header.Yes. Wait for Retry-After and send the same request with the same Idempotency-Key, so a retried open never applies twice.
widgetrealtime_unavailableThe widget used all its reconnect attempts and now runs on the polling fallback.Nothing to do. The widget keeps trying to reconnect in the background.
widgetwidget_session_expiredThe widget session token expired and the automatic refresh failed.No retry from your side. The widget refreshes the session on its own, and this state shows only when that refresh failed.
widgetwidget_config_invalidThe embed parameters failed the check at widget boot. The widget reports it to the browser console and shows nothing to the end user.No. Fix the embed parameters.
widgetwidget_runtime_errorA client-side exception the widget caught at its own boundaries. The widget reports it to us with its error beacon.No. The widget reports it to us, and there is nothing to retry.

The FAQ explains the codes that operators meet most often.

Before you go live

Before we approve live access and you get your live key, your test traffic must show each item below. The Go-live checklist page explains the approval.

  1. User-sync round-trip: user upserted via PUT /v1/users/{external_user_id} and read back via GET with the expected status and balance.
  2. Idempotency replay test passed: a balance-sync replayed with the same Idempotency-Key and body returns the stored response byte-identical; the same key with a different body is rejected 422 with nothing applied.
  3. Position lifecycle exercised: a position opened in test mode against a seeded market and observed through settlement or void to its terminal state.
  4. Webhook receipt and signature verification confirmed: at least one signed event received at the operator's webhook_url and its X-Webhook-Signature HMAC verified before processing.
  5. Reconciliation cycle clean: at least one full scheduled reconciliation run over the operator's test-mode activity with zero discrepancies.
  6. Rate-limit behavior understood: the operator's client has observed a 429 rate_limited response and demonstrably honors Retry-After instead of blind-retrying.
Last updated on