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:
- You register the user and report every balance change on your side with a balance sync. The virtual balance moves by the same amount.
- The user opens a position in the widget. We take the stake from the virtual balance and send you
position.opened. - The market resolves. We settle the position and send you
position.settledwith the payout. If the market is voided, we refund the stake and sendposition.voided. If the user closes the position before the market resolves, we pay the close amount into the virtual balance and sendposition.closed. - 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.
| Field | Required | Notes |
|---|---|---|
target | yes | The element that the widget mounts into. |
sessionToken | yes | The per-user token from above. Never your tenant API key. |
onSessionExpired | yes | Called when the session can no longer be refreshed. Mint a new token and pass it to setSession(). |
apiBaseUrl | no | The 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. |
locale | no | A BCP 47 tag. See Locale. |
scheme | no | light or dark. It wins over the scheme of your tenant setting. See Theme. |
theme | no | Values for the theme tokens. See Theme. |
onLoginRequest | with an anonymous token | Called when a visitor who has not logged in presses the log-in button in the trade panel. See Anonymous visitors. |
onReady, onDepositRequest, onPositionOpened, onPositionClosed | no | Functions the widget calls when something happens inside it. See Callbacks. |
route | no | The view the widget opens on: { view: 'list' }, { view: 'market', slug }, { view: 'portfolio' } or { view: 'leaderboard' }. The list when you leave it out. See Routes. |
onRouteChange | no | Called with the new route after the player moves between views inside the widget. See Routes. |
hostNavigation | no | true 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:
| Method | What 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.
| Token | What it sets |
|---|---|
background, surface, text | The page, card and text colors. |
accent | The accent color of buttons and highlights. The widget derives the text color on an accent button, so the text stays readable. |
positive, negative | The colors for gains and losses. |
muted | The color of secondary text. If you do not set it, the widget uses text at 60%. |
card-border | The border color of cards. If you do not set it, the widget uses text at 14%. |
border-width | The border width, a px length from 0 to 8px. The default is 1px. |
radius | The corner radius of cards and panels. |
button-radius | The 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-family | A 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-size | The 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. |
spacing | The 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:
- 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/configbefore its first paint, and again every 15 minutes while its tab is visible. CallrefreshConfig()to read it at once. - The
themeoption ofmount(). 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.
| Directive | Source to allow |
|---|---|
script-src | The origin of {{CDN_URL}}. The locale files load from the same path prefix, and this source covers them too. |
connect-src | The 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-src | The 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.
| Code | What the player sees | What to do |
|---|---|---|
widget_config_invalid | Nothing. 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_error | Depends 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.
| Area | Endpoint | What it does |
|---|---|---|
| users | GET /v1/users/{external_user_id} | Get a user |
| users | PUT /v1/users/{external_user_id} | Save a user |
| balance-sync | POST /v1/balance-sync | Apply a balance movement |
| balance-sync | POST /v1/balance-sync/batch | Apply balance movements in a batch |
| positions | GET /v1/position-attempts | List rejected position attempts |
| positions | GET /v1/positions | List positions |
| positions | POST /v1/positions | Open a position |
| positions | GET /v1/positions/{id} | Get a position |
| positions | POST /v1/positions/{id}/close | Close a position |
| positions | GET /v1/positions/{id}/close-quote | Get a close quote |
| positions | GET /v1/transactions | List ledger transactions |
| orders | GET /v1/orders | List limit orders |
| orders | POST /v1/orders | Place a limit order |
| orders | GET /v1/orders/{id} | Get a limit order |
| orders | DELETE /v1/orders/{id} | Cancel a limit order |
| widget | POST /v1/widget/sessions | Open a widget session |
| realtime | POST /v1/realtime/tokens | Get a realtime connection token |
| reports | POST /v1/reports | Request a report |
| reports | GET /v1/reports/{id} | Get a report status |
| reports | GET /v1/reports/{id}/download | Download a report |
| support-tickets | POST /v1/support-tickets | Open 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:
| Tier | Endpoints | Per end user | Per tenant |
|---|---|---|---|
balance_sync | POST /v1/balance-syncPOST /v1/balance-sync/batch | None | 450/min |
user_sync | PUT /v1/users/{external_user_id} | None | 3,000/min |
positions | POST /v1/positionsPOST /v1/positions/{id}/closePOST /v1/ordersDELETE /v1/orders/{id} | 10/min | 4,500/min |
default | every other /v1 route | 60/min | 600/min |
feed_read | GET /v1/marketsGET /v1/markets/{slug}GET /v1/markets/{slug}/snapshotGET /v1/markets/{slug}/history | 60/min | 600/min |
widget_session | POST /v1/widget/sessionsPOST /v1/widget/sessions/refreshGET /v1/widget/configPOST /v1/widget/error-reportsGET/PUT/DELETE /v1/widget/profileGET /v1/widget/leaderboard | 10/min | 13,500/min |
users_me | GET /v1/users/me | 60/min | 4,500/min |
saved_markets | GET /v1/widget/saved-marketsPUT /v1/widget/saved-markets/{market_slug}DELETE /v1/widget/saved-markets/{market_slug} | 20/min | 5,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 yourwebhook_secret.X-Webhook-Event: the event type.X-Webhook-Delivery: the id of the delivery, also in theidfield of the body.
Handle each delivery in this order:
- Compute the HMAC over the raw body bytes and compare it with
X-Webhook-Signaturein constant time. On a mismatch, reject the request. - Delivery is at least once. If you already processed the
X-Webhook-Deliveryid, answer2xxand stop. - 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:
| 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 |
Events
| Event | When it fires |
|---|---|
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 |
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 |
market.resolved | per market resolved to an outcome via panel-based settlement, delivered to each tenant with the market enabled |
market.voided | per market voided via the panel; every open position is refunded at its original stake |
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 |
market.resolution_corrected | per market whose resolved outcome a confirmed settlement correction replaced, delivered to each tenant with the market enabled |
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 |
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 |
reconciliation.discrepancy | per detected ledger-vs-operator balance mismatch |
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 |
report.ready | per report job reaching status ready; the operator may poll GET /v1/reports/{id} instead |
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 |
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 |
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 |
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:
| Status | Code | When it fires | Retry and action |
|---|---|---|---|
| 400 | validation_failed | The 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. |
| 400 | idempotency_key_required | A write request has no Idempotency-Key header. | No. Add the Idempotency-Key header and send the request again. |
| 401 | invalid_api_key | The 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. |
| 401 | invalid_session_token | The 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. |
| 403 | insufficient_scope | The 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. |
| 403 | tenant_suspended | The 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. |
| 403 | user_blocked | The 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. |
| 404 | not_found | The resource id in the request does not exist. | No. Check the id in the request. |
| 404 | unknown_user | The 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. |
| 409 | would_overdraw | A 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. |
| 409 | position_terminal | A 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. |
| 409 | price_moved | The 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. |
| 409 | market_suspended | The 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. |
| 409 | stake_limit_exceeded | The 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. |
| 409 | stake_below_minimum | The 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. |
| 409 | tenant_exposure_limit | The 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. |
| 409 | state_conflict | A 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. |
| 409 | report_limit_reached | The 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. |
| 409 | saved_markets_limit_reached | The 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. |
| 409 | idempotency_key_in_progress | An 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. |
| 413 | request_too_large | The request body is larger than the size limit of the route. | No. Make the body smaller or split the batch. |
| 422 | idempotency_key_reused | The 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. |
| 429 | rate_limited | The 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. |
| 500 | internal_error | An 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. |
| 503 | sync_paused | We paused the balance-sync endpoints with a kill switch. | Yes, after we clear the pause. Queued syncs replay safely with their Idempotency-Key. |
| 503 | positions_paused | We paused the opening of positions with a kill switch. | Yes, after we clear the pause. The widget shows a paused state meanwhile. |
| 503 | feed_paused | We paused the Market Feed reads with a kill switch. | Yes, after we clear the pause. |
| 503 | widget_paused | We paused widget session mint, widget session refresh and the widget config read with a kill switch. | Yes, after we clear the pause. |
| 503 | market_not_ready | The 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. |
| widget | realtime_unavailable | The 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. |
| widget | widget_session_expired | The 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. |
| widget | widget_config_invalid | The 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. |
| widget | widget_runtime_error | A 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.
- User-sync round-trip: user upserted via PUT /v1/users/{external_user_id} and read back via GET with the expected status and balance.
- 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.
- Position lifecycle exercised: a position opened in test mode against a seeded market and observed through settlement or void to its terminal state.
- 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.
- Reconciliation cycle clean: at least one full scheduled reconciliation run over the operator's test-mode activity with zero discrepancies.
- Rate-limit behavior understood: the operator's client has observed a 429 rate_limited response and demonstrably honors Retry-After instead of blind-retrying.