Getting started
Authentication
One credential identifies the operator; one header identifies the player. Parity never authenticates an end user and holds no personal data about them.
Every partner request
Authorization: Bearer pk_test_... # sandbox. Live keys are sk_live_...
X-Parity-User: <your own id for this player>
Content-Type: application/json| Header | Required on | Meaning |
|---|---|---|
Authorization | Every /api/v1/* request | The operator credential. Bearer scheme only. |
X-Parity-User | POST /api/v1/quotes, POST /api/v1/orders, POST /api/v1/funding/deposit-address | Your opaque id for the player. It is the entire record Parity keeps of them. The legacy name X-Predicta-User is still accepted on the same routes; send the canonical one. |
The funding route in that list is sandbox only and answers 403 sandbox_only for a live operator, along with the other funding routes, the /api/v1/sandbox/* test controls, GET /api/v1/users/{id}/balance and GET /api/v1/users/{id}/ledger. They fund and read a balance held by Parity, which a live operator does not have: its customers' cash is on its own books. The scope exists so a sandbox key can reach them; holding it does not make them reachable in production.
Two routes take no operator key at all, and both are browser surfaces rather than API calls. GET /api/v1/images/{id} serves event artwork from Parity's own origin, because it is an <img src> in a player's browser and a page cannot be handed a secret key. GET /api/v1/stream/prices is an EventSource, for the same reason; it accepts an embed session token, which is not a key, and uses it only to decide whose offering policy filters the feed.
Missing the user header is a 400, not a 401
A request with a valid key and no X-Parity-User returns 400 missing_user. It is a well-formed credential making an incomplete request, not a credential problem.
What X-Parity-User actually is
Parity does not authenticate end users, issues them no credentials and knows nothing about them. You have already established who the player is; the header carries your own opaque id for them, and that id is the entire record.
- A write path creates the user on first use. The first quote or order for an id registers it. There is no separate “create player” call to forget.
- A read path never creates one. On the settlement register, the reconciliation feed and the two sandbox balance reads, an id that has never traded is
404 unknown_userrather than an empty page: a typo'd id that returned[]reads as “this player has nothing”, and the mistake surfaces later as a missing credit nobody can explain. - The order and position lists are the exception. They apply
externalUserIdas a plain filter, so an unknown id returns an empty page there rather than a404. Check the id before concluding a player holds nothing. - Every read surface keys on your id, never on a Parity uuid, so you never store a second identifier for the same person and then have to reconcile the two.
- The id is scoped to your operator. Two partners may use the same string for different people with no collision.
How keys behave
- A key is shown exactly once, in the response that creates it. Parity stores a SHA-256 hash and a short non-secret prefix; there is no code path that recovers a key from the database.
- The prefix (the leading characters,
pk_test_XXXXorsk_live_XXXX) is what a console shows and what a log line may safely carry. It identifies which key to revoke and cannot authenticate anything. - Revocation is a timestamp, never a delete. A revoked key returns
401 revoked_keyand stays on the record. - A suspended operator returns
403 operator_suspendedon a key that is otherwise perfectly valid. - The prefixes are kept deliberately greppable so secret scanners catch an accidentally committed key.
The prefix reports the mode; it does not decide it
Sandbox keys are minted pk_test_… and live keys sk_live_…, and the two spaces do not overlap. What decides whether a request is a sandbox request is the tenant's mode, not the string in the header, so no caller can reach the other environment by presenting a different-looking key, and no parameter moves a key across the boundary.
| Response | Code | Meaning |
|---|---|---|
401 | missing_credentials | No Authorization header, or not a Bearer token. |
401 | invalid_key | The key is not recognised. |
401 | revoked_key | The key was revoked. |
403 | operator_suspended | The operator account is suspended. |
Scopes
Every /api/v1 route names the single scope it needs and the gate checks it before the handler runs. A key that lacks it gets 403 insufficient_scope with the scope it wanted in a required field, so the fix never has to be guessed.
Deny by default: an empty scope list grants nothing
An empty scopes array once meant every scope, which made the least-configured credential in the system the most powerful one, and the column defaults to empty, so any insert that simply did not mention scopes minted a key that passed every check. A scope must now be listed to be granted.
The table below is a summary. The complete, generated route-by-route scope list is on Rate limits, which is built from the OpenAPI document rather than typed out, so it cannot go stale.
| Scope | Routes it opens |
|---|---|
markets:read | GET /api/v1/events, GET /api/v1/events/{id}, GET /api/v1/categories, GET /api/v1/users/{id}/jurisdiction |
quotes:write | POST /api/v1/quotes |
orders:write | POST /api/v1/orders, POST /api/v1/orders/{id}/confirm-reserve, POST /api/v1/orders/{id}/cancel, PUT /api/v1/users/{id}/jurisdiction, and the sandbox-only scenario control |
orders:read | GET /api/v1/orders, GET /api/v1/orders/{id}, and the sandbox-only scenario list |
positions:read | GET /api/v1/positions, GET /api/v1/positions/{id} |
settlements:read | GET /api/v1/settlements, GET /api/v1/settlements/positions, and the sandbox-only resolution control |
ledger:read | GET /api/v1/reconciliation, GET /api/v1/reconciliation/feed, and the sandbox-only balance and movement reads |
funding:write | The sandbox-only funding routes. |
Scope is checked before the rate limit is consumed
A caller with the wrong scope has made a mistake no amount of waiting fixes. Burning their quota to say so would turn a 403 into a 429 on the retry, which reads as a completely different bug and sends whoever is debugging it to the wrong page. The order is fixed: key, then scope, then limit, then tenant mode.
Idempotency
Orders are idempotent on clientOrderId, scoped to the operator and the player. The guarantee comes from a unique index on (operator, user, clientOrderId), so two requests arriving at the same instant produce one order and the loser re-reads the winner's.
POST /api/v1/orders { "quoteId": "...", "clientOrderId": "ticket-9031" }
→ 201 { ..., "idempotentReplay": false } first call, order created
POST /api/v1/orders { "quoteId": "...", "clientOrderId": "ticket-9031" }
→ 200 { ..., "idempotentReplay": true } same order returned- Derive the key from the reservation in your own ledger (your ticket or bet id), never from a clock and never from a random value per attempt. A retry has to present the same string to be worth anything.
- A rejected order still consumes its
clientOrderId. That is deliberate: retrying a spent key must not open a second live order.
There is no Idempotency-Key header
No endpoint accepts one. Idempotency here is a property of the resource rather than of a header: clientOrderId for placing an order, and the ORDER itself for confirm-reserve and cancel. A second confirmation yields one executable order, and a second cancel replays the same cancellationId and the same releaseAmount. In every one of those cases the safe action after a lost response is to send the same request again. Reusing one clientOrderId for a different order is the one case that conflicts: 409 idempotency_conflict.
Rate limits
Two per-minute limiters run on every /api/v1 route: one per key, and one per operator across every key it holds. Most responses, a success as much as a refusal, carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset for the key. The quote and order routes answer without them, and no header reports the tenant counter. Full semantics on Rate limits.
Embed sessions
Your API key is a server credential and must never reach a browser. To mount Parity Embed inside your own page, exchange it server-side for a short-lived session scoped to one of your customers. This section is about the credential. The full integration (iframe, sizing, the message handshake, the reserve handshake) is on that page.
curl -X POST $PARITY_BASE/api/v1/embed-sessions \
-H "Authorization: Bearer $PARITY_KEY" \
-H "Content-Type: application/json" \
-d '{"externalUserId":"your-customer-id","returnOrigin":"https://your-app.example"}'
# → { "token": "pes1.…", "expiresAt": "…", "expiresInSeconds": 900, … }Hand that token to the browser and let it mount the embed. It authorises the embedded customer experience for one operator and one customer and nothing else: not your API key, not another tenant, not another of your customers, not admin, and no venue credential.
- Needs all three scopes.
markets:read,quotes:writeandorders:writetogether. A session can browse, quote and order, so the key that mints one must be able to do all three itself. Any other rule would let a deliberately narrowed key mint a wider capability than it holds. A key missing one gets403 insufficient_scopenaming which. - Short-lived. 15 minutes by default, 60 seconds to 1 hour by request. Mint a new one rather than trying to extend one.
- Header only. Send it as
Authorization: Embed <token>. A token in a query string is refused outright, because query strings land in logs, browser history and referrer headers, and a credential that leaks that way leaks quietly. - Origin-bound. Supply
returnOriginand the session only works from that origin. It is a browser-scoped control: it stops a leaked token being used on someone else's page, not from a terminal. - Revocable. Because it is a handle rather than a self-describing token, a session can be ended immediately, for self-exclusion, a suspension, or a leak.
Your offering policy governs the embed automatically
The session identifies you, so the catalogue your customer sees is the one your offering policy allows, by the same evaluator that governs quoting and ordering, applied to discovery, to the live price stream, and to any market id supplied by hand. A market you do not offer cannot be browsed, cannot be quoted, cannot be ordered, and will not reach the browser over the live channel. There is nothing to filter on your side.
With the SDK
The TypeScript client takes the key once and threads the player header for you, so the two identities cannot be swapped by hand on a call.
import { ParityClient } from '@/sdk';
const parity = new ParityClient({
baseUrl: process.env.PARITY_BASE!,
apiKey: process.env.PARITY_KEY!, // pk_test_… in sandbox
});
// Catalogue reads need no player.
const events = await parity.listEvents({ category: 'economy', limit: 10 });
// Anything that trades is bound to YOUR id for the player.
const player = parity.forUser('user-4471');
const quote = await player.quoteBuy({ contractId, side: 'YES', stake: '50.00' });A missing scope surfaces as a typed InsufficientScopeError carrying required. See the SDK.
The public demo surface
/api/demo/quotes and /api/demo/orders take no credentials. They run the same pricing and order code as the partner routes under a well-known demo operator, which is what makes the public demo evidence that the partner path works rather than a parallel implementation.
Read this
Do not build against /api/demo/*. It is unauthenticated, it identifies a visitor by a cookie it mints for them rather than by a credential you hold, and it exists so a page anyone can open still exercises the production pipeline.
Aggregators: one key, many operators
If you resell Parity to your own downstream operators, you hold ONE key and name which operator each request is for. Send x-parity-operator carrying that operator's id, alongside your usual x-parity-user. Omit it, or name your own id, and nothing changes: you are an ordinary direct tenant.
A key may act for itself, and for an operator it is the direct parent of. Nothing else. Naming an operator you do not parent is 403 operator_not_permitted, and the refusal deliberately does not tell you whether that operator exists, because an error that distinguishes "not yours" from "not real" is a way to enumerate other tenants.
Each downstream operator is a full tenant in its own right, so its market offering, wager limits, display currency, language, fees and reporting are its own and are configured the way any tenant's are. Rate limits are metered per operator rather than per key, so one busy downstream client cannot spend another's budget.

