Docs/Core Concepts/Response envelope & sideloading

Response envelope & sideloading

Every list and single-resource response carries a consistent envelope. An opt-in ?include= parameter sideloads related resources into a deduplicated included block, so a single request can return everything the caller needs to render rich views without secondary lookups.

Envelope shape

List, single-resource, and filter responses share the same outer shape:

Response envelope
{ "data": [ ... ] // or { ... } for single-resource endpoints "included": { "<object_type>": [ ... ] }, "meta": { "next_cursor": "...", "has_more": true }, "warnings": [ ... ] }
data
The primary resource(s). An array on list endpoints, a single object on detail endpoints.
included
Sideloaded related resources, keyed by resource type. Absent unless the request supplies ?include= tokens. Present (with empty arrays where nothing matches) once ?include= is used.
meta
Cursor pagination metadata (next_cursor, has_more). Present on list endpoints; absent on single-resource endpoints.
warnings
Optional advisory notes from soft validation — for example, when a cross-type filter references a field key that does not resolve uniformly across every type. Absent when empty.
References inside data always carry the minimal embed shape (id plus a few render-critical fields). The full objects live in included. Expanding a relationship never rewrites the references in data.

The ?include= parameter

Pass ?include=<token>[,<token>...] on a GET endpoint to expand related resources. Each token names a relationship — not a field.

Expanding two relationships
GET /v1/workspaces/{workspaceId}/instruments?include=type,tags

Filter endpoints take the same tokens as a JSON body field instead of a query string, keeping the request self-contained:

POST /filter — body array
POST /v1/workspaces/{workspaceId}/instruments/filter Content-Type: application/json { "filter": { /* nested and/or conditions */ }, "include": ["type", "tags"] }
Count endpoints (/.../count) do not support include. Their response is just {"count": N}, with no data for related resources to attach to.

Token rules

Tokens follow a small set of rules that hold across every resource:

  • Tokens name relationships, not fields. ?include=type means “expand the type”, which implicitly carries everything needed to render that type.
  • Multiple tokens are comma-separated: ?include=type,tags.
  • Duplicate tokens are silently deduplicated. ?include=type,type is equivalent to ?include=type.
  • There is no per-request maximum on the number of tokens. Callers requesting expensive expansions pay the latency cost themselves.
  • Unknown tokens return 400 Bad Request with a payload listing the valid tokens for that endpoint (see Validation below).

Dot-notation for cross-resource expansion

Dot-notation crosses resource boundaries. ?include=instrument.type on the time series values endpoint expands the referenced instrument and that instrument’s type — both appear in included under their respective resource keys.

Multi-level expansion in one request
GET /v1/workspaces/{workspaceId}/time-series-values ?include=series.tags,instrument.type
  • Dot-notation is only valid between different resources. Same-resource drill-downs (e.g. type.fields, since fields is a sub-property of the type object) are rejected with 400.
  • Maximum depth: 2 levels (a.b works, a.b.c is rejected with 400). Two levels covers every observed dashboard rendering need; deeper nesting is deferred until a concrete use case appears.

Default minimal embed vs. expanded included

Without ?include=, references in data are minimal — enough to render a list row without a follow-up request:

Default — minimal type embed and tag_ids array
{ "type": { "id": "equity-type-id", "name": "Equity", "identifier": "equity", "color": "#185FA5" }, "tag_ids": ["large-cap-id", "materials-id"] }

With ?include=type,tags, the references in data are unchanged. The full objects appear once each in included, deduped by ID across the response page:

Expanded — included block
"included": { "instrument_types": [ { "id": "equity-type-id", "name": "Equity", "color": "#185FA5", "fields": [ ... ] } ], "instrument_tags": [ { "id": "equity-id", "parent_id": null, "path": ["equity-id"] }, { "id": "large-cap-id", "parent_id": "equity-id", "path": ["equity-id", "large-cap-id"] } ] }

Dedup is by resource ID and scoped to a single response page. When a list is paginated, each cursor page carries its own included block; clients are responsible for caching across pages if they need workspace-wide dedup.

Frontend consumption pattern

Build lookup tables once per response page, then resolve references on every data row:

Resolving included via lookup tables
const typesByID = Object.fromEntries( response.included.instrument_types.map(t => [t.id, t]) ); const tagsByID = Object.fromEntries( response.included.instrument_tags.map(t => [t.id, t]) ); for (const instrument of response.data) { const fullType = typesByID[instrument.type.id]; const fullTags = instrument.tag_ids.map(id => tagsByID[id]); // render with fullType.fields, fullTags[i].path, etc. }

Validation

Unknown tokens return 400 Bad Request. The payload enumerates the valid tokens for that endpoint exactly — no spelling correction or near-match suggestion.

400 — invalid_include
{ "error": "invalid_include", "message": "Unknown include token: 'type.fields'. Valid tokens for this endpoint: 'type', 'tags'.", "details": { "invalid_tokens": ["type.fields"], "valid_tokens": ["type", "tags"] } }

The exact token set for any given endpoint is documented on the relevant API reference page (e.g. Time Series).

PrivacyTermsStatus© 2025 Ptolemy Pty Ltd