Getting started

Authentication

One credential identifies the operator; one header identifies the player. Predicta never authenticates an end user and holds no personal data about them.

Every partner request

http
Authorization: Bearer pk_test_...          # sandbox. Live keys are sk_live_...
X-Predicta-User: <your own id for this player>
Content-Type: application/json
HeaderRequired onMeaning
AuthorizationEvery /api/v1/* requestThe operator credential. Bearer scheme only.
X-Predicta-UserPOST /api/v1/quotes, POST /api/v1/orders, POST /api/v1/funding/deposit-addressYour opaque id for the player. It is the entire record Predicta keeps of them.

The funding route in that list is sandbox only and answers 403 sandbox_only for a live operator, along with GET /api/v1/users/{id}/balance and GET /api/v1/users/{id}/ledger. They fund and read a balance held by Predicta, 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.

The one exception is GET /api/v1/images/{id}, which serves event artwork from Predicta's own origin and takes no credentials at all: it is an <img src> in a player's browser, and a page cannot be handed a secret key.

Missing the user header is a 400, not a 401

A request with a valid key and no X-Predicta-User returns 400 missing_user. It is a well-formed credential making an incomplete request, not a credential problem.

What X-Predicta-User actually is

Predicta 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. An id that has never traded is 404 unknown_user, not 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.
  • Every read surface keys on your id, never on a Predicta 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. Predicta 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_XXXX or sk_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_key and stays on the record.
  • A suspended operator returns 403 operator_suspended on 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.

ResponseCodeMeaning
401missing_credentialsNo Authorization header, or not a Bearer token.
401invalid_keyThe key is not recognised.
401revoked_keyThe key was revoked.
403operator_suspendedThe 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.

ScopeRoutes it opens
markets:readGET /api/v1/events, GET /api/v1/events/{id}, GET /api/v1/categories
quotes:writePOST /api/v1/quotes
orders:writePOST /api/v1/orders
orders:readGET /api/v1/orders, GET /api/v1/orders/{id}
positions:readGET /api/v1/positions, GET /api/v1/positions/{id}
settlements:readGET /api/v1/settlements, GET /api/v1/settlements/positions
ledger:readGET /api/v1/reconciliation, GET /api/v1/reconciliation/feed, and the sandbox-only balance and movement reads
funding:writeThe 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.

http
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, and there is no 409 idempotency_conflict response. Orders are the only idempotent write on this API and clientOrderId is how you get it.

Rate limits

A per-key, per-minute limiter runs on every /api/v1 route. Every response — a success as much as a refusal — carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, so a client can slow down before it is refused rather than discovering the budget by hitting it. Full semantics on Rate limits.

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.

typescript
import { PredictaClient } from '@predicta/sdk';

const predicta = new PredictaClient({
  baseUrl: process.env.PREDICTA_BASE!,
  apiKey: process.env.PREDICTA_KEY!,   // pk_test_… in sandbox
});

// Catalogue reads need no player.
const events = await predicta.listEvents({ category: 'economy', limit: 10 });

// Anything that trades is bound to YOUR id for the player.
const player = predicta.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, its identity is shared, and it exists so a page anyone can open still exercises the production pipeline.