Skip to content

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_id identifies the row itself.
  • account_id is the customer account the row belongs to — the identity every access decision is made against.
  • fields carries exactly what you asked for, after field-level security.
  • next_cursor is 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.
  • equity is balance + credit + open_pnl.
  • margin_level is equity / 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".
  • live is true when the figures came from a live snapshot and false when the account has been idle and the figures were derived from its stored balances instead. Both are correct; live: false simply means nothing has moved.
  • applied_revision is a freshness token. It is 0 for 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.