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 |
Swipe the table sideways for more columns.
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 |
Swipe the table sideways for more columns.
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: lower case letters, digits and hyphens, at most 63 characters. Public, and it grants nothing: it selects your operator's frame policy, stored theme, language, region and display currency. |
| Every origin that will frame the embed | scheme://host[:port]. No path, no trailing slash, no wildcard, at most ten. Include staging. Parity registers them on your operator's embed settings; an unregistered origin cannot render the frame at all, and it cannot exchange messages with it either. |
| 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, and your default language | The region orders the board (which markets lead) and selects region-scoped offering rules. The language is the one the frame speaks when the iframe URL does not name one. Both are stored on your operator, so no parameter is needed on every placement. |
| Your presentation: mode, accent colour, corner radius, density | The only branding knobs the frame has. There is no font or logo setting for an operator: the frame inherits your page around it rather than re-drawing your brand. Each can also be set per placement on the iframe URL. |
Swipe the table sideways for more columns.
| 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. Parity turns the embed on for your operator at the same time; until it is on, the frame renders “Predictions are not available here yet.” |
| Your host | Configure it as an environment variable. A key does not encode which host it belongs to; hard-coding it is how a staging integration ends up pointed at production. |
Swipe the table sideways for more columns.
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",
"jurisdiction": { "country": "MT" }
}The body is strict: a field this route does not know is refused with 400 invalid_request rather than silently dropped, so a typo cannot mint a session that is quietly configured differently from what you sent. detail names the offending key.
| Field | Required | Meaning |
|---|---|---|
externalUserId | yes | YOUR id for YOUR customer, for a customer you have already authenticated. 1 to 200 characters. 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 call the embed API with this token. Must be https (loopback http is accepted for local development) with no credentials; a path is dropped and only scheme://host[:port] is kept. It binds the session's API calls to that Origin; it does not decide who may frame the page, which is your operator's registered origin list. A session without it is usable from any page that obtains the token. |
ttlSeconds | no | An integer from 60 to 3600; default 900. A value outside the range is refused, not clamped. See Session renewal. |
locale | no | A BCP-47 shaped tag, at most 35 characters. The frame renders this customer in it: en, es, pt or hr, with a regional tag read by its language (pt-BR is pt). Any other language leaves the frame on your placement and operator settings. See Presentation. |
region | no | One of global, ar, br, mx, cl, co, pe, us, eu, hr, lower case exactly. Anything else is 400 invalid_region. It is a merchandising hook, not a jurisdiction: it orders the board this customer sees, so the markets that matter to them lead. |
jurisdiction | no | Where you say this customer is: { country, subdivision? }, an ISO 3166-1 alpha-2 country and optionally the part of an ISO 3166-2 code after the hyphen (NY). Recorded against the customer, not the session, so it outlives the token. It is what decides a jurisdiction-enforced offering; see Jurisdiction. |
theme | no | At most 32 keys; a key up to 64 characters, a value a string (up to 256), number or boolean. Stored, echoed and never a permission. The frame applies mode, accent, radius and density from it, with the same values and validation as the presentation parameters; other keys are stored and ignored. |
operatorBookId | no | Binds the session to one of your operator-backed books, enabled for your account during onboarding. Needs the operator_book:write scope, and externalUserId must then be 1 to 128 characters of [A-Za-z0-9._:-]. Omit it for the standard integration. |
Swipe the table sideways for more columns.
{
"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": "en",
"region": "us",
"jurisdiction": { "country": "MT", "subdivision": null },
"theme": {},
"usage": "Send as `Authorization: Embed <token>` on /api/embed/*. Never in a URL or a cookie."
}locale, region and jurisdiction are echoed as stored, and are null when not sent. theme is {} when none was sent. operatorBookId appears only when a book was bound.
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, or you sent operatorBookId without operator_book:write. required and missing in the body name which. |
400 invalid_json | The body is not JSON. |
400 invalid_request | A field failed validation, or the body carries a field this route does not know. detail lists each problem with its path. |
400 invalid_origin | returnOrigin is unparsable, is not https (http is accepted for loopback only), or carries credentials. A path is not a refusal; it is dropped. 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. |
400 invalid_region | region is not one of the ten listed above. The match is exact and lower case, so US is refused and us is not. |
422 invalid_operator_book | operatorBookId is unknown, belongs to another operator, or the capability is off. One refusal for all three, so the field cannot be used to discover book ids. |
422 invalid_external_user_id | A book-bound session was asked for with an externalUserId outside [A-Za-z0-9._:-] (1 to 128 characters). |
503 embed_unavailable | Embed sessions are temporarily unavailable on our side. Retry later; nothing about your request needs to change. |
500 internal_error | The session could not be minted. Safe to retry: nothing usable was issued. |
Swipe the table sideways for more columns.
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 your operator's frame policy and presentation defaults, and every byte of quote, order or position data requires the session token in a header. To open one market directly, frame /embed/predictions/{id}?operator=acme-casino with the event id from the catalogue.
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. Its Content-Security-Policy lists every registered origin in frame-ancestors, and an operator with none gets frame-ancestors 'none', so the browser refuses to render the frame anywhere else. This is decided per operator, from the list Parity registers on your embed settings, not per session: two operators framing the same build get two different policies, and changing domain is a configuration change that Parity makes for you. The session's returnOrigin is a separate check on the frame's API calls.
- Format is
scheme://host[:port]. No path, no trailing slash, no wildcard, no scheme-less value. - Up to ten origins are supported. Register staging before you need it.
- The same list decides who the frame talks to. It sends
parity.session.requiredto each registered origin (never to'*'), acceptsparity.sessiononly from one of them, and from then on posts only to the origin that answered. - 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
Add sizing=auto to the iframe URL and the embed emits parity.height_changed with a pixel height when its content grows or shrinks by 24 pixels or more, at most once per animation frame. Without sizing=auto (the default is pane) the message is never sent. 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.
Presentation: theme, language and region
Every presentation setting has a stored default on your operator and can be overridden per placement on the iframe URL. The URL wins, then your stored setting, then Parity's default. A value that fails validation falls back rather than erroring.
| Parameter | Values | Default |
|---|---|---|
mode | light, dark, inherit | inherit (no mode is forced) |
accent | #rgb or #rrggbb; URL-encode the # as %23 | none |
radius | sharp, soft, round | soft |
density | comfortable, compact | comfortable |
locale | en, es, pt, hr | see below |
region | global, ar, br, mx, cl, co, pe, us, eu, hr | see below |
sizing | pane, auto (auto-height) | pane |
Swipe the table sideways for more columns.
<iframe
src="https://<your-parity-host>/embed/predictions?operator=acme-casino&mode=dark&accent=%2300c2a8&density=compact&locale=es®ion=mx"
title="Predictions"></iframe>- These parameters are carried on every link inside the frame, so a player who opens a market keeps the same presentation.
- Branding is these four knobs: mode, accent, radius and density. There is no font or logo setting for an operator. The accent's text colour is chosen automatically for contrast.
- Language is exactly one of
en,es,pt,hr. The match is exact:pt-BRis notptand falls through. The frame uses the first of:?locale=, the session'slocale, your operator's configured primary language, the language stored in your embed theme, then the browser'sAccept-Language. - Region orders the board so the markets your customers care about lead. The frame uses
?region=, then the session'sregion, then your operator's stored region, then a default. - Currency has no URL parameter, deliberately. Stakes are shown in your operator's display currency, converted only through a peg or a recorded rate; see The money contract.
- Per customer: the session's
locale,regionandthemeapply to that customer's frame as soon as it holds the token. The order is the iframe URL, then the session, then your stored settings: a parameter on a placement is the most specific thing you can say, so it keeps winning. Language and region are drawn on our side, so the frame reloads its own page once to apply them; the theme applies in place. - Live:
parity.themefrom your page changes the theme at any time, without a reload, and wins over every other source. See the message contract.
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 andexpiredwhen the held token stopped working (an expiry, a revocation or any other refusal of the token). Both are answered the same way: mint a fresh token and reply. The protocol type also namesrejected; handle every reason the same way. Anexpiredrequest is always preceded byparity.session.expired, so a page that wants its own reconnecting state has a moment to show it.- Answer before you wait for anything.
parity.readyis sent only after the frame has accepted aparity.session. A page that holds its answer untilreadyarrives waits forever. - Send a fresh token. A token equal to the one the frame just had refused is ignored, and the frame stays in its reconnecting state until a different one arrives. Replacement requests are throttled to one every 5 seconds.
- Never post to
'*'. A wildcard target origin posts a customer's session token to whatever page happens to be listening. parity.sessionmay also carrycapabilities, a list of what your page handles beyond the minimum.order.cancelsays you answerparity.order.cancel_requested, and gives the customer a withdraw action on an order that has not traded. Without it the frame offers none.- 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 } | Must handle. Mint a token and reply parity.session. Sent to every registered origin until one answers. |
parity.ready | { protocol, capabilities } | The frame accepted your token and is live. Sent once, after the first valid parity.session. Hide your loading skeleton; never gate your token reply on it. capabilities lists what the frame handles, including theme and navigate. |
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.height_changed | { height } | Only with sizing=auto on the iframe URL. See auto-height. |
parity.session.expired | { at } | The held token was refused: it ran out, you ended it, or it was refused for any other reason a fresh token fixes. Sent once per token, immediately before parity.session.required. Show a reconnecting state if you want one. |
parity.error | { code, message } | A failure the customer can see. code is one of order_no_answer (your page did not answer an order request within 20 seconds), order_unreadable (you accepted and the frame could not read the order back) or session_refused (the session is refused for a reason a new token does not fix, such as a suspended customer). message is a fixed sentence for the code. Log it, or show your own help. |
parity.order.cancel_requested | { requestId, orderId } | Only when you declared order.cancel. The customer withdrew an order that has not traded. Cancel it from your server with POST /api/v1/orders/{id}/cancel and apply the release it returns; the frame reads the result back from Parity. See Cancellation and release. |
Swipe the table sideways for more columns.
One name per event
The protocol type also lists parity.session_expired, an alias of parity.session.expired kept so listeners typed against it still compile. The frame sends the dotted name only; treat the two as the same event.
Your page → Parity
| Message | Payload | When |
|---|---|---|
parity.session | { token, expiresAt?, capabilities? } | 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. |
parity.theme | { mode?, accent?, radius?, density? } | Any time. Restyles the frame in place, with the values listed under Presentation. A field outside its set is ignored and the rest still apply; successive messages add up. |
parity.navigate | { to, contractId? } | Any time. to: "markets" shows the board; to: "market" opens the market whose contractId you received in parity.market.opened. to: "orders" is accepted and moves nothing, because order history belongs to your own account pages. Ignored while an order request is waiting for your answer. |
Swipe the table sideways for more columns.
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.
- A
parity.order.acceptedwithout anorderIdis ignored. The frame then reads the order back from Parity and shows itsoperatorMoney.authorizationAmount; the acceptance is only trusted once that read succeeds. - A
reasonoutside the three is shown asrejected.messageis your own words to your customer, rendered as plain text and cut at 200 characters. - If neither answer arrives within 20 seconds, the ticket shows a no-answer error. Your server may still complete the order; recover it by
clientOrderId, as in Failure and recovery. - Only messages from your registered origins are read, and once one of them has answered the handshake, only that one. That applies to every inbound message, including
parity.themeandparity.navigate. parity.navigatenames a destination and never carries an address. The frame builds the path itself, so it can only ever move within the frame.
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.
What the frame calls, and how it is refused
You never call these yourself; the frame does, with the session token. They are listed so your monitoring and your support team can read a refusal when a player reports one.
- The token travels as
Authorization: Embed <token>, or in theX-Parity-Embed-Sessionheader (an earlier name for that header is still read). ABeareroperator key is not accepted here. - If the session carries a
returnOrigin, a request'sOriginmust equal it. The frame's own same-origin calls, and requests with noOrigin, pass. - Rate limit: 300 requests a minute per session, and every session also counts against your operator's per-minute budget, the same one your API key draws on (see Rate limits). Over either, the answer is
429 rate_limitedwithRateLimit-*andRetry-Afterheaders.
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_session | No token, or a Bearer key was sent instead. |
| 401 | token_in_query | A ?token= was sent. Refused on every embed surface. |
| 401 | malformed_token | Not the pes1 token shape. |
| 401 | invalid_token | The signature does not verify. |
| 401 | unknown_session | No such session. |
| 401 | session_expired | Past expiresAt. The frame asks you for a new one. |
| 401 | session_revoked | The session was revoked. |
| 403 | operator_suspended | Your operator is suspended. |
| 403 | user_suspended | This customer is suspended. |
| 403 | origin_mismatch | The Origin is not the session returnOrigin. |
| 429 | rate_limited | Over the per-session or per-operator budget. |
| 503 | signing_unavailable | Embed signing is not configured. Ours. |
Swipe the table sideways for more columns.
Jurisdiction
Where your offering depends on where the customer is, Parity decides from the jurisdiction you assert, never from an IP address. Assert it with jurisdiction when you mint the session, or with PUT /api/v1/users/{id}/jurisdiction; either records it against the customer.
- If your tenant's jurisdiction rule is enforced, a customer whose asserted jurisdiction you are not offered in sees a panel explaining that predictions are not available where they are (with the country's flag when one is known) instead of the board, and the catalogue, quote and order paths refuse on the server regardless of what the frame shows.
- If the rule is not enforced, the assertion is stored and nothing is blocked by it.
- The frame also records the country of the player's IP address as evidence for your own review. It never blocks, allows or changes anything.
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 |
Swipe the table sideways for more columns.
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.
When the customer withdraws in the frame. If your page declared order.cancel on parity.session, an order that has not traded shows a withdraw action. Pressing it sends parity.order.cancel_requested { requestId, orderId }; your server calls this endpoint for that orderId and applies the release. The frame reads the order back from Parity and shows it withdrawn once it is cancelled. If it is not cancelled within about 20 seconds, the customer is told the withdrawal is unconfirmed, never that it happened.
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. |
Swipe the table sideways for more columns.
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 floor is 60 and 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 sends
parity.session.expiredand thenparity.session.requiredwithreason: "expired", at the moment a request is refused. Mint and reply, with the same handler you already wrote. The frame also checks its session once a minute and just afterexpiresAt, so an idle frame asks in time too. - The frame does not renew ahead of time: the
expiresAtyou pass withparity.sessionis not read. If you want no visible reconnect, send a freshparity.sessionbefore the old token expires; the frame takes the newer token. - 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.
- Keep session lifetimes short. To stop a customer at once, suspend them from your operator portal: suspension is checked on every request, so the session's next request is refused with
user_suspended. - 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.
End a session early
/api/v1/embed-sessions/{id}API keysessionId the mint returned.{
"sessionId": "52dad6af-…",
"revoked": true,
"revokedAt": "2026-08-22T23:19:54.624Z",
"idempotentReplay": false
}- Call it when your own session for the customer ends: sign-out, self-exclusion, or a token you believe has leaked. The frame's next request with the token is refused
401 session_revoked, and your page receivesparity.session.expiredandparity.session.required. Your server decides whether to mint a replacement. - Revoking ends that one session immediately. To stop a player, your server should also stop minting sessions for them, so the frame's request for a replacement is refused on your side.
- Same scopes as minting:
markets:read,quotes:writeandorders:write. A key missing any of them gets403 insufficient_scopenaming the ones missing. - Idempotent. Ending an ended session returns
200with the originalrevokedAtandidempotentReplay: true. Resend freely. - A session id belonging to another operator is
404 unknown_session, the same answer as an id that never existed. Every revocation is recorded in your audit trail with the API key that made it.
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 every reason,initialandexpiredincluded, without waiting forparity.ready.- The region you send, on the session and on the URL, is lower case and one of the ten listed; the language is
en,es,ptorhr. - If your offering is jurisdiction-enforced, every session carries the customer's
jurisdiction. 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.

