Parity Embed
Embed the Predictions tab
The turnkey integration: an iframe, one server endpoint, and a message listener. Parity draws the markets, the ticket and the positions; you keep the customer, the wallet and the brand. Most operators are live in days.
Embed or Headless?
Parity ships in two shapes, and the choice decides everything else you read. If you are adding a Predictions tab to an existing casino, sportsbook or wallet, you want Embed. That is this page. If you are building your own trading UI, you want Headless, which is the rest of these docs.
| Parity Embed (recommended) | Parity Headless | |
|---|---|---|
| What you ship | an <iframe> and one server endpoint | your own UI over the API and the stream |
| Who draws the market list, ticket and positions | Parity | you |
| Server work | mint an embed session; reserve, confirm and release against your ledger | everything Embed does, plus catalogue, pricing, quoting and position rendering |
| Price feed | Parity opens it inside the frame | you open it, or you poll |
| Typical time to first order | days | weeks |
| Best for | casinos and sportsbooks adding a vertical | operators and fintechs with a trading UI already |
The money architecture below is identical in both. It is the part of Parity that does not bend.
The money architecture, before any code
You own ONE authoritative customer cash ledger
There is no Parity player balance. There is no Parity deposit screen, no Parity withdrawal screen, and no “transfer to Predictions” step. There never will be. Parity never holds a player's funds and has no route that pays one out. The customer's money stays in the single balance you already run, and Parity is told only whether a reserve against it succeeded.
The worked example, because this is the part people re-invent
A customer has $100 with you. They stake $25 on a contract.
- Parity returns a quote whose
operatorMoney.authorizationAmountis"25.00". - You reserve $25 in your ledger. The customer now sees
$75 available,$25 reserved. Their total is still $100 and it never left your books. - You tell Parity the reserve succeeded. That is the entire handshake: success or failure, nothing else crosses.
- If you cannot reserve it, you decline. Parity renders an insufficient-funds state, invents no fill, and transmits nothing to a venue.
| Parity owns | Parity never owns |
|---|---|
| Catalogue, live pricing, quote, order, routing, fills, positions, cashout, settlement, fees, reconciliation, and the exact money instructions you apply | Player deposits, withdrawals, the general cash balance, the casino wallet, the customer account, KYC, support |
Every amount Parity hands you is an exact decimal string you apply. Never recompute one from a price and a quantity: the moment you do there are two answers to one question and only one of them is in Parity's ledger. The full contract, including partial fills and exits, is on The money contract.
How the pieces fit
YOUR PAGE (browser) PARITY
┌───────────────────────────┐
│ your header │
├───────────────────────────┤ GET /embed/predictions?operator=…
│ <iframe> ───────────────────────────▶ the Predictions surface
│ Parity draws │ GET /api/v1/stream/prices (SSE)
│ markets · ticket · │◀────────────── live prices, filtered by
│ positions │ your offering policy
├───────────────────────────┤
│ your bottom nav │
└───────────────────────────┘
│ ▲
│ │ postMessage: NO MONEY IN EITHER DIRECTION
▼ │
YOUR SERVER
POST /api/v1/embed-sessions mint a browser-safe token
POST /api/v1/quotes (optional) price it yourself
POST /api/v1/orders record a durable intent
POST /api/v1/orders/{id}/confirm-reserve "the funds are held"
POST /api/v1/orders/{id}/cancel release instruction
GET /api/v1/orders?… recoveryThree things you build: a server endpoint that mints a session, a message listener on the page that holds the iframe, and the reserve/release wiring into your own wallet. Everything inside the frame is ours.
Provisioning
Before you write anything, Parity provisions your tenant. Send us four things and you get back two.
| You give Parity | Why it is needed |
|---|---|
Your operator slug preference, e.g. acme-casino | It is the ?operator= value in the iframe URL. Public, and it grants nothing: it selects a frame policy and a palette. |
| Every origin that will frame the embed | scheme://host[:port]. No path, no trailing slash, no wildcard. Include staging. An unregistered origin cannot render the frame at all. |
| Your offering policy: which categories and venues you may carry | Applied server-side to the catalogue, the stream and the quote path. A market you are not offered is never sent, not sent-and-hidden. |
| Your region, if you serve one jurisdiction | Selects region-scoped offering rules at session-mint time. |
| Parity gives you | Notes |
|---|---|
| An API key | Scoped markets:read + quotes:write + orders:write. An embed session can browse, quote and order, so the key that mints one must be able to do all three itself. Server-side only, forever. |
| Your host | Configure it as an environment variable. There is one deployment and a key does not encode which host it belongs to; hard-coding it is how a staging integration ends up pointed at production. |
The API key never reaches a browser
It is a permanent, operator-wide credential. Anything it can do, anyone who reads your bundle can do for every one of your customers. The whole purpose of POST /api/v1/embed-sessions is to trade it for something that expires in minutes and can only act as one customer.
1. Mint a session, server-side
/api/v1/embed-sessionsAPI keyPOST /api/v1/embed-sessions
Authorization: Bearer <your Parity API key>
Content-Type: application/json
{
"externalUserId": "acme-user-1234",
"returnOrigin": "https://acme.example",
"ttlSeconds": 900,
"locale": "en",
"region": "US"
}| Field | Required | Meaning |
|---|---|---|
externalUserId | yes | YOUR id for YOUR customer, for a customer you have already authenticated. Parity never authenticates end users and stores no other identifier. The same string under two operators is two different people. |
returnOrigin | no, but send it | The origin that will frame the embed. Must be https (loopback http is accepted for local development). A session without it is usable from any page that obtains the token. |
ttlSeconds | no | Default 900. Minutes, not hours. See Session renewal. |
locale | no | BCP-47 language tag, e.g. pt-BR. |
region | no | Selects region-scoped offering rules. |
theme | no | Presentation only, at most 32 keys. Never a permission. |
{
"sessionId": "52dad6af-…",
"token": "pes1.<keyId>.<secret>.<mac>",
"tokenType": "embed_session",
"expiresAt": "2026-08-22T23:19:54.624Z",
"expiresInSeconds": 900,
"operatorId": "50516095-…",
"externalUserId": "acme-user-1234",
"origin": "https://acme.example",
"locale": null, "region": null, "theme": {},
"usage": "Send as `Authorization: Embed <token>`. Never in a URL or a cookie."
}The token is returned once
Only a SHA-256 of its secret is stored. Nothing on this API can recover it. If you lose it, mint another. That is cheap and it is the intended recovery.
Your endpoint
Wrap the call in one route on your own server. It authenticates your customer with your own session, then mints. This is the only server work Embed requires beyond the wallet wiring.
// POST /api/parity/embed-session (on YOUR backend)
export async function POST(request: Request) {
const user = await requireSignedInUser(request); // YOUR auth, not ours
const res = await fetch(`${process.env.PARITY_HOST}/api/v1/embed-sessions`, {
method: 'POST',
headers: {
// Server-side only. This key must never be reachable from the browser.
authorization: `Bearer ${process.env.PARITY_API_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
externalUserId: user.id,
returnOrigin: process.env.PUBLIC_ORIGIN, // https://acme.example
ttlSeconds: 900,
}),
});
if (!res.ok) return Response.json({ error: 'session_unavailable' }, { status: 502 });
const { token, expiresAt } = await res.json();
// Return ONLY these two fields. Never proxy the whole body: it echoes your
// operatorId and the session id, and your page needs neither.
return Response.json({ token, expiresAt });
}| Refusal | What it means |
|---|---|
403 insufficient_scope | Your key is missing one of markets:read, quotes:write, orders:write. The body names which. |
400 invalid_origin | returnOrigin is not an https origin, or carries a path or credentials. Caught at mint time on purpose: otherwise it presents later as a session that silently refuses every request from a browser with no server-side clue why. |
503 embed_unavailable | Embed signing is not configured on this deployment. Ours, not yours. |
2. Mount the iframe
<iframe
src="https://<your-parity-host>/embed/predictions?operator=acme-casino"
title="Predictions"
style="width:100%;height:100%;border:0"
></iframe>?operator=acme-casino is public and grants nothing. It selects exactly two things (the frame policy and the palette), and every byte of market, quote, order or position data requires the session token in a header.
Never put the token in the URL
A ?token= is refused outright by every Parity embed surface. A fifteen-minute credential in a query string has fifteen real minutes of life in your access logs, your referrer headers, your analytics and the customer's browser history.
Allowed origins
The embed document can only be framed from an origin registered against your operator. Anything else receives Content-Security-Policy: frame-ancestors 'none' and the browser refuses to render the frame. This is decided per session rather than per deployment, so two operators framing the same build get two different policies and changing domain is a provisioning change, not a Parity redeploy.
- Format is
scheme://host[:port]. No path, no trailing slash, no wildcard, no scheme-less value. - Several origins are supported. Register staging before you need it.
- An operator with no registered origin is unframable. That is the safe end of the trade: a visible, reportable failure rather than a silent widening.
Sizing: give the iframe your actual content pane
This is the recommended integration and the one to build first. Parity cannot know whether your mobile nav is 56px or 72px, whether you have a bottom bar, or whether your header collapses on scroll. So you hand the frame the pane you would have given any other screen, and Parity owns scrolling inside it.
/*
The pane is what is left after YOUR chrome. Parity fills it and scrolls
inside it: one scroll context, no nested scrollbars, no page-level jump
when the player opens a market.
*/
.app {
display: grid;
grid-template-rows: auto 1fr auto; /* header · pane · bottom nav */
height: 100dvh; /* dynamic: survives the mobile URL bar */
overflow: hidden; /* the PAGE never scrolls; the pane does */
}
.app__header { height: 56px; }
.app__nav { height: 64px; }
.app__pane {
min-height: 0; /* without this a grid row refuses to shrink and the
iframe pushes the nav off-screen on small phones */
overflow: hidden;
}
.app__pane iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}- Prefer
100dvhto100vh. On mobile Safari and Chrome100vhis the viewport with the URL bar hidden, so a100vhlayout hides your own bottom nav under the browser chrome until the user scrolls.dvhtracks the pane that is actually visible. - Do not assume the pane is the viewport. Subtract your chrome, with a grid row as above, or
height: calc(100dvh - 56px - 64px)if you would rather be explicit. min-height: 0on the pane is load-bearing. A CSS grid or flex item defaults tomin-height: autoand will not shrink below its content, which on a long market list silently pushes your navigation off the bottom of the screen.- Add
-webkit-overflow-scrolling: touchnowhere andoverflow: hiddenon the page: two scroll contexts stacked is the single most common embed complaint, and it is always the host page scrolling behind the frame.
Optional: auto-height
The embed emits parity.height_changed with a pixel height when its content grows or shrinks. You can resize the iframe from it, and for a short page inside a long marketing site that is the right answer.
if (data.type === 'parity.height_changed') {
iframe.style.height = `${data.height}px`;
}Why a casino should not do this
A market list is thousands of pixels tall. Auto-height turns that into a 5,000px iframe that grows as the player browses: your own header scrolls away, your bottom nav is unreachable, the browser re-lays-out the whole document on every price tick, and the player's scroll position jumps whenever a row appears. It also means Parity can never keep a sticky ticket in view, because there is no viewport inside the frame to be sticky against.
Use auto-height for a small, bounded surface. Use the application pane for a Predictions tab.
Content Security Policy
If your page ships a CSP (and it should), it must allow the frame. This is on your policy; Parity sets frame-ancestors on its own response and the two have to agree or the frame stays blank.
Content-Security-Policy: frame-src https://<your-parity-host>;frame-src, notchild-src. If you support older browsers, send both.- You do not need a
connect-srcentry for Parity. The price stream is opened by the document inside the frame, under Parity's own policy. - If a proxy, WAF or CDN sits in front of your page, confirm it passes
text/event-streamthrough unbuffered. A buffering proxy turns a live price feed into a feed that delivers everything at once, minutes late.
3. Hand the token to the frame
The embed document is fetched by an iframe navigation, which is a plain GET: a browser will not put an Authorization header on one, a cookie would be CSRF with a trading API behind it, and a query string leaks. So the frame carries no credential and asks your page for one. That request is parity.session.required, and answering it is the minimum working integration.
const PARITY = 'https://<your-parity-host>';
const frame = document.querySelector('iframe');
window.addEventListener('message', async (event) => {
// ORIGIN FIRST, before the payload is even looked at. An unrecognised
// sender should cost one string compare, not a parse of whatever they sent.
if (event.origin !== PARITY) return;
const message = event.data;
if (!message || message.protocol !== 'parity.embed.v1') return;
if (message.type === 'parity.session.required') {
// YOUR endpoint from step 1. The API key stays on your server.
const res = await fetch('/api/parity/embed-session', { method: 'POST' });
const { token, expiresAt } = await res.json();
frame.contentWindow.postMessage(
{ protocol: 'parity.embed.v1', type: 'parity.session', token, expiresAt },
PARITY, // an explicit target origin. NEVER '*'.
);
}
});reasonisinitialon mount, andexpiredorrejectedwhen the held token stopped working. All three are answered the same way: mint a fresh token and reply.- Never post to
'*'. A wildcard target origin posts a customer's session token to whatever page happens to be listening. - Do not persist the token: not
localStorage, not a cookie, not the URL. A token inlocalStorageoutlives the session that needed it and is readable by every script your page ever loads.
The message contract
No monetary value crosses postMessage, in either direction
Not a stake, not an authorization amount, not a release. The order messages carry identifiers only.
Why: a browser must never be the authority for money. Anything your page reads from a message event is a number the customer's own devtools can change before you see it, and a protocol that let the parent send an amount back would make the browser the source of truth for a debit. So your page learns that an order was requested, and your server obtains the authoritative authorizationAmount from its own Parity call. Two different trust levels, and only one of them is allowed to move money.
Parity → your page
| Message | Payload | What to do |
|---|---|---|
parity.session.required | { reason: initial | expired | rejected } | Must handle. Mint a token and reply parity.session. |
parity.session.expired | { at } | Notification. Show a reconnecting state if you want one; the request for a replacement travels separately. |
parity.ready | { protocol, capabilities } | The frame is alive. Hide your loading skeleton. |
parity.market.opened | { contractId, title } | Analytics, deep links, your own breadcrumb. Optional. |
parity.quote.created | { quoteId, contractId, side, stake, expiresAt } | The player is looking at a priced ticket. Optional; useful for telemetry. |
parity.order.requested | { requestId, quoteId, clientOrderId } | Must handle to take orders. Go to your server. See the order flow. |
parity.order.cancel_requested | { requestId, orderId } | The player withdrew an order. Cancel it server-side and release. |
Your page → Parity
| Message | Payload | When |
|---|---|---|
parity.session | { token, expiresAt? } | In answer to parity.session.required. |
parity.order.accepted | { requestId, orderId } | Your server reserved the funds and confirmed the reserve with Parity. requestId echoes the one from parity.order.requested. |
parity.order.declined | { requestId, reason, message? } | You could not. reason is one of insufficient_funds · unavailable · rejected. |
requestId is how a reply is matched to its request. Echo it exactly; a reply with an unknown requestId is dropped, because a shell that answers the wrong ticket is worse than one that answers none.
Every message carries protocol: "parity.embed.v1". Filter on it: your page's window receives messages from React devtools, analytics SDKs and payment widgets, and an embed that skips the check ends up with mystery crashes rather than security incidents. The version is a contract: a field may be added, a field is never changed.
4. Live prices
/api/v1/stream/pricesNo authGET /api/v1/stream/prices?contracts=ctr_a,ctr_b
Authorization: Embed <token>
Accept: text/event-stream- One connection per page, however many contracts it watches. Fan-out is ours: a thousand browsers on one contract cost one upstream subscription.
- Frames are
hello(what you are subscribed to, and what was refused),snapshot(everything; safe to replace state with),update(only what changed; patch, never replace),heartbeat,status(the feed went stale or recovered),revoked(contracts withdrawn from a live connection) anderror. - Filtered by your offering policy before delivery. A market you may not offer is refused in
helloand no price for it is ever sent, not sent and hidden. - Contract ids are opaque
ctr_…. No venue name appears anywhere on this wire. - Each frame carries an
id:;EventSourcereplays it asLast-Event-IDon reconnect, so continuity is a browser feature rather than code you write.
The session token here decides exactly one thing: whose offering policy applies. A connection with no credential gets the unrestricted public catalogue. A connection with a refused credential is closed, never downgraded to anonymous, which would serve a revoked session more than it could see while valid.
5. The order flow, end to end
This is the sequence that moves money, and every step of it exists so that there is never a moment where you have reserved a customer's funds but cannot determine whether Parity holds an executable order.
player taps BUY inside the frame
│
├─▶ embed → your page parity.order.requested { requestId, quoteId, clientOrderId }
│ (no money in this message)
│
│ YOUR SERVER
│ 1. POST /api/v1/orders { quoteId, clientOrderId, externalUserId,
│ executionIntent: "record_intent" }
│ → orderId, state reserve_pending, transmissionState not_transmitted
│ → operatorMoney.authorizationAmount ← the AUTHORITATIVE amount
│
│ 2. reserve authorizationAmount in YOUR ledger
│ ├─ cannot? → POST /api/v1/orders/{orderId}/cancel and decline
│ └─ held? → continue
│
│ 3. POST /api/v1/orders/{orderId}/confirm-reserve
│ → state pending_venue_execution
│
├─◀ your page → embed parity.order.accepted { requestId, orderId }
│ or parity.order.declined { requestId, reason }
▼
the frame renders the confirmed ticket, or an insufficient-funds stateStep 1: record a durable intent
/api/v1/ordersAPI keyPOST /api/v1/orders
Authorization: Bearer <your Parity API key>
X-Parity-User: acme-user-1234
{
"quoteId": "qt_…",
"clientOrderId": "your-own-idempotency-key",
"executionIntent": "record_intent"
}The response carries a stable Parity orderId, status reserve_pending, transmissionState not_transmitted, and operatorMoney. operatorMoney.authorizationAmount is the number you reserve, obtained here, from your own server-side call, and never from a message the browser sent you.
reserve_pending cannot execute. That is a missing edge, not a check
reserve_pending has no transition to submitted in the execution state machine. No worker and no retry can transmit an order whose customer funds nobody has confirmed are held, not because a handler tests for it, but because the path does not exist.
clientOrderId is your id and it is the idempotency key. Derive it from the reservation in your own ledger, never from a clock or a random value: the whole point is that it survives the crash that lost you our response. Re-POSTing the same id returns the same order with idempotentReplay: true, never a second one. That is enforced by a unique index on (operator, user, clientOrderId), not by a check in a handler.
Step 2: reserve, in your ledger
Nothing here is Parity's. Move authorizationAmount from available to reserved against the customer's one balance. If it does not fit, go to cancel and decline the order. An insufficient balance is a normal outcome, not an error state.
Step 3: confirm the reserve
/api/v1/orders/{id}/confirm-reserveAPI keyPOST /api/v1/orders/ord_…/confirm-reserve
Authorization: Bearer <your Parity API key>
{ "reserveReference": "your-own-hold-id" }The order becomes pending_venue_execution: cleared to execute, and it progresses to submitted and then filled under the same state names you are already consuming. reserveReference is optional, stored and never interpreted. It is your half of the audit trail.
Idempotent. A call that times out can simply be sent again: the same confirmation twice returns 200 with idempotentReplay: true and yields one executable order. It is never a conflict, because answering the safe action with an error is how an integration learns to stop retrying.
It refuses a cancelled order, and an order that is not awaiting a reserve at all (one already at a venue, filled or rejected), with a 409. Absorbing those would tell you your hold is standing behind a live order when it is standing behind nothing.
Step 4: answer the frame
if (message.type === 'parity.order.requested') {
const { requestId, quoteId, clientOrderId } = message;
// Your server does steps 1-3 and answers with a verdict, never an amount.
const res = await fetch('/api/parity/order', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ quoteId, clientOrderId }),
});
const result = await res.json();
frame.contentWindow.postMessage(
result.ok
? { protocol: 'parity.embed.v1', type: 'parity.order.accepted',
requestId, orderId: result.orderId }
: { protocol: 'parity.embed.v1', type: 'parity.order.declined',
requestId, reason: result.reason }, // insufficient_funds | unavailable | rejected
PARITY,
);
}| reason | Use it when | What the player sees |
|---|---|---|
insufficient_funds | the reserve did not fit the available balance | an insufficient-funds state, with your own top-up affordance outside the frame |
unavailable | your wallet, ledger or Parity call was unreachable | a retryable error |
rejected | your own rules said no: limits, self-exclusion, jurisdiction, risk | a terminal refusal, not a retry prompt |
A decline is a decline
No fill is invented, no position appears, and nothing is transmitted to a venue. An embed that showed a filled ticket after a failed reserve would have created a position with no cash behind it, which is the most expensive bug available on this surface.
6. Cancellation and release
/api/v1/orders/{id}/cancelAPI key{
"cancellation": {
"cancellationId": "cxl_…",
"currency": "USD",
"authorizationAmount": "25.00",
"actualDebit": "0.00",
"releaseAmount": "25.00",
"reason": "player cancelled",
"issuedAt": "2026-08-22T23:19:54.624Z",
"idempotentReplay": false
}
}authorizationAmount = actualDebit + releaseAmount, exactly. It is an equality, not a tolerance: every figure is an exact integer of minor units rendered as a decimal string, andreleaseAmountis a subtraction rather than an independently derived remainder.actualDebitis"0.00"on a cancellation. A cancelled order spent nothing, so everything comes back.- Cancelling twice returns the SAME
cancellationIdwithidempotentReplay: true. Key your release on that id and you release exactly once even if your first response was lost. Two ids for one cancellation is how a customer gets credited twice.
If transmissionState is `unknown`, DO NOT RELEASE
A 409 carrying transmissionState: "unknown" means Parity asked a venue and got no answer. The order is at the venue or it is not, and nobody knows which yet. A release issued there hands the customer back money that may be standing behind a real position, and you cannot discover the error afterwards because we told you it was cancelled.
Keep holding the customer's authorization. Reconciliation settles it, and the order's own operatorMoney then says exactly what to apply. unknown never auto-resolves to released.
A 409 carrying transmissionState: "resolved" is the other refusal: the order is filled or rejected, its money is already decided, and operatorMoney on the order says what to do with it.
7. Failure and recovery
Every boundary in this flow recovers from an id Parity already holds, and none of them asks you to remember anything else.
| What failed | What to do |
|---|---|
| The POST /orders timed out | Resend it with the same clientOrderId. The unique index replays the original order rather than opening a second. |
| The confirm-reserve timed out | Resend it, or read the order back. A confirmation of an already-confirmed order returns 200, not an error a retrying integration would treat as fatal. |
| The cancel timed out | Resend it. The instruction was issued once and is replayed verbatim: same id, same amount. |
| You lost the response entirely | GET /api/v1/orders?externalUserId=…&clientOrderId=…. It reports reserve_pending or pending_venue_execution, and there is no third answer. |
| The page reloaded mid-order | Nothing is lost. The order is durable on Parity's side and the reserve is durable on yours; the frame re-mounts, asks for a session, and reads the order back. |
You received 409 quote_already_used | The quote was spent by an order carrying a different clientOrderId. The body names that orderId; a resend of yours would have replayed as a 200 instead, so this is not a case a retry can reach. |
The rule underneath all of it: never release on an unknown. A timeout is not a rejection. Hold the authorization and resolve it by reading, not by assuming.
8. Session renewal
- Default TTL is 900 seconds; the ceiling is an hour. Minutes, not days, because a browser-held credential is bounded by expiry rather than by secrecy.
- On expiry the embed emits
parity.session.expiredand thenparity.session.requiredwithreason: "expired". Mint and reply, with the same handler you already wrote. - A refused token is never retried on a backoff. The embed asks you instead, so an expiry becomes one round trip rather than a reconnect loop.
- Revoking a session takes effect on its next request, including on an open price stream.
- Mint per customer session, not per page view, and mint lazily. A token minted and not used is a credential with no purpose and a real lifetime.
9. Settlement
Settlement is the same in Embed and Headless, and it is the one part of the lifecycle the frame cannot do for you: crediting a winner is a movement in your ledger.
- Poll
GET /api/v1/settlements. Each record carries the exact figure:operatorMoney.settlementCreditfor a winner,operatorMoney.voidCreditfor a cancelled market, fee included. - Every record is safe to apply twice: key on the settlement
id. - Stop on
hasMore, never on the cursor. This feed hands back a cursor on its last page (it is a resumable watermark, not an end marker), sowhile (nextCursor)never terminates. GET /api/v1/reconciliationis the verdict on whether your books and ours agree, andGET /api/v1/reconciliation/feedis the totally-ordered fact stream behind it.
Full detail on Settlement and Reconciliation.
The execution path
An order is recorded before it can execute, and it becomes executable when you confirm that the customer's funds are held. The row is written first on purpose: an execution that succeeds upstream and then fails to be recorded is the one outcome with no recovery path. That is what the reserve handshake expresses, and it is why reserve_pending and pending_venue_execution are states your server can read.
An order is not a fill
executionIntent: "simulate" produces an immediate simulated fill and a simulated position. That is sandbox behaviour, it is the default only for a tenant that is a declared simulation, and it must never be read as the production order path. Read simulated on the response rather than assuming either. See Sandbox and Going live.
Load-bearing on both paths: the catalogue, live pricing from real upstream books, quote expiry, the durable order record, idempotency, the reserve handshake, cancellation and release, collateral, settlement records and reconciliation. The state names are the same in the sandbox and live, so an integration built against them does not change when the key does.
Before you go live
- The API key is server-side only. Grep your bundle for its prefix and prove it.
- Every production and staging origin is registered, and your CSP names
frame-src. - The iframe gets your real content pane, with
100dvhandmin-height: 0, and the host page does not scroll behind it. parity.session.requiredis answered for all three reasons, includingrejected.clientOrderIdcomes from your ledger, not a clock, and a replayed POST is handled as success.- A failed reserve declines with
insufficient_fundsand never with a fabricated accept. - A cancel is keyed on
cancellationId, andtransmissionState: "unknown"holds rather than releases. - Settlement polling stops on
hasMore, and credits are keyed on the settlement id. - You have worked through Going live, which covers what to prove before the key changes.

