Environments & endpoints
Sandbox and production base URLs, which surface talks to which host, and the limits that apply to every call.
There are two environments — sandbox and production — and they are separate systems, not two modes of one. The part that catches people is not the base URL, though: it is that not every side of the integration talks to the API domain at all. Your backend does. The player's browser does not.
The hosts
| Sandbox | Production | |
|---|---|---|
| B2B API — you call us | https://api.sandbox.predmask.com | https://api.predmask.com |
| Player origin — platform-provided host | <game-host> | <game-host> |
| Wallet hooks — we call you | your wallet_base_url | your wallet_base_url |
Every example in this book is written against the sandbox hosts. Only the first row is a fixed address of ours, and it is fixed per environment: moving to production is a change of base URL and credentials, not a change of contract. Your credentials are issued per environment and are not interchangeable.
The other two rows are not addresses you copy from this page, but for opposite reasons. The player origin is a platform-provided host — not one of yours — and it reaches you inside game_url from the launch call, never constructed by hand; the exact host differs per environment and can differ per launch. And wallet hooks run on your host, not ours — the operator calls the wallet_base_url you registered, so nothing about that leg involves the API domain in either direction.
The player origin is platform-provided, in both environments. You do not register or point a domain of your own to launch a player — the platform supplies the host. What can still stop a launch is the merchant not being launch-ready: with no site_key, or its setup otherwise incomplete, the launch call refuses with merchant_domain_not_ready and returns nothing to embed. Merchant-owned custom domains (CNAME) still exist for merchants that enabled one before this changed, but they are deprecated and draining — see Custom domain (CNAME).
Which side uses the API domain
This is the distinction worth getting right before you write any client code, because guessing it wrong produces failures that look like authentication problems and are not.
server to server
Your backend → the API domain
Everything under /b2b/v1 — minting launch tokens, orders, quotes, positions, settlements — is signed with your B2B key and sent to the API host in the table above. This is the only surface a base URL of ours belongs in your configuration.
in the browser
The player → a platform-provided host
The embedded game, the player API under /c-api/v1 and the realtime feed are all served on the platform-provided host inside game_url, routed there by our edge, so every call the frame makes is same-origin with the frame. The player's browser never calls the API domain; who the request is for comes from the launch token the session was opened with, not from the Host.
This is not a preference, and there is no cross-origin fallback. The player API answers no CORS headers at all and has no preflight routes, so a page served from any other origin cannot call it from a browser regardless of what credentials it holds — the calls have to originate inside the frame served from the platform-provided host. Who the request is for comes from the launch token the session was opened with, not from a merchant header, so a request arriving on a host we do not recognise answers 404 rather than being attributed to anyone. If you are building against an older description of this — a browser talking straight to the API domain with a merchant header — that shape is retired, header and all.
https, everywhere, with no fallback
Every base URL in this book is https, and no http variant of any host is published. Treat a plaintext base URL in your configuration as a bug rather than a degraded mode: it is not a supported form of these hosts, so a client that quietly downgrades does not get a weaker connection — it gets a broken one.
The rule is also enforced on the URLs you register with us, which is where it bites in practice. A wallet_base_url on http is rejected outright when you save it — 400, with a message naming TLS — and the only exemption is a loopback address for local development. It is not a production-only check and there is no flag that relaxes it: the wallet hook is the channel money instructions travel on, so it is held to TLS in every environment. The same save-time validation refuses private, loopback, link-local and cloud-metadata addresses, and redirects are never followed at call time. See Wallet hooks for what that failure looks like from your side, which is nothing at all.
The two environments share nothing
- Separate data. Merchants, players, markets, orders and settlements exist independently in each. Nothing is copied across, in either direction, at any point — a sandbox player id and the same string in production are two unrelated accounts.
- Separate credentials, and they are not interchangeable. The
access_key/secretpair issued for one environment answers401against the other — a flat unauthorized with no hint that the key simply belongs elsewhere, which is why a mixed-up base URL is one of the classic causes of an afternoon spent debugging a signature that was correct all along. Keep the base URL and the key in the same block of configuration so they cannot drift apart. - Separate launch readiness. Each environment has its own merchant profile, so being launch-ready in sandbox grants nothing in production — complete the profile in each. There is no domain of yours to register for this; the platform supplies the host. See Custom domain (CNAME).
- Sandbox first, deliberately. The sandbox exists so that a version of your integration can be exercised against real platform behaviour with disposable data before it touches real money. Certification runs there, and production access follows it rather than preceding it.
Telling “not deployed” from “not authorised”
The two environments do not always carry the same build, so a call that works in one can 404 in the other for a reason that has nothing to do with your request. Probe with an unauthenticated, empty request and read the status: it separates the two cases without needing a valid signature.
curl -s -o /dev/null -w '%{http_code}\n' -m 30 \
--retry 4 --retry-all-errors --retry-delay 2 \
-X POST "https://api.sandbox.predmask.com/b2b/v1/auth/launch-token"
# 401 → the route exists and wants authentication → deployed
# 400 → the route exists and wants valid arguments → deployed
# 404 → not deployed in this environmentAn occasional 000 from curl is a TLS handshake that timed out, not an endpoint that is down — hence the retry flags above. Treat a single one as noise and a run of them as a network path worth looking at.
Limits that apply in both
These are identical in sandbox and production, which makes the sandbox a fair place to discover them.
| Field | Type | Description | |
|---|---|---|---|
| request rate | 429 | optional | 300 requests per 10 seconds, fixed window, counted per source IP address — one bucket shared by every surface, not a per-merchant or per-key quota. Only /healthz and /metrics are exempt. Over the limit answers too_many_requests with a Retry-After header whose value is the whole window; no X-RateLimit headers are sent. Budget your polling against this before you design it — see the B2B API page for what it means for a player base behind one egress address. |
| timestamp window | 401 | optional | X-Timestamp must be within ±300 seconds of platform time. (The retiring sa1 browser credential carries its own ts under the same window; the host-free launch token that replaces it carries none.) Your clock is your responsibility: there is no handshake that corrects drift, and a drifted signer fails identically to a bad key. |
| nonce lifetime | 401 | optional | A nonce is single-use and remembered for 601 seconds — twice the clock window plus one — scoped per access key. Re-sending an identical signed envelope after a timeout therefore fails; re-sign instead, keeping the same idem_key in the body. |
| request body | 413 | optional | 1 MiB. The cap is applied before the signature is checked and the body is never truncated and then verified, so an oversized request is refused outright rather than silently altered. |
| source IP | 403 | optional | If your merchant runs an IP allowlist, calls from an unlisted address are refused with 403 rather than 401 — the split is deliberate, so status alone tells a source problem from a signing one. Under the strict policy an empty list rejects everything. |
Because the rate limit counts addresses rather than accounts, it is your egress topology that decides your real headroom, not your contract. The B2B API page works that through, along with the fixed-window edge cases; the error codes page separates this 429 from the unrelated one that means a lease is held elsewhere.
Where the base URL shows up
Every signed example elsewhere in this book uses one of the hosts above — this page is the single place they are defined, so a curl that does not work is worth checking against this table before anything else. The B2B canonical is signed over the path, never the host, so switching environments means changing the base URL and the key and nothing else in your signer. See Authentication for the canonical itself and Signature self-check for telling a wrong-environment key apart from a wrong canonical.
# sandbox
API_BASE_URL = https://api.sandbox.predmask.com
API_KEY = ak_... # issued for sandbox — 401 anywhere else
API_SECRET = <32+ bytes> # server-side only, never in a browser
# production — use the values delivered with your merchant, not these
API_BASE_URL = https://api.predmask.com
API_KEY = ak_...
API_SECRET = <32+ bytes>