Authentication¶
Every call to the Broker Platform API carries an access token, and every access
token comes from POST /api/v1/oauth/token.
There are two kinds, for two different jobs.
| Token | Grant | Lifetime | Used by |
|---|---|---|---|
| Server token | client_credentials |
10 minutes | Your backend |
Delegated token — opaque, prefixed dt_ |
RFC 8693 token exchange | 15 minutes | One trader's session |
Your credential¶
An administrator in your firm creates the credential in the Backoffice, under
Security → Broker API Credentials. Creating it returns a client_id and a
client_secret.
The secret is shown once
The client_secret is displayed exactly once, at creation, and cannot be
read back afterwards. Put it in your secret manager at that moment. If it
is lost, rotate the secret rather than creating a second credential for the
same integration.
Each credential carries, and your administrator can change at any time:
- Scopes — what it may ever ask for. A token can never exceed them.
- Brand — optional. A brand-bound credential reaches only that brand's accounts; leave it unset for the whole firm.
- Allowed addresses — an optional list of addresses and ranges your backend may call from. Empty means any address. It applies when minting a token and on every call made with a server token; it is not applied to a trader's delegated session, which by design comes from wherever the trader is.
- Allowed origins — intended for browser use, but inert today. Nothing reads this list, and no browser can call the REST endpoints regardless of what you put in it. Setting it neither enables nor blocks anything. See the note under delegated tokens.
- Protected fields — which field-level-security-protected fields this credential is allowed to receive unmasked. Empty, the default, means all of them come back masked.
- Plan tier — the rate limits it runs under. See Rate limits.
The credential is not the ceiling
Scopes are intersected with what your firm currently permits. Narrowing the firm's own permissions immediately narrows every credential under it — the API can never grant more than the Backoffice does.
Minting a server token¶
Send the credential with HTTP Basic authentication:
curl -sS -X POST https://platform-api.example.com/api/v1/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'scope=accounts:read transactions:read'
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 600,
"scope": "accounts:read transactions:read"
}
Then send it on every call:
Authorization: Bearer eyJhbGciOi...
scope is optional and must be a subset of what the credential holds; ask
for less than everything if the process needs less. Asking for more than the
credential holds is invalid_scope. If you cannot use HTTP Basic, send
client_id and client_secret as form fields instead — the header wins when
both are present.
Caching and refresh¶
The token lives 600 seconds, and expiry is enforced with no grace period.
- Cache it. The token endpoint allows 10 requests per minute per
client_id, so minting per request will fail. - Refresh early — roughly 30 seconds before expiry — so a request already in flight cannot cross the boundary.
- Mint once across concurrent workers. A cold start that fans out ten parallel requests must not become ten token requests.
The SDKs do all three. If you are hand-rolling, budget for them.
Minting a delegated trader token¶
A delegated token is scoped to a single customer account. Your backend exchanges its server token for one and hands that to the trader's session, so your credential never reaches a browser.
The exchange authenticates with your server token, not with the client secret:
curl -sS -X POST https://platform-api.example.com/api/v1/oauth/token \
-H "Authorization: Bearer $SERVER_TOKEN" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
-d 'subject_token=1000123' \
-d "subject_token_type=$SUBJECT_TOKEN_TYPE" \
-d 'read_only=false' \
-d 'scope=stream:quotes stream:account'
{
"access_token": "dt_9f2c...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 900,
"scope": "stream:quotes stream:account"
}
subject_tokenis the customer account id, as a decimal string. A customer account is the only subject type served today.subject_token_typeis a URN identifying that subject type. It is being finalized ahead of general availability and is issued to you with your credential — see the note below.- Requires
trader:delegateon the server credential.
One value is still settling
The subject_token_type URN is the last piece of the token-exchange
contract that is not final. It is deliberately not printed here so that no
integration is written against a value that changes before launch. Your
integration pack carries the current one, and the changelog
will record it the day it is frozen.
Read-only is the default¶
read_only defaults to true. To mint a token that could ever trade or move
funds, your backend must explicitly send read_only=false, which it should do
only after it has authenticated the trader itself. A token minted for a
read-only login never carries trading or funds capability, whatever you ask
for.
What a delegated token can do today¶
Delegated tokens authorize the WebSocket channels — live prices
and that account's live figures and events — and, when minted with
read_only=false, trading and funds calls on
their own account.
They cannot call the bulk-read endpoints, which require the server-credential
scopes (accounts:read, positions:read, transactions:read). Run those reads
from your backend.
A delegated token is limited to the account it was minted for, and — when the
exchange named a specific trading account — to that trading account. Anything
else answers 404, not 403, so the token cannot be used to discover which
ids exist.
Browser calls are not available
Calling the REST endpoints from a browser does not work, and the Allowed origins setting on your credential does not change that: nothing reads it, so no web origin is ever permitted and every cross-origin request is blocked by the browser itself.
The WebSocket is unaffected — browsers do not apply that check to it — so a browser session can stream with a delegated token today. Route every REST call, read or write, through your own backend.
Failures are deliberately uninformative¶
A subject that does not exist, belongs to another firm, or belongs to a
different brand all answer the same invalid_grant. That is intentional: a
distinguishable error would let anyone map your customer ids by trial. Do not
build logic on the difference — there isn't one.
Delegated tokens live 900 seconds and do not slide. A busy trader's token still expires on schedule, so re-exchange rather than trying to extend.
Scopes¶
The scope vocabulary is frozen: these fourteen strings are the wire contract,
and none of them will change meaning inside v1. Request only what a given
process needs.
| Scope | Grants | Available |
|---|---|---|
accounts:read |
Read accounts and trading accounts, including live status | Yes |
positions:read |
Read positions | Yes |
transactions:read |
Read monetary transactions | Yes |
stream:quotes |
Subscribe to the price stream | Yes |
stream:account |
Subscribe to live account figures and the event feed | Yes |
webhooks:manage |
Create, inspect, rotate and disable webhook subscriptions | Yes |
trader:delegate |
Exchange a server token for a delegated trader token | Yes |
accounts:write |
Create and update accounts, singly and in bulk | Yes |
trades:write |
Open and close positions | Yes |
transactions:write |
Record transactions, and approve, reject or cancel them | Yes |
trader:trade |
Same as trades:write — see the note below |
Yes |
trader:funds |
Same as transactions:write — see the note below |
Yes |
transactions:amend |
Nothing on its own. It extends the two funds scopes so they may also cancel a transaction that has already settled | Only as an extension |
trader:read |
Reserved for reading on behalf of one trader | Not yet served |
trader:read is real and grantable, but no endpoint accepts it today.
Granting it changes nothing; it will start working when the matching endpoints
ship, and that will be a changelog entry.
The trader: prefix is not a boundary
trader:trade and trader:funds limit a delegated token to its own
account. Held by a server credential, they do not limit anything: that
credential can trade and move money on every account it can reach, exactly
as trades:write and transactions:write do. The account limit comes from
the delegated token, not from the scope name. Grant trader:trade and
trader:funds to a server credential only if you mean to grant full trading
and funds capability.
Holding a scope is necessary but never sufficient. Every call is also authorized against the specific record, so a firm-wide credential still cannot reach another firm's account, and a brand-bound one cannot reach another brand's.
Write scopes are checked once more, per call, against what your firm currently permits. Narrowing your firm's own permissions makes a granted write scope inert immediately, and the API answers exactly as it would if the scope had never been granted.
Rotation¶
Rotate on a schedule, and immediately after any suspected exposure. Your administrator has three separate controls, and they do different things:
| Control | Effect |
|---|---|
| Rotate secret | Issues a new client_secret. The old secret stops working immediately, so no further tokens can be minted with it. Server tokens already minted keep working until they expire. |
| Revoke all tokens | Leaves the secret alone and invalidates every outstanding server token at once. |
| Revoke credential | Retires the credential. It keeps its history for audit and can never authenticate again. |
Rotating the secret does not recall tokens already issued
Rotating closes the door on minting; it does not reach back for the tokens already through it. Those remain valid for up to their 10-minute lifetime. When you are responding to a leak, do both: rotate the secret and revoke all tokens.
The unhurried path is to create a second credential, deploy it, confirm traffic has moved (each credential records when it was last used), then revoke the first.
Errors from the token endpoint¶
The token endpoint answers in OAuth 2.0's own error shape, not problem+json.
| Status | Body | Meaning |
|---|---|---|
401 |
{"error":"invalid_client"} |
Unknown, wrong, revoked, or called from a disallowed address. One uniform answer for all four. |
400 |
{"error":"invalid_request"} |
The form is malformed, or a parameter was sent twice. |
400 |
{"error":"unsupported_grant_type"} |
That grant is not available here. |
400 |
{"error":"invalid_scope"} |
You asked for more than the credential holds. |
400 |
{"error":"invalid_grant"} |
The token-exchange subject was rejected. |
429 |
{"error":"rate_limited"} |
More than 10 token requests in a minute. Honor Retry-After. |
Keeping tokens safe¶
- Never put a token in a URL, a log line, or browser storage you do not control. The WebSocket takes its token as a subprotocol rather than a query parameter for exactly this reason.
- A leaked server token is usable for at most 10 minutes. Rotate the secret to close the window immediately.
- A leaked delegated token affects one account for at most 15 minutes. In early access there is no way to recall one before it expires, so treat the 15-minute lifetime as the exposure window and keep delegated tokens out of anything that persists them.
- If a
client_secretleaks, treat every token minted from it as compromised: rotate the secret, then review the credential's recorded last-used time.