InterposeInvest

Sandbox environment. Interpose is not a registered broker-dealer and holds no FINRA approval. Every API documented here is available in a simulated evaluation environment only — no client assets are held, no real trades are executed, and no custody is provided. The calculation engines are the production code paths; fills, custody, settlement and KYC decisioning are simulated. What is real and what is simulated →

Learn these once instead of per endpoint.

A small number of rules hold everywhere. Every item on this page is something that breaks a naive client, so it is worth reading before you write a model class rather than after.

Money and quantities are strings

Never parse a monetary value into a float. Not in your ORM, not in your serialiser, not “just for the chart”. Floating-point arithmetic silently corrupts money, and in a regulated context that is a reportable error rather than a rounding curiosity.

DataJSONPrecisionExample
Fiat monetary amountstring4 dp"152.3400"
Equity / fund quantitystring8 dp"10.00000000"
FX ratestring6 dp"1.265432"
Performance returnstring10 dp"0.1630319325"
Percentage / basis pointsstringdecimal form"0.0400" = 4%

Performance returns are decimal fractions, not percentages

"0.1630319325" means 16.30%, not 0.16%. Multiply by 100 before display. Getting this backwards understates every return by a factor of 100 — it is a mistake we have made and fixed in our own client code.

handle as decimals
# Python
from decimal import Decimal
balance = Decimal(response["cash_balance"])      # right
balance = float(response["cash_balance"])        # wrong, and it will bite you

// TypeScript
import { Decimal } from "decimal.js";
const balance = new Decimal(response.cash_balance);  // right
const balance = Number(response.cash_balance);       // wrong

The API does not normalise what you send

A quantity posted as "1.5" is stored as "1.5" and returned as "1.5" on every subsequent read — not "1.50000000". The precisions above describe values the platform computes; values you supply come back exactly as written. Trailing zeros are your responsibility. Compare as decimals, never as strings.

Currency is explicit — under two different field names

Currency is never implied, but the field name depends on the object:

ObjectFieldMeaning
Orders, transactions, quotes, feescurrencyThe currency of this amount
Accounts, portfoliosbase_currencyThe currency the book is denominated and valued in

Reading currency off an account returns nothing. ISO 4217 three-letter codes throughout; USD, GBP and EUR are supported.

Typed security identifiers — on the trading surface

On orders, positions, tax lots and settlements there is no bare symbol field. A security is an object:

{ "type": "ticker", "value": "AAPL" }
{ "type": "cusip",  "value": "037833100" }
{ "type": "isin",   "value": "US0378331005" }

This applies to the trading surface only

Market data, research, screener, watchlist and price-alert endpoints take and return a plain ticker string, because there is no lot or position to identify — the symbol is the whole reference. GET /api/v1/baas/market/quotes/AAPL is correct as written.

The rule to carry: anything that touches a position or a tax lot takes the typed object; anything that only looks up a price does not.

Identifiers

Every entity id is a prefixed ULID — a type prefix, an underscore, and 26 Crockford base-32 characters. ULIDs rather than UUIDs because they sort lexicographically by creation time and are URL-safe with no escaping.

acct_01HV4Y2K3M5N8P9QR2ST4UVWXY
PrefixEntityPrefixEntity
acct_Brokerage accountptf_Portfolio
ord_Orderreb_Rebalancing run
pos_Positionlot_Tax lot
txn_Transactionevt_Event
pauth_Portal user (a login)sbx_Sandbox signup

Treat ids as opaque strings

Not every *_id field is a prefixed ULID — firm_id among them. Some are firm-supplied or legacy codes. A client that validates every *_id against a ULID regex will reject live, valid responses. Do not parse them, do not assume a length, and do not derive one id from another.

Timestamps

Always UTC. Fractional precision and suffix are not uniform across services, so parse with a conformant ISO 8601 parser rather than pattern-matching:

ServiceShape
BaaS, PM2026-05-11T14:23:00.000000000Z — 9 digits, Z
Agents, CRM2026-05-11T14:23:00.000000Z — 6 digits, Z
Auth2026-05-11T14:23:00.000000+00:00 — 6 digits, offset

All three are valid ISO 8601 UTC and any conformant parser handles them. A regex expecting Z or exactly nine digits will not. For ordering, sort by ULID rather than by timestamp — it is monotonic by construction and immune to precision differences.

Pagination — four shapes

the envelope, where it applies
GET /api/v1/baas/accounts?page=1&page_size=100

{
  "items":     [ ... ],
  "total":     412,
  "page":      1,
  "page_size": 100
}

The envelope is the minority shape — check before you loop

Four shapes exist across the platform, and the envelope is not the common one. A loop written against body["items"] does not fail loudly on the other three — it raises on a bare array, or silently yields nothing where the collection sits under a different key. Detect the shape rather than assuming it.

ShapeWhere
{items, total, page, page_size}Top-level collections — /accounts, /portfolios
A bare JSON arrayThe majority — most sub-resources, e.g. /accounts/{id}/positions
{items, total}Assorted reports and registries, with no paging keys
{clients, total}A named collection key — a handful of listings

page is 1-indexed. Parameter names are not uniform either — most endpoints take page/page_size, some take limit/offset, and a few accept no paging parameters while still returning a page_size key. A defensive accessor costs one line:

def rows(body):
    if isinstance(body, list):
        return body
    for key in ("items", "clients", "results", "data"):
        if isinstance(body.get(key), list):
            return body[key]
    raise ValueError(f"unrecognised list shape: {sorted(body)[:8]}")

Errors — detail has three shapes

Errors are FastAPI-standard, which means detail is a string, a list, or an object depending on why the request failed. Type-check it before indexing.

1. a handled error — string
{ "detail": "Account acct_01HV4Y... not found." }
2. a schema validation failure — list
{
  "detail": [
    { "type": "missing", "loc": ["body", "security"], "msg": "Field required" }
  ]
}
3. a business-rule rejection — object
{
  "detail": {
    "message": "Order rejected by compliance checks.",
    "failed_checks": [
      { "check": "account_active", "reason": "Account ... is PENDING; must be ACTIVE." }
    ]
  }
}
d = response.json()["detail"]
if   isinstance(d, str):  reason = d
elif isinstance(d, list): reason = "; ".join(e["msg"] for e in d)
else:                     reason = d.get("message", str(d))
StatusMeaningRetry?
400Malformed requestNo — fix the request
401Not authenticatedNo — get a token
403Authenticated, outside your scopeNo
404Not found, or not visible to youNo
409Conflict with current stateNo — re-read state first
422Schema failure or a business-rule rejectionNo — inspect detail
429Application-level cap exceededYes, with backoff
503Rate limited at the edge, or a dependency is downYes, with backoff

Rate limits

PathSustainedBurst
/api/v1/{pillar}/*600 / minute100
/api/v1/agents/*600 / minute40
/api/v1/auth/* — including sandbox signup10 / minute5
This site's signup form5 / minute3

Exceeding a limit returns 503, not 429 — and there is no Retry-After

The edge proxy uses its default status. A client that backs off only on 429 will hot-loop against a 503. Use exponential backoff with jitter, and treat 503 as potentially rate-limit-related.

The 429 you will see is application-level — for example a sandbox tenant exceeding its 100-account cap.

Idempotency

Idempotency support is per-endpoint, not platform-wide. There is no global Idempotency-Key header. Where it exists it is an explicit field in the request body. The safe general pattern: capture the ids the API returns, and have your retry logic check for existence before re-posting.

Versioning

Every path is under /v1. There is no /v2 and no deprecation in flight. Additive changes — new response fields, new optional request fields — ship inside v1 without notice, so your client must ignore unknown fields rather than failing to deserialise them. Breaking changes get a new version prefix.

Positions are derived, not stored

One architectural fact that changes how to read the API: positions and balances are folded from an immutable transaction ledger rather than held as mutable rows. The ledger is append-only — corrections are new entries, never edits, enforced at the database level rather than by convention. A position that looks wrong is a symptom; the ledger is the source of truth, and it is queryable.