Bulk reads¶
Two endpoints cover reading data in bulk: a general query surface over your
firm's records, and a purpose-built page of live trading-account figures. Both
are POST, both are paged the same way, and neither changes anything.
Ids come back as numbers here
record_id, account_id and trading_account_id are JSON numbers on
these two endpoints, and JSON strings on every
write endpoint. Convert them when you
feed a read into a write. This is an inconsistency between the two halves
of the API, and it is on the list to reconcile before general availability.
Query an entity¶
POST /api/v1/{entity}/query
{entity} names what a page of rows is about. Four are served:
{entity} |
A row is | Scope required |
|---|---|---|
account |
A customer account | accounts:read |
forex |
A trading account | accounts:read |
position |
An open position | positions:read |
monetary_transaction |
A deposit, withdrawal, or other money movement | transactions:read |
Anything else is 404 not_found. That includes entities that exist internally
but are not part of this API — the surface deliberately does not tell you which
is which, so you cannot map it by probing.
Closed positions
Closed positions are not queryable on this API yet. Rather than return an
empty page — which is indistinguishable at your end from "this account has
no closed trades", and is the worst possible answer about money — the
entity is simply not served, and asking for it is a 404.
The request¶
curl -sS -X POST https://platform-api.example.com/api/v1/monetary_transaction/query \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"columns": [{"field": "amount"}, {"field": "currency"}, {"field": "created_at"}],
"filter": {
"kind": "group",
"combinator": "and",
"children": [
{"kind": "condition", "field": "amount", "op": "gte", "values": ["1000"]},
{"kind": "condition", "field": "currency", "op": "eq", "values": ["USD", "EUR"]}
]
},
"sort": "created_at",
"dir": "desc",
"limit": 200
}'
Finding the field names
Field names are lower-case with underscores, and each entity publishes its
own set. Send one request with no columns and a limit of 1: the fields
object that comes back is the complete list for that entity, which is a more
reliable answer than any list printed here.
| Field | Meaning |
|---|---|
filter |
The filter tree. Omit for no filtering. |
columns |
Which fields to return. Omit for every field the entity publishes. |
sort |
Sort column, from the entity's allowed set (below). |
dir |
asc or desc. Defaults to desc. |
limit |
Rows per page. Defaults to 100. Larger values are served as 500; zero or negative is served as 1. |
after |
A cursor from a previous page's next_cursor. |
The response¶
{
"rows": [
{
"record_id": 88231,
"account_id": 4210001,
"fields": {"amount": "1500.00", "currency": "USD", "created_at": 1753634000123}
}
],
"next_cursor": "eyJzb3J0Ijo..."
}
record_ididentifies the row itself.account_idis the customer account the row belongs to — the identity every access decision is made against.fieldscarries exactly what you asked for, after field-level security.next_cursoris present only when there is another page. There is no total count: counting a firm-wide ledger costs more than the page does, and a stale count is worse than no count.
The account fields you receive¶
There is one account entity for everyone, and you get the shape that matches
your platform. The fields an account row carries follow your firm's
offering — you do not choose a shape or pass
a flag, the response simply reflects what your firm operates:
- On a standalone trading offering, a row carries the core identity and contact fields — name, first and last name, phone, country, language, status, city, postal code and date of birth. That is the whole of what a trading-only setup keeps about a person.
- On a CRM offering — a brand with full Client Zone sign-up — a row additionally carries the customer-relationship data your operators maintain (such as marketing attribution, priority and next action, and operator tags and notes) and any custom fields your firm has defined.
This applies to the account entity only. The forex, position and
monetary_transaction entities return the same fields for every firm.
Custom fields are opt-in per request
Custom fields come back only when you name them in columns with
custom: true, and they arrive nested under a customFields object inside
each row's fields. A request that names no columns returns the built-in
fields only, so defining a new custom field never changes the shape an
existing query already relies on. A standalone-offering credential cannot
name a custom field at all.
Sorting¶
Each entity allows a small set of sort columns, chosen so that page 400 costs
the same as page 1. Anything outside the set is 422 sort_not_allowed.
| Entity | Allowed sort |
Default |
|---|---|---|
account |
record_id |
record_id |
forex |
record_id |
record_id |
position |
record_id |
record_id |
monetary_transaction |
created_at, record_id |
created_at |
record_id increases with creation, so sorting by it is a stable proxy for
insertion order.
Building a filter¶
The filter is a tree of four node kinds, each tagged by kind.
condition — compare a field.
{"kind": "condition", "field": "country", "op": "eq", "values": ["DE", "AT"]}
| Field | Meaning |
|---|---|
field |
The field name. Unknown names are 422; nothing you send is ever treated as a database identifier. |
op |
One of eq, neq, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte. |
values |
The values to compare against, as strings. |
path |
Optional. Relation hops out to a related row that owns field. |
custom |
Optional. true to compare a custom field rather than a built-in one. |
There is no is_null, is_not_null or in operator. How many values you
supply says which you meant:
values |
eq |
neq |
|---|---|---|
| 0 | is empty | is set |
| 1 | equals | does not equal |
| 2 or more | is any of | is none of |
group — combine children with and or or.
{"kind": "group", "combinator": "or", "children": [ ... ]}
join — narrow through a related entity. The related row must exist,
so adding a join is itself a filter, and the related row's fields become
filterable inside it.
{"kind": "join", "relation": "trading_account_id", "combinator": "and", "children": [ ... ]}
exists — ask only whether a related row is there, and read nothing out of
it. Set negated: true for "has no such row", which is the only way to express
it. Unlike a join, an exists node is legal inside an or.
{"kind": "exists", "relation": "trading_account_id", "negated": true}
Custom fields need the flag
A custom field your firm defined may share a name with a built-in one.
Setting custom: true says which you meant, so an already-saved filter
cannot quietly change meaning the day somebody adds a colliding custom
field.
Fields you are not allowed to see¶
Fields protected by field-level security are withheld from fields, and they
are also refused as a filter or sort term — a 422. Filtering on a field you
cannot read would let you learn its value by narrowing, so the two go together.
Your administrator can allow a specific credential to receive specific
protected fields unmasked; see Authentication.
Errors¶
| Status | Code | Cause |
|---|---|---|
400 |
invalid_cursor |
The after cursor is malformed or was edited. |
403 |
forbidden |
The credential lacks the entity's scope. |
404 |
not_found |
No such entity on this API. |
413 |
scope_too_wide |
A brand-bound credential resolves to too many accounts to serve. Narrow the query. |
422 |
validation_failed |
An unknown field, an unusable column, or a bad value. |
422 |
sort_not_allowed |
The sort column is not allowed for this entity. |
A 413 is never a truncated page. Serving a short page silently would make a
reconciliation quietly wrong, so the request is refused instead.
Account status¶
POST /api/v1/accounts/status/query
One page of trading accounts with their live figures — the numbers you would
put on a risk dashboard. Requires accounts:read.
curl -sS -X POST https://platform-api.example.com/api/v1/accounts/status/query \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"limit": 200}'
{
"accounts": [
{
"account_id": 4210001,
"trading_account_id": 4210044,
"currency": "USD",
"applied_revision": 771,
"open_pnl": "-125.40",
"balance": "10000.00",
"credit": "0",
"equity": "9874.60",
"margin": "2000.00",
"margin_level": "493.73",
"net_deposit": "10000.00",
"live": true
}
],
"next_cursor": "eyJzb3J0Ijo..."
}
- Every money field is a decimal string. Parse with a decimal type.
equityisbalance + credit + open_pnl.margin_levelisequity / margin * 100, or null when no margin is in use. Null is not zero: a margin level of zero means liquidate now, so it must never stand in for "nothing at risk".liveistruewhen the figures came from a live snapshot andfalsewhen the account has been idle and the figures were derived from its stored balances instead. Both are correct;live: falsesimply means nothing has moved.applied_revisionis a freshness token. It is0for a trading account no live figures have ever been produced for.
Paging takes the same limit and after as the query endpoint, and is always
ordered by account.
If the live figures cannot be produced at all, the endpoint answers 503 —
it does not serve stale numbers as if they were current.
Paging¶
Both endpoints page with a cursor, not an offset.
cursor = None
while True:
page = post(url, {"limit": 500, "after": cursor} if cursor else {"limit": 500})
handle(page["rows"])
cursor = page.get("next_cursor")
if not cursor:
break
Two things follow, and both matter for a job that runs against a live book:
- There is no "page 7". Follow the cursor. You cannot skip ahead, and you do not need to.
- A page boundary holds under concurrent writes. Offset paging silently skips or repeats rows when the underlying set changes mid-scan; cursor paging does not. That is what makes an overnight reconciliation trustworthy.
The cursor is opaque and signed. Store it if your job can be interrupted, pass
it back unchanged, and do not try to build or edit one — an altered cursor is
400 invalid_cursor, never a different page.