# TRON Charts — full documentation corpus Source: https://docs.troncharts.xyz/docs/ --- # Overview URL: https://docs.troncharts.xyz/docs/start/overview/ TRON Charts is a multi-venue trading platform exposed as one API. The same credential reaches four things: - **Trading** — compose, sign, and dispatch order intents across every wired venue, then read back positions, fills, and balances. - **Real-time state** — depth, tape, marks, and account risk over WebSocket. - **A white-label DEX** — a trading venue under your own brand, backed by the platform's execution, market-data, and risk engine. - **An on-chain prop firm** — a funded-trader program where custody stays at your multisig and the rules are committed on-chain. ## Who each surface is for | You are | You want | Read | | --- | --- | --- | | A trading application or bot | Orders, positions, market data | [Quickstart](/docs/start/quickstart/) → [Orders & OMS](/docs/trading/orders/) | | A broker or venue operator | Your own branded trading front end | [Launch a DEX](/docs/launch/dex/) | | A prop-firm operator | Challenges, deposits, payouts | [Launch a prop firm](/docs/launch/prop-firm/) | | An agent builder | Tools instead of REST calls | [MCP server](/docs/sdks/mcp/) | ## The shape of the API Everything public lives under two prefixes: - `/api/v1/*` — the versioned provider API. Bearer JWT (or a browser cookie session). This is the surface documented here and the one the SDK targets. - `/ws/*` — three WebSocket channels: `/ws/market`, `/ws/risk`, `/ws/quotes`. Requests resolve to a **tenant** — the branded deployment you belong to — and every read and write is scoped to it. See [Tenancy](/docs/auth/tenancy/). ## What the platform does not hold The trust boundary is the API, not your wallet. The platform never takes custody of a partner's funds: a prop firm's `PropInstance` contract is owned by the multisig address you supply, deposits go straight to it, and payouts are authorised by you. See [Trust & custody model](/docs/auth/trust-model/) for the full statement of what is and isn't held. --- # Quickstart URL: https://docs.troncharts.xyz/docs/start/quickstart/ Three calls, start to finish. You need a credential — `{ apiKey, apiSecret }` — from [self-serve signup or your admin console](/docs/auth/credentials/), and your tenant slug. ## 1. Mint a bearer token ```bash curl -s https://api.troncharts.xyz/api/auth/api-token \ -H 'content-type: application/json' \ -d '{"apiKey":"'"$TC_API_KEY"'","apiSecret":"'"$TC_API_SECRET"'"}' # → { "token": "eyJ…", "tier": "fullTrading", "expiresAt": , … } ``` The token is a 24-hour JWT. Send it as `Authorization: Bearer ` on every call below. Revoke early with `POST /api/auth/api-token/revoke` (`{ token }`). The mint endpoint is one of the few that resolves without a tenant. Every `/api/v1/*` call below must also say **which tenant** it is for: send `X-Tenant-Slug: `, or call from an origin registered on your tenant. Miss it and you get `404 {"error":"unknown_tenant_origin"}` — a 404 here means "no tenant", not "no such endpoint". See [Tenancy](/docs/auth/tenancy/). ## 2. Read account state ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/state \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, # perVenue: [ { venue, exchange, equityUsd, balanceUsd, marginUsedUsd, # marginAvailableUsd, unrealizedPnlUsd, … } ] } ``` There are no top-level balance or equity fields: every figure is **per venue**, USD-denominated, and returned as a decimal string. The account total is the sum across `perVenue`. Everything readable about an account — positions, orders, trades, fills, funding — hangs off the same `/api/v1/accounts/{accountId}/*` prefix. See [Accounts & positions](/docs/trading/accounts/). ## 3. Place an order intent Requires a `fullTrading` credential tier. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{ "venue": "hyperliquid", "symbol": "BTC.HL", "side": "buy", "type": "limit", "qty": "0.01", "price": "60000" }' # → { "intentId": "…", … } ``` One endpoint covers `market` / `limit` / `stop` / `stop_limit` / `take_profit` across every venue. An order is an **intent** the platform composes, signs, and dispatches; the venue-side order id and the fills arrive over [`/ws/risk`](/docs/realtime/risk/) rather than by polling. `accountId` is optional — omit it and the credential's active account is used. Always send an `idempotency-key` on OMS writes — `/api/v1/oms/*` is the surface that honours it; see [Conventions](/docs/start/conventions/). :::caution[No inline brackets] There is no `bracket` field on compose. Place the entry first, then attach OCO legs with `POST /api/v1/oms/brackets/attach` (`{ parentIntentId, tpPrice, slPrice }`). ::: ## The same three steps in TypeScript The client is constructed **with** a token — there is no anonymous client, so mint the token first with a plain `fetch`. Pass `tenantSlug` and the SDK sends `X-Tenant-Slug` on every request for you. ```ts import { TronCharts } from '@tronchartsxyz/api-client' const res = await fetch('https://api.troncharts.xyz/api/auth/api-token', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ apiKey, apiSecret }), }) const { token } = (await res.json()) as { token: string } const sdk = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token, tenantSlug: 'your-slug', }) const state = await sdk.accounts.state(accountId) const { intentId } = await sdk.oms.composeIntent({ venue: 'hyperliquid', symbol: 'BTC.HL', side: 'buy', type: 'limit', qty: '0.01', price: '60000', }) ``` ## Next - [Base URLs & discovery](/docs/start/base-urls/) — never hardcode a venue list. - [Scopes & tiers](/docs/auth/scopes/) — what your credential is allowed to do. - [Connecting](/docs/realtime/connecting/) — stream state instead of polling. --- # Base URLs & discovery URL: https://docs.troncharts.xyz/docs/start/base-urls/ ## Environments | Environment | Base URL | | --- | --- | | Production | `https://api.troncharts.xyz` | There is no public staging host today. To rehearse against something other than production, use a paper account on the production API — that is what the [Sandbox](/docs/sandbox/overview/) section is for. White-label deployments answer on their own domain. If you are integrating against a branded venue, use that venue's host — the API surface is identical and the tenant is resolved from the host. See [Tenancy](/docs/auth/tenancy/). ## The two public prefixes - `/api/v1/*` — the versioned REST surface. Everything documented here. - `/ws/*` — three WebSocket channels: `/ws/market`, `/ws/risk`, `/ws/quotes`. A handful of unversioned reads (`/api/branding`, `/api/theme`, `/api/features`) exist for front ends that render a tenant's chrome before a session exists. They need no credential, but they are still **tenant-scoped**: a request that resolves to no tenant gets `404 {"error":"unknown_tenant_origin"}`, so call them from an origin registered on your tenant or send `X-Tenant-Slug`. ## Don't hardcode — discover Which venues are wired, which order types they accept, and which symbols trade differ per deployment and change without an API version bump. Three endpoints tell you at runtime: | Endpoint | Answers | | --- | --- | | `GET /api/v1/discovery` | A curated catalog of the most-used endpoints: bearer-mint URLs, the WS streams, and each listed endpoint's required credential tier. No auth, no tenant. | | `GET /api/v1/venues` | Which venue adapters are enabled here, with their asset classes and supported order types. | | `GET /api/v1/symbols/{venue}` | The tradeable symbol list for one venue. | Discovery is hand-maintained and deliberately partial. It does not list the scope-gated prop, firm, and tenant-config surfaces, it does not list every route documented here (`/api/v1/venues` among them), and it reports only the `tier` a route needs — never the `firm:operate` / `prop:manage` / `tenant:config` scopes. For the complete surface read the [OpenAPI spec](/docs/reference/specs/). ```bash curl -s https://api.troncharts.xyz/api/v1/discovery | jq '.endpoints | length' curl -s https://api.troncharts.xyz/api/v1/venues \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" | jq '.venues[].slug' ``` Building a venue picker off `/api/v1/venues` instead of a constant means a newly enabled venue appears in your UI without a release. ## Symbol format Symbols carry a venue suffix — `BTC.HL`, `ETH.HL`, `WINFUT.B3`. Always take them from `GET /api/v1/symbols/{venue}` rather than constructing them; the suffix is part of how the platform resolves pricing. --- # Conventions URL: https://docs.troncharts.xyz/docs/start/conventions/ The rules below describe the `/api/v1/*` surface. Where a rule is narrower than that — idempotency and the page envelope both are — it says so. ## Idempotency `Idempotency-Key` is honoured on **`/api/v1/oms/*`** — the whole OMS router, which is every write that can move a position. Elsewhere on `/api/v1/*` the header is accepted and ignored, so treat a retry on those routes as a second write, not a replay. Where it applies, the key is 8–200 characters: - Replaying the **same key with an identical payload** returns the cached response, with `Idempotency-Replay: true`. The window is 24 hours. - The **same key with a different payload** returns `409` (`idempotency_key_conflict`). - A key outside 8–200 characters returns `400` (`invalid_idempotency_key`). ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "idempotency-key: $(uuidgen)" \ -H 'content-type: application/json' \ -d '{ … }' ``` Send one on every order write. A network timeout on placement is otherwise indistinguishable from a rejection, and retrying without a key can double a position. ## Pagination Most list endpoints are cursor-paginated. - `?limit=` — default 100, and the cap is per endpoint: 500 on most, including `/api/v1/accounts/{id}/events`; 1000 on the `/api/v1/accounts/{id}` history routes `/orders`, `/trades`, `/fills`, `/order-history` and `/funding`; 5000 (default 1000) on `/api/v1/accounts/{id}/state/history`. - `?cursor=` — take it from the previous response. Every *cursor-paginated* list response carries `nextCursor`, and a `null` `nextCursor` means you have reached the end. Treat the cursor as opaque; it is not an offset and its encoding is not part of the contract. Not every list route is cursor-paginated. `/api/v1/accounts/{id}/state/history` is window-based: it returns a boolean `truncated` and no cursor at all, so widen `from`/`to` or raise `limit` rather than paging it. Small collections such as `/api/v1/accounts` and `/api/v1/accounts/{id}/positions` are returned whole. The array key is **not** uniform. The account history routes name the array after the resource and return no `hasMore`: | Endpoint | Array key | | --- | --- | | `/api/v1/accounts/{id}/orders` | `orders` | | `/api/v1/accounts/{id}/trades` | `trades` | | `/api/v1/accounts/{id}/fills` | `fills` | | `/api/v1/accounts/{id}/funding` | `events` | Other list endpoints do carry `hasMore`, but the array key still varies: `/api/v1/sor/decisions` uses `data`, while `/api/v1/reports/*` also names the array after the resource (`orders`, `fills`, `positionsClosed`, `funding`). Take the shape from the endpoint's own reference page rather than assuming one envelope, and on a cursor-paginated route page until `nextCursor` is `null`: ```ts let cursor: string | undefined do { const url = new URL(`https://api.troncharts.xyz/api/v1/accounts/${accountId}/trades`) url.searchParams.set('limit', '200') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url, { headers: { authorization: `Bearer ${token}`, 'x-tenant-slug': tenantSlug }, }) const page = (await res.json()) as { trades: unknown[]; nextCursor: string | null } handle(page.trades) cursor = page.nextCursor ?? undefined } while (cursor) ``` ## Rate limits Limits are per credential and configured per deployment. When you exceed one you get `429` with a `Retry-After` header — honour it rather than backing off on a fixed timer. The bootstrap path (`POST /api/auth/bootstrap`) is hard-capped at 10 requests per minute per API key. ## Decimals Sizes and prices are accepted as **decimal strings** or numbers, and the server coerces. Prefer strings: JSON numbers are IEEE-754 doubles, and a size like `0.1` does not survive the round-trip exactly. The platform reads money as decimals end to end — match it. ## Errors Failures return a JSON body with a stable machine-readable `error` code, usually alongside a `detail`: ```json { "error": "venue_rejected", "detail": "insufficient margin" } ``` `detail` is best-effort, not guaranteed: some failures carry none at all, and on validation errors it is a structured array of field issues rather than a sentence. Never require it to be present or to be a string. | Status | Means | | --- | --- | | `400` | Malformed request — a field is missing or the wrong shape. | | `401` | No credential, or an expired / revoked token. | | `403` | Authenticated but not allowed — wrong scope, wrong tier, or another tenant's resource. | | `404` | The request resolved to no tenant (`unknown_tenant_origin`). Send `X-Tenant-Slug` or call from a registered origin — see [Tenancy](/docs/auth/tenancy/). | | `409` | Conflict — idempotency-key reuse with a different payload, or a state transition that isn't legal. | | `410` | The tenant is suspended (`tenant_suspended`). | | `422` | The venue rejected the order. `detail` carries the venue's reason. | | `429` | Rate-limited. Read `Retry-After`. | A `404` is the one to read carefully: it far more often means "you did not identify a tenant" than "that endpoint does not exist". Check the `error` code before concluding a route was removed. Branch on `error`, never on `detail` — the code is contract, the prose is not. --- # Credentials & tokens URL: https://docs.troncharts.xyz/docs/auth/credentials/ Every integration call is made with a **credential** — `{ apiKey, apiSecret }` — that you exchange for a short-lived bearer token. The credential also carries the [scopes and tier](/docs/auth/scopes/) that decide what you can do. :::note[First decide which of the two you need] A credential is minted as either a **trader** (acts on its own account, can place orders) or an **operator** (administers your tenant, never places orders). It is one or the other, permanently, and each reaches a different half of the API. See [Trader vs operator](/docs/auth/audiences/) before minting — an actor that both administers and trades needs one of each. ::: ## 1. Get a credential Two ways: - **Self-serve signup** — `POST /api/public/tenants/signup` with your email and a slug, then `POST /api/public/tenants/verify` with the emailed token. Verify provisions your tenant, an admin invite, and (where self-serve issuance is enabled) an operator credential carrying `firm:operate` + `prop:manage`. - **Admin console** — an existing tenant issues a credential from the API Clients screen and picks its scopes. The secret is shown **once**. Store it like a password. ## 2. Exchange it for a bearer token ```bash curl -s https://api.troncharts.xyz/api/auth/api-token \ -H 'content-type: application/json' \ -d '{"apiKey":"…","apiSecret":"…"}' # → { "token": "eyJ…", "tier": "…", "expiresAt": , … } # 24h JWT ``` Send `Authorization: Bearer ` on every `/api/v1/*` call, along with `X-Tenant-Slug` (or call from an origin registered on your tenant) — without a tenant the request 404s before your token is read. See [Tenancy](/docs/auth/tenancy/). The mint call above is one of the few paths exempt from that gate. Revoke a single token before it expires with `POST /api/auth/api-token/revoke`, passing `{ token }`. The SDK client is constructed with a token, so mint it first and then build the client: ```ts const sdk = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token, tenantSlug: 'your-slug', }) ``` ## Cookie sessions vs bearer sessions Two authentication paths reach the same API: | Path | Who uses it | How | | --- | --- | --- | | **Cookie session** | Browser front ends | SIWE or social sign-in yields a `tron_session` cookie; same-origin requests authenticate automatically. | | **Bearer JWT** | Integrators, algos, servers | Minted from a credential as above. | Use bearer for anything server-side. A cookie session is scoped to a browser and a single origin; it is not a machine credential. ## WebSocket handshake tokens The WebSocket channels do **not** accept the bearer token in the `Authenticate` frame. Call `POST /api/auth/bootstrap` — authenticating either with `{ apiKey, apiSecret }` in the body or with a bearer token and no body — and it returns a short-lived, single-use handshake token per channel. That handshake token is what the socket wants. See [Connecting](/docs/realtime/connecting/). Unlike the bearer mint, `/api/auth/bootstrap` is tenant-gated, so send `X-Tenant-Slug` on it too. ## Rotation Rotate by issuing a second credential, deploying it, then revoking the first. Revoking a credential takes effect immediately for minting new tokens, for order placement, modification and cancellation by tokens already outstanding, and for the `firm:operate` / `prop:manage` / `tenant:config` scope gates — though those three are cached briefly, so allow a few minutes. :::caution[Revoking a credential is not a kill switch for a leaked token] It does not stop **reads**. An access token already minted from that credential keeps working on the read surface — accounts, positions, orders, market data — until its own 24-hour expiry, because the read path checks the token's signature, expiry, and per-token revocation list, not the credential row behind it. So if a *token* leaked, revoke the token: `POST /api/auth/api-token/revoke` with `{ token }`, once per outstanding token. Revoking the credential alone leaves the leaked token reading for up to a day. ::: --- # Trader vs operator credentials URL: https://docs.troncharts.xyz/docs/auth/audiences/ Two very different integrations use this API, and they need different credentials: - a **trader** — a person or bot acting on **their own account**: placing orders, reading their positions, streaming their fills; - an **operator** (partner / tenant) — a business acting on **its tenant**: creating traders, prop firms, challenge templates, risk profiles and trading groups, approving payouts. A credential is minted for exactly one of these. The audience is fixed at mint and never changes, and one credential can never be both. ## What each can do | | Trader credential | Operator credential | | --- | --- | --- | | Acts on | Its own account | Your tenant | | Minted from | The account, in the browser — Settings → API Clients | The admin console — API Clients | | Carries | A `tier` (`readonly` / `fullTrading`), a risk envelope, venue + symbol allowlists, and an **agent wallet** | Scopes: `prop:manage`, `firm:operate`, `tenant:config`, `accounts:intervene` | | Can place orders | **Yes** | **No — never** | | Can administer a tenant | No — scopes are rejected at mint | Yes, and only its own tenant | ### Why an operator can't trade Not a missing feature, and not something to work around. Authority over an account comes from **that account**, never from a credential: a trader credential can place orders because the account's own wallet signed an agent authorization for it, in the browser, where the wallet is. An operator has no access to that wallet and cannot forge that signature — the constraint is cryptographic. So an operator credential holds no agent wallet by design. Presenting one on the trading surface returns `403 wrong_credential_audience`. :::note[An actor that both administers and trades needs two credentials] That is intended, not a workaround: different authority, different blast radius, different revocation path. Hold both and use the matching client for each — do not re-mint one as the other, which silently drops everything the first one carried. ::: ## In the SDK Use the client that matches your credential. Calling the other surface then fails at compile time instead of at runtime. ```ts import { TronChartsTrader, TronChartsOperator } from '@tronchartsxyz/api-client' // Acting on your own account const trader = new TronChartsTrader({ baseUrl, token: traderToken, tenantSlug }) await trader.oms.place({ /* … */ }) await trader.accounts.state(accountId) // Administering your tenant const ops = new TronChartsOperator({ baseUrl, token: operatorToken, tenantSlug }) const { user } = await ops.users.create({ externalId: 'crm-4471' }) await ops.propTemplates.create({ /* … */ }) ``` | Client | Resources | | --- | --- | | `TronChartsTrader` | `oms` · `accounts` · `propAccounts` · `market` · `reports` · `sor` · `venues` · `kyc` · `referrals` · `analytics` · `indicators` · `backtests` · `copyTrading` | | `TronChartsOperator` | `users` · `firms` · `propTemplates` · `riskProfiles` · `tradingGroups` · `tenant` · `sandbox` · `propAccounts` (read-only oversight) · `reports` · `analytics` | `TronCharts` still exposes both surfaces on one object. It works, and it tells you nothing about which half your token can reach — prefer the scoped clients. ## When you get it wrong ```json { "error": "wrong_credential_audience", "detail": "this endpoint is for operator credentials, but this is a trader credential. …", "expected": "operator", "actual": "trader" } ``` HTTP `403`. The fix is never to change the credential you have — audiences are immutable — but to mint the other one and keep both. You may still meet the older, less direct errors underneath this gate: | Error | What it actually means | | --- | --- | | `wrong_credential_audience` | Wrong kind of credential. Mint the other one. | | `credential_missing_agent_wallet` | A **trader** credential minted without an agent wallet. Re-mint it from the account it should trade. | | `tier_insufficient` | A trader credential whose `tier` is too low — `readonly` cannot open positions. | | `forbidden` on an operator route | Right audience, missing that specific scope. An operator grants scopes per credential. | ## Browser sessions A cookie session (your own logged-in user in the FE) carries no credential and no audience, so this gate does not apply to it. Each route handles it on its own terms: the operator surface refuses it outright — tenant administration is an explicit, audited credential capability — while the trading surface accepts it as the trader it represents. **Next:** [Credentials & tokens](/docs/auth/credentials/) · [Scopes & tiers](/docs/auth/scopes/) · [How an account is configured](/docs/launch/account-configuration/) --- # Scopes & tiers URL: https://docs.troncharts.xyz/docs/auth/scopes/ Two orthogonal gates sit on every call. **Scopes** decide which surfaces you can reach. **Tiers** decide how far you can write on the trading surface. A credential carries both. ## Scopes | Scope | Unlocks | | --- | --- | | `firm:operate` | Operate the **firm**: create, configure, deploy, go live, pause, and approve payouts. `/api/v1/firms/*`. | | `prop:manage` | Manage **accounts inside** a firm: challenge templates, risk profiles, trading groups, account resets and promotions. `/api/v1/prop-templates/*`, `/api/v1/risk-profiles/*`, `/api/v1/trading-groups/*`, `/api/v1/prop-accounts/*`. | | `tenant:config` | Write your own tenant's branding, theme, and feature flags — for partners bringing their own front end. | The scopes are independent; a full prop-firm operator credential carries `firm:operate` and `prop:manage` together. ## Credential tiers Tiers gate the OMS write surface, enforced per endpoint. The rule is **`liquidation` may reduce risk, never add it**: | Tier | Reads | `/oms/cancel` · `/oms/cancel-all` · `/oms/flatten` | Every other `/oms/*` write | | --- | --- | --- | --- | | `readonly` | ✓ | ✗ | ✗ | | `liquidation` | ✓ | ✓ | ✗ | | `fullTrading` | ✓ | ✓ | ✓ | "Every other `/oms/*` write" means `/oms/intents`, `/oms/modify`, `/oms/reverse`, `/oms/chase`, and the three `/oms/brackets/*` routes. Placing, modifying, reversing a position and chasing a working order to a new price all establish or re-establish exposure, so each of them requires `fullTrading`. Cancelling and flattening only ever shrink the book, so `liquidation` reaches them. A rejected `/api/v1/oms/*` call returns `403` — `{"error":"tier_readonly"}` when the credential is `readonly`, `{"error":"tier_insufficient"}` otherwise. `detail` names the tier you hold and the tier the route wants. Other tier-gated routes may answer `tier_insufficient` for every rejected tier — `POST /api/v1/sandbox/paper-account` does — so branch on both codes. `liquidation` exists for risk systems that must be able to flatten a book they are not allowed to add to. Issue the narrowest tier that does the job — a read-only dashboard has no business holding `fullTrading`. ## Tenant binding On top of both gates, every credential is bound to **one tenant**. The tenant comes from the credential's own row, not from the request, so you can only ever read and write your own tenant's data. Note this is separate from the `X-Tenant-Slug` header your requests must carry to resolve at all — see [Tenancy](/docs/auth/tenancy/). ## Checking what you hold `GET /api/v1/discovery` lists the tier required by each endpoint it catalogs. Two limits to know before you lean on it: the catalog is partial, and it reports tiers only — it never reports the `firm:operate` / `prop:manage` / `tenant:config` scopes above, so it cannot verify the scope half of your credential. For the full surface read the [OpenAPI spec](/docs/reference/specs/); to confirm a scope, call one of the endpoints it gates and check for a `403`. --- # Tenancy URL: https://docs.troncharts.xyz/docs/auth/tenancy/ A **tenant** is one branded deployment: its own name, logo, theme, enabled venues, feature flags, fee schedule, and users. One platform serves many; the API surface is identical for all of them. ## How the tenant is resolved Resolution runs **before authentication**, in this order: 1. **`X-Tenant-Slug` header** — required for server-side integrations. It is trusted only when it names a real tenant; an unknown slug is silently ignored rather than rejected, and resolution falls through. A slug naming a **suspended** tenant still resolves — it is rejected afterwards with `410 {"error":"tenant_suspended"}`, not skipped. 2. **Origin / Host** — matched against the tenant's registered origins. This is how a browser front end on a branded domain resolves without a header. When no `Origin` is present the `Host` header is used instead. 3. **Platform default** — only when the request carries neither `Origin` nor `Host`, or when the origin is the platform tenant's own registered origin. Anything else is rejected with `404 {"error":"unknown_tenant_origin"}`. That last point is the wall every integrator hits first. `Host` is mandatory on every HTTP request, so a server-side call to a shared host like `api.troncharts.xyz` never falls through to rule 3 — it presents an origin that belongs to no tenant and 404s before your credential is even read. **Every server-side call must send `X-Tenant-Slug`, or come from an origin registered on your tenant.** There is no third option. ```bash curl -s https://api.troncharts.xyz/api/v1/venues \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` Because an unknown slug is ignored rather than rejected, a typo in the slug gives you the same `404 unknown_tenant_origin` as sending no header at all. A small set of paths skip the gate entirely, because the caller has no tenant to offer yet: the bearer mint and revoke (`/api/auth/api-token`, `/api/auth/api-token/revoke`), `/api/v1/discovery`, self-serve signup (`/api/public/tenants/*`), the public firm-transparency reads, and the docs themselves. Everything else — including `POST /api/auth/bootstrap` and all of `/api/v1/*` — is gated. ### What the header does and does not do `X-Tenant-Slug` gets you **past the origin gate**. It does not choose which tenant your credential acts as, and it cannot widen a credential. A bearer token acts under its credential's own tenant, read from the credential row on every request; the tenant the request resolved to is deliberately never consulted for scope. So a bearer that reaches a host belonging to another tenant still acts as its own tenant — the request is not rejected, the header is simply not load-bearing for scope. The one case that is rejected (`403`) is a token whose pinned claim disagrees with its credential row, which happens only if the credential was moved between tenants after the token was minted. ## What an operator controls per tenant | Area | Controls | | --- | --- | | Brand | Display name, logo, favicon, support email | | Theme | Colour tokens, typography, radius — light and dark | | Markets | Which venues and symbols are enabled | | Features | Per-tenant feature flags | | Fees | The tenant's fee schedule | Read the effective configuration back at any time — useful for mirroring it into your own front end: ```bash curl -s https://api.troncharts.xyz/api/v1/tenants/me \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { id, displayName, branding, theme, flags, venues, plan, … } ``` Sub-resources: `/api/v1/tenants/{id}/branding`, `/theme`, `/flags`, `/venues`, `/plan`. These are read-only unless your credential carries `tenant:config`. ## The isolation rule Tenant isolation is the load-bearing rule of the platform: **every read and every write is filtered by the caller's tenant, at the query, not in the application layer.** This holds for REST and for the WebSocket channels — a socket is scoped to its tenant at handshake and is torn down if that access is revoked mid-connection. The practical consequence for you: you never have to filter by tenant yourself, and you can treat any row you can see as yours. --- # Trust & custody model URL: https://docs.troncharts.xyz/docs/auth/trust-model/ This page states plainly what the platform holds and what it does not. If you are evaluating an integration, read it before the endpoints. ## Order signing vs custody The platform holds a **per-account, per-venue agent key** — AES-256-GCM encrypted at rest, decrypted in-process only at signing time. **The agent key can:** place, cancel, and modify orders on the venue, for the one account it belongs to. These are exactly the powers a venue agent wallet or API wallet is designed to delegate. So when you submit an intent, the order is signed and dispatched server-side — no wallet popup per trade. **The agent key cannot:** move funds off-venue. Venues release withdrawals only to the user's signed-in main wallet, and the withdrawal signature must come from that wallet, which the platform never holds. It also cannot register, rotate, or revoke the agent on the user's behalf. :::note[The custody boundary is the user's wallet] Deposits, venue withdrawals, and agent authorisation all require the end user's own wallet signature. The agent key shifts *order signing* off the wallet; it does not shift custody. A partner integration — or even a fully compromised backend — cannot perform a custody-moving action on a user's behalf. ::: That is the reason partners integrate at the API layer: order flow is API-driven and server-signed, while everything that moves money stays pinned to the end user's own key. ## Prop firms: custody stays at your multisig For an on-chain funded-trader program the same principle applies to the operator: - Your `PropInstance` contract is **owned by the multisig address you supply** at create time. - Traders' challenge deposits go **straight to your instance**. The platform never takes custody of them. - The deploy is broadcast with a gas-only platform deployer — it pays gas, it does not own the result. - Approving a payout is an **authorisation**, not an on-chain send. You are the authoriser; settlement is dispatched against your instance. - You never hold platform keys, and the platform never holds yours. ## Verify it rather than trust it Every live firm publishes an on-chain proof page — treasury, collateral, ledger totals, and a dollar-for-dollar reconciliation against the multisig, with no login required. See [How verification works](/docs/verify/how-it-works/) or go straight to [Verify a firm](/docs/verify/). ## Execution: routed vs internalised One more thing worth stating openly, because it changes how you read a fill. An order reaches the market one of two ways: - **Routed** — the order is signed and dispatched to the named exchange. `venue` is that exchange, and `venueOrderId` / `venueTradeId` are the exchange's own identifiers. - **Internalised** — the firm is the counterparty. The order is matched by the platform's own engine against a firm-canonical mark, and no order is placed at any exchange for it. Internalised rows carry `venue: "paper"`. The sentinel means *matched by our engine*, not *simulated money* — an internalised account settles real PnL. Which mode applies is a property of the **account**, never of the venue string, so discriminate on the account if you need to tell them apart. On an internalised row the ids are minted by the platform: stable and safe as dedup keys, but they resolve nowhere off-platform, so don't build exchange-side reconciliation on them. --- # Orders & OMS URL: https://docs.troncharts.xyz/docs/trading/orders/ Orders are **intents**. You submit what you want; the platform composes, signs, and dispatches it to the venue, then reports the outcome over [`/ws/risk`](/docs/realtime/risk/). One endpoint set covers every wired venue. Every write on this page needs a non-`readonly` credential. `cancel`, `cancel-all`, and `flatten` accept `liquidation`; everything else — place, modify, reverse, chase, and the bracket calls — needs `fullTrading`. The rule is that `liquidation` may reduce exposure, never add it. A `readonly` credential is refused with `403 tier_readonly`, a `liquidation` credential on a `fullTrading` route with `403 tier_insufficient`. See [Scopes & tiers](/docs/auth/scopes/). ## Place an intent `POST /api/v1/oms/intents` ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{ "venue": "hyperliquid", "symbol": "BTC.HL", "side": "buy", "type": "limit", "qty": "0.01", "price": "60000", "timeInForce": "Gtc" }' ``` | Field | Notes | | --- | --- | | `venue` | Required. One of `hyperliquid`, `aster`, `polymarket`, `kalshi`, `paper`. | | `symbol` | Required, venue-suffixed (`BTC.HL`). From `GET /api/v1/symbols/{venue}`. | | `side` | `buy` / `sell`. | | `type` | `market`, `limit`, `stop`, `stop_limit`, `take_profit`. | | `qty` | Required. Decimal string preferred. | | `price` | Required for `limit`. | | `triggerPrice` | Required for `stop`, `stop_limit`, `take_profit`. | | `stopLimitPrice` | Required for `stop_limit` — the resting limit price, alongside `triggerPrice`. Omitting it is a `400 stopLimitPrice_required`. | | `reduceOnly` | Never increases exposure. | | `timeInForce` | `Gtc` / `Ioc` / `Alo`. | | `leverage` | Checked at compose time against the effective per-venue cap. | | `accountId` | Optional — defaults to the credential's active account. Set it to place on a specific owned account. | | `clientOrderId` | Your own correlation id, echoed back on the response and on the WS frames. | Sandbox orders go in as `venue: "paper"` against a paper account. `GET /api/v1/venues` is capability discovery — the adapters wired in this deploy and what each one supports. It is not this enum: `b3` is listed there but compose rejects it (paper B3 orders go in as `paper`), and `paper` is never listed there. The response carries the **intent id**. Subscribe to the `Order-Intent-Changed` topic on `/ws/risk` and match `Order-Intent-Update` frames by their `intentId` — they carry the venue order id, the last event, and the terminal state. :::caution[Trailing stops are not accepted] `trailing_stop` is a deliberate, permanent defer. Build it client-side as cancel-and-replace if you need it. ::: ## Cancel, modify, and the bulk operations | Endpoint | Does | | --- | --- | | `POST /api/v1/oms/cancel` | Cancel one working order by `venueOrderId` — the venue's id, not the intent id. Read it from `GET /api/v1/accounts/{id}/orders` or the `Open-Orders-Update` frames. | | `POST /api/v1/oms/modify` | Replace a working order's price, qty, or trigger by `intentId`. Resolves a new intent id. | | `POST /api/v1/oms/cancel-all` | Cancel every working order on the account. | | `POST /api/v1/oms/flatten` | Close every open position on the account. | | `POST /api/v1/oms/reverse` | Close a position and open the opposite side. | | `POST /api/v1/oms/chase` | Cancel-and-replace toward the touch. | ```bash curl -s https://api.troncharts.xyz/api/v1/oms/cancel \ -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "idempotency-key: $(uuidgen)" \ -d '{ "venueOrderId": "…" }' ``` ```ts await sdk.oms.modify({ intentId, newPrice: 59500 }, idempotencyKey) ``` Modify takes `newPrice`, `newQty`, `newTriggerPrice`, and `newStopLimitPrice`. Unlike compose, these are numbers only — a decimal string is rejected. ## Brackets There is **no inline bracket field on compose**. Place the entry, then attach the OCO legs: | Endpoint | Does | | --- | --- | | `POST /api/v1/oms/brackets/attach` | Attach TP and/or SL to an entry by `parentIntentId`. | | `POST /api/v1/oms/brackets/attach-to-position` | Attach to an already-open position. | | `POST /api/v1/oms/brackets/modify` | Move an existing leg. | ```bash curl -s https://api.troncharts.xyz/api/v1/oms/brackets/attach \ -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -d '{ "parentIntentId": "…", "tpPrice": 64000, "slPrice": 58000 }' ``` At least one of `tpPrice` / `slPrice` must be set. When the parent is still unfilled the response carries `staged: true` — the legs arm on fill. ## Venue-specific fields Some venues accept extras: `asterTimeInForce`, `asterKind`, `clientId`. They are ignored by venues that don't use them, but read `GET /api/v1/venues` for what each adapter actually supports rather than sending them blind. ## Leverage Pass `leverage` on the intent itself. It is validated at compose time against the effective per-venue cap, which comes from the account's **trading group** — groups scope fee schedules, leverage caps, and venue/symbol allowlists. An intent above the cap is rejected at compose, before anything reaches the venue. --- # Order lifecycle & signing URL: https://docs.troncharts.xyz/docs/trading/order-lifecycle/ [Placing an intent](/docs/trading/orders/) returns an `intentId`. What happens next depends on **who holds the signing key**, and that determines whether your integration is done or has one more step. ## Two dispatch paths | Your credential | What happens after compose | | --- | --- | | Has a platform-held venue agent | The platform signs and dispatches. The intent moves on by itself; you watch [`/ws/risk`](/docs/realtime/risk/) or poll. | | Holds its own signing key (SDK / MCP server) | The intent stops at `intent_pending` and waits for **you** to sign it and return the signature. | The second case is the one that strands integrations: you get an `intentId` back, nothing else happens, and there is no error to react to. The intent is waiting. ## The states ``` intent_pending → signed_pending_dispatch → dispatched → filled ``` `intent_pending` means *composed, risk-checked, not yet signed*. Everything downstream is the platform's job. ## Poll one intent ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents/$INTENT_ID \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` Scoped to `(accountId, apiClientId)` — you see the intents **your credential** composed, not every intent on the account. An intent belonging to another credential answers `404`, not `403`, so this can't be used to probe. ## List your recent intents ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` Most recent first, capped at 50. Same credential scoping. ## Finish a pending intent When you hold the key, sign the payload and return the signature: ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents/$INTENT_ID/sign-result \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "signedPayload": { … } }' # → { "ok": true, "intentId": "…", "venueOrderId": "…", "resting": true } ``` A success may also carry `inlineFill` when the venue filled it immediately. :::note[Bearer only] This endpoint answers `401 bearer_required` to a cookie session. Browser front ends return their signature over the WebSocket instead — this REST shape exists for SDK and MCP clients. ::: ### The failures worth handling | Error | Status | Means | | --- | --- | --- | | `bearer_required` | 401 | Called with a cookie session. | | `intent_not_found` | 404 | No such intent. | | `forbidden` | 403 | The intent belongs to another account, or was composed under a different credential. | | `invalid_state` | 409 | Already signed, dispatched or dead. Only `intent_pending` can be sign-result'd — treat as *someone else finished it*, not as a retry. | | `batch_predecessor_pending` | 409 | An earlier leg of a flatten or reverse batch hasn't dispatched. The intent stays pending — **retry after the earlier leg lands**. | That last one is the one to get right. A flatten or reverse composes several intents in order (cancels → close → open), and signing a later leg before an earlier one dispatches would put the position through an intermediate state nobody asked for. The 409 is the platform refusing to let that happen; back off and retry rather than treating it as failure. If dispatch itself fails, you get `400` with `ok: false` and an `error` / `detail` from the venue — the signature was accepted, the venue rejected the order. ## When the credential can't sign at all A credential whose agent link is missing or dead is rejected at **compose** with `403`. No intent is created, so you never reach the sign step above: - `credential_missing_agent_wallet` — the credential has no agent linked. - `agent_authorization_revoked` / `agent_authorization_expired` — it had one and it is no longer valid. `agent_not_dispatchable` is the other shape, and it comes back from `cancel` and `modify` as a `409`: the intent composed, but nothing could sign it, so it was never dispatched. The agent needs re-approving. These are provisioning problems, not order problems. Fix the credential; the order path is fine. ## Which pattern should you build? If your credential is platform-signed, ignore this page: place the intent and take the outcome from [the risk channel](/docs/realtime/risk/). If you hold your own key, the loop is: compose → read `intentId` → sign → `sign-result` → confirm from the response or the socket. Poll `GET /intents/{id}` only as a reconciliation backstop, not as your primary path — the socket is faster and cheaper. --- # Accounts & positions URL: https://docs.troncharts.xyz/docs/trading/accounts/ Everything readable about an account hangs off one prefix: `/api/v1/accounts/{accountId}/*`. All reads work on any credential tier. ## The reads | Endpoint | Returns | | --- | --- | | `GET /state` | Equity, balance, margin used and available, and unrealised PnL — one row per venue under `perVenue`, as USD-suffixed decimal strings. | | `GET /positions` | Open positions across every venue. | | `GET /orders` | Working and recent orders. Cursor-paginated. | | `GET /trades` | Closed round-trip trades. Cursor-paginated. | | `GET /fills` | Individual executions. Cursor-paginated. | | `GET /funding` | Deposit / withdraw / transfer ledger. Cursor-paginated. | ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/positions \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ```ts const state = await sdk.accounts.state(accountId) const { positions } = await sdk.accounts.positions(accountId) const page = await sdk.accounts.trades(accountId, { limit: 200 }) ``` ## Fills vs trades They answer different questions, and mixing them up is the most common reconciliation bug: - A **fill** is one execution. A single order can produce many. - A **trade** is a closed round trip — an entry matched with its exit, carrying realised PnL. Compute volume from fills. Compute PnL from trades. ## Listing and creating accounts | Endpoint | Does | | --- | --- | | `GET /api/v1/accounts` | Every account your credential owns. | | `POST /api/v1/accounts` | Create an account. | | `GET /api/v1/accounts/{id}` | One account's summary. | | `PATCH /api/v1/accounts/{id}` | Update name, settings. | | `PATCH /api/v1/accounts/{id}/suspension` | Suspend or restore. Needs the `prop:manage` scope — see [Blocking vs suspending](/docs/launch/prop-accounts/#blocking-vs-suspending). | | `POST /api/v1/sandbox/paper-account` | Provision a sandbox account to rehearse against. | Start every integration against a sandbox account. It exercises the identical order path with no real money. ## History & reconciliation The reads above are the live view. These four answer *what actually happened*, and the first one carries a fact that changes what your blotter can show. | Endpoint | Returns | | --- | --- | | `GET /api/v1/accounts/{id}/order-history` | Every order **the venue saw** — including orders placed directly in the venue's own interface, which never passed through this platform. Filter by `venue`, `symbol`, `status` (`open`, `filled`, `canceled`, `rejected`, `triggered`, `expired`); cursor-paginated. | | `GET /api/v1/accounts/{id}/state/history` | The equity and margin time series behind any charted account view. `venue`, `from`, `to`, `limit` (max 5000, default 1000). | | `GET /api/v1/accounts/{id}/events` | The account's own audit log. `kind`, `venue`, `from`, `to`; cursor-paginated, `limit` max 500. | | `POST /api/v1/accounts/{id}/manual-trades` | Import an off-platform round trip so it lands in the same PnL surface. | :::caution[`/orders` and `/order-history` are not the same list] `/orders` is the platform's order book — what went through the OMS. `/order-history` is the **venue's**. If a trader also places orders in the venue's own UI, those exist only in the second list. A blotter built on `/orders` alone will look correct and be incomplete, and the discrepancy surfaces as unexplained position drift. ::: Importing a manual trade takes the round trip, not two legs: ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/manual-trades \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "symbol": "BTC.HL", "side": "long", "qty": 0.5, "entryTime": "2026-07-01T12:00:00Z", "entryPrice": 61000, "exitTime": "2026-07-02T09:30:00Z", "exitPrice": 63250, "fees": 12.4 }' ``` `side` is `long` / `short` — the position's direction, not a buy/sell. Both timestamps are ISO 8601, and `fees` is optional. ## Reports The account reads above are the live view. For historical reconciliation use the reports surface, which is built for range queries and export: | Endpoint | Returns | | --- | --- | | `GET /api/v1/reports/orders` | Order history over a window. | | `GET /api/v1/reports/fills` | Fill history. | | `GET /api/v1/reports/positions/closed` | Closed positions. | | `GET /api/v1/reports/daily-pnl` | Daily realised PnL. | | `GET /api/v1/reports/funding` | Funding-payment history. | | `GET /api/v1/reports/statements` | Statement documents. | ## Analytics Derived performance reads, if you'd rather not compute them yourself: `GET /api/v1/analytics/performance`, `/per-symbol`, `/equity-curve`, and `/risk-metrics`. ## Streaming instead of polling Do not poll `/state` or `/positions` in a loop. Subscribe to [`/ws/risk`](/docs/realtime/risk/) — position, margin, balance, and order state are pushed as they change, and the snapshot handlers give you the initial state on connect. --- # Market data URL: https://docs.troncharts.xyz/docs/trading/market-data/ REST gives you the snapshot; [`/ws/market`](/docs/realtime/market/) gives you the stream. Use REST to seed a chart or a book, then keep it current over the socket rather than re-polling. ## Endpoints | Endpoint | Returns | | --- | --- | | `GET /api/v1/market/candles/{venue}/{symbol}` | OHLCV history. Served from cache, falling back to the venue's REST API. | | `GET /api/v1/market/depth/{venue}/{symbol}` | Order-book depth. | | `GET /api/v1/market/depth/{venue}/{symbol}/grouped` | Depth aggregated into price buckets. | | `GET /api/v1/market/tape/{venue}/{symbol}` | Recent trade prints. `?limit=` 1–200, default 50. | | `GET /api/v1/market/stats/{ticker}` | Mark price and 24h statistics. Takes the venue-suffixed ticker (`BTC.HL`) as the whole path segment. | | `GET /api/v1/market/quotes/{venue}/{symbol}` | The current quote for one symbol. | | `GET /api/v1/market/fx` | FX rates, for accounts denominated in a non-USD currency. | The `{venue}/{symbol}` suffix is mandatory on `candles`, `depth`, and `tape`, as is the `{ticker}` segment on `stats` — the bare prefix 404s. `candles` also requires `?interval=`. First call on a pair nobody is subscribed to opens the subscription and answers `503 cold_cache` on `/depth` and `/tape` rather than blocking. Retry after ~500ms. ```bash curl -s "https://api.troncharts.xyz/api/v1/market/candles/hyperliquid/BTC.HL?interval=1h&limit=500" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` Candles are REST-only — `sdk.market` covers quotes, depth, and FX, so reach candles through the raw request escape hatch: ```ts type Bar = { openTime: string; closeTime: string open: string; high: string; low: string; close: string; volume: string closed: boolean } const { bars } = await sdk.client.request<{ bars: Bar[] }>( '/api/v1/market/candles/hyperliquid/BTC.HL', { query: { interval: '1h', limit: 500 } }, ) ``` The response envelope is `{ venue, symbol, interval, bars, source, window }`. An empty `bars` means the window holds no prints — unless `truncated: true` is also present, which means the history lookup gave up mid-flight and you should retry rather than treat the series as exhausted. ## Symbols carry their venue `BTC.HL`, `WINFUT.B3` — the suffix is how the platform resolves pricing, and it is part of the symbol, not decoration. Always source symbols from `GET /api/v1/symbols/{venue}`. ## Depth granularity Deep books are expensive to ship in full. Where a venue supports it, request grouped depth and let the server aggregate rather than pulling every level and bucketing client-side. ## Shared subscribers Market-data subscriptions are shared across all consumers of a deployment: the platform holds one upstream connection per venue and fans out. You are not charged a venue connection per client, and you cannot exhaust a venue's connection budget by scaling your own fleet. ## Custom indicators and backtests If you compute studies server-side, `GET /api/v1/indicators` and `POST /api/v1/backtests/run` evaluate declarative indicator specs against the same candle data, so a study renders identically in your UI and in a backtest. --- # Smart order routing URL: https://docs.troncharts.xyz/docs/trading/routing/ When smart order routing is enabled on an account, the platform picks the venue for an order and records **why**. Both the decision history and its rollup are readable — the audit trail is the product, not a log. Routing only happens on the routed-order endpoint below. A normal [`POST /api/v1/oms/intents`](/docs/trading/orders/) carries an explicit `venue` and is dispatched there — it is never re-routed, whatever the account flag says. | Endpoint | Returns | | --- | --- | | `GET /api/v1/sor/decisions` | The decision history: what was considered, what was chosen, and the cost estimate behind it. Cursor-paginated. | | `GET /api/v1/sor/decisions/summary` | The rollup — realised savings over a window. | ```bash curl -s "https://api.troncharts.xyz/api/v1/sor/decisions?limit=50" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ```ts const decisions = await sdk.sor.decisions({ limit: 50 }) const summary = await sdk.sor.summary() ``` ## Placing a routed order `POST /api/v1/oms/route-intents` composes one intent against the venue the engine picks. Its body is not the compose body: ```json { "asset": "BTC", "side": "buy", "qty": 0.01, "type": "market" } ``` | Field | Notes | | --- | --- | | `asset` | Required. The bare asset (`BTC`), not a venue-suffixed symbol — the engine resolves the symbol per venue. | | `side` | `buy` / `sell`. | | `qty` | Required, number. | | `type` | Must be `market`. Routing limit and stop orders is not wired. | | `reduceOnly`, `leverage`, `clientId`, `validForMs` | Same meaning as on compose. | A success is `201 { intentId, decision }` — the decision is the same shape the audit endpoints return, so you can log the reasoning with the order. The account must have smart routing enabled; otherwise the call is `400 routing_disabled` and the response `detail` names the toggle. When the engine can't pick, you get `400` with an `error` of `asset_unknown`, `no_eligible_venue`, or `no_quotes`, plus the `candidates` it evaluated and why each was skipped. The candidate pool is `hyperliquid`, `aster`, and `polymarket`. Anything else you trade — including paper — has to be placed with an explicit venue. :::caution[Wallet-rooted only] This endpoint resolves the caller through a wallet-linked session. A Provider API bearer credential has no wallet on its session, so it cannot place routed orders today — use `/oms/intents` with an explicit venue. ::: ## Reading a decision Each row records the candidate venues, the estimated cost at decision time, and the selected venue. That makes two things possible that a plain fill record cannot support: - **Best-execution reporting** — show a client what the alternatives were. - **Post-trade review** — compare the estimate against the realised fill and find where the model is drifting. ## Routing is per account Routing is a property of the account, and venue eligibility is further constrained by the account's trading group (its venue and symbol allowlists). An order for a venue outside the allowlist is rejected at compose time. --- # Connecting URL: https://docs.troncharts.xyz/docs/realtime/connecting/ Three channels share one connection model: | Channel | Carries | | --- | --- | | [`/ws/market`](/docs/realtime/market/) | Depth, order book, trade tape, volume at price, inside quote, candles. | | [`/ws/risk`](/docs/realtime/risk/) | Account state, positions, risk, order lifecycle, fills. | | [`/ws/quotes`](/docs/realtime/quotes/) | The canonical mark per venue and symbol. | Endpoint: `wss://api.troncharts.xyz`. ## Authenticate Only `/ws/risk` needs authentication. Market data is public, so `/ws/market` and `/ws/quotes` accept subscribe frames with no `Authenticate` at all — send one only if you want a scope attached to the connection. 1. Mint a handshake token — `POST /api/auth/bootstrap` returns `{ success, data }`, where `data` carries `riskEngineWss` and `quoteServiceWss`, each `{ endpoint, token, expiresAt }`. There is no separate `/ws/market` token, because that channel needs none. 2. Open the socket. 3. Send `Authenticate` as the **first** message, using `data.riskEngineWss.token`: ```json { "type": "Authenticate", "token": "" } ``` 4. You get back `Authenticated`, or `Result { ok: false }` — `token_required` when the frame carries no token, `token_invalid` when the token is unknown, already consumed, or expired. Each token is **single-use** and lives 60 seconds; the socket that consumes it burns it. Bootstrap again for every reconnect — the mint is rate-limited to 10 requests per minute per API key, which is ample for reconnect backoff but not for a hot loop. Browser sessions rooted in a cookie authenticate automatically; sending the frame anyway is harmless and keeps client code uniform. The sample below uses the TypeScript SDK, which is [not yet on the public npm registry](/docs/sdks/typescript/) — drive the socket with any WebSocket client if you are integrating from outside the monorepo. ```ts import { RiskEngineClient } from '@tronchartsxyz/api-client' const ws = new RiskEngineClient({ url: 'wss://api.troncharts.xyz/ws/risk', token: wsToken, onFrame: (frame) => apply(frame), }) await ws.connect() ws.send({ type: 'Subscribe', topics: ['Position-Changed', 'Order-Intent-Changed'] }) ``` ## Heartbeat Send `Ping` every 30 seconds; the server replies `Pong { serverTime }`. The socket's idle timeout closes it after 60 seconds of inbound silence, and the Ping resets it. (`Alive` resets it too, but acks with `Result { ok: true }` instead of a `Pong` — legacy; new code should use Ping/Pong.) Both are exempt from the per-frame rate limit, so a heartbeat is never dropped. ## Subscribe to topics `/ws/risk` pushes **nothing** until you subscribe. A fresh socket carries an empty topic set, and every push frame is gated on its topic — authenticate, subscribe to nothing, and the socket stays silent for its whole life. ```json { "type": "Subscribe", "topics": ["Position-Changed", "Order-Intent-Changed"] } ``` `topics` is required and must be non-empty; a `Subscribe` without it comes back as `Result { ok: false, error: "invalid_frame" }`. The explicit `Get-*` requests answer regardless of what you subscribed to — topics gate pushes, not replies. | Topic | Unlocks | | --- | --- | | `Account-State-Changed` | `Account-State-Update` — plus an immediate snapshot on subscribe. | | `Position-Changed` | `Position-Update`, `Position-Removed`. | | `Order-Intent-Changed` | `Order-Intent`, `Order-Intent-Update`. | | `Order-Changed` | `Order-Update`. | | `Open-Orders` | `Open-Orders-Update`. | | `Fill` | `Fill` — one frame per execution, never coalesced. | | `Trade-Changed` | `Trade-Update` (closed round trips). | | `Balance-Changed` | `Balance-Update`. | | `Margin-Changed` | `Margin-Update`. | | `Funding-Changed` | `Funding-Update`. | | `Risk-Changed` | `Risk-Update`. | | `Blocking-Changed` | `Blocking-Update`. | | `Account-Event-Changed` | `Account-Event`. | | `Prop-Account-Changed` | `Prop-Account-Update` and the `Prop-Account-*` transition frames. | | `Companion-Event-Changed` | `Companion-Event`. | | `IP-Changed` | `IP-Update`. | `Unsubscribe { topics }` removes them again. ## What a socket receives Beyond topics, a socket only receives frames for the accounts in its scope: | Credential | Scope | | --- | --- | | API credential (bootstrap token) | The one account it is bound to. `Subscribe { topics, accountId }` re-scopes to another account the credential **owns** — an unowned id is rejected `account_forbidden`; one active account at a time. | | Cookie session | Every account the signed-in user owns. | Scope is enforced at the socket, not filtered client-side. You cannot receive another tenant's frames, and if your access is revoked mid-connection the socket is torn down rather than left alive until the next reconnect. ## Tier enforcement, per frame The credential [tier](/docs/auth/scopes/) is checked on every inbound frame, but the socket draws a single line — read versus write: | Frame | Tiers allowed | | --- | --- | | `Authenticate`, `Ping`, `Alive`, `Resume`, `Subscribe`, `Unsubscribe`, `Get-*`, `Request-Trade-History` | `readonly`, `liquidation`, `fullTrading` | | `Order-Sign-Result`, `Cancel-Order-Intent`, `Replace-Order-Intent`, `Bracket-Insert`, `Bracket-Modify`, `Bracket-Cancel`, `Position-Close` | `liquidation`, `fullTrading` | A `readonly` credential is rejected with `Result { ok: false, error: "tier_readonly" }` — the socket stays open. Admin read-only impersonation sessions are barred from the write frames the same way. The socket gate is coarser than the REST one: `/api/v1/oms/*` splits `liquidation` (cancel, cancel-all, flatten) from `fullTrading` (everything that opens or re-prices exposure), while `/ws/risk` lets any non-`readonly` credential send every write frame. If you are issuing a `liquidation` credential to a third-party risk system, that difference is the thing to account for. Frames rejected for other reasons carry their own code — `invalid_json`, `invalid_frame`, `rate_limited`, `account_forbidden`, `intent_not_found`, `no_account`, `partial_close_unsupported`, and so on. Always branch on `error`, never on a message string. ## Binary encoding Append `?encoding=msgpack` to the upgrade URL to receive server→client frames as MessagePack instead of JSON. The decoded object is identical either way — msgpack is purely a smaller, faster transport. JSON is the default, so existing clients and the TypeScript SDK are unaffected. Client→server frames stay JSON. ## Reconnecting On a drop: mint a fresh bootstrap token, reconnect, re-authenticate, **re-`Subscribe`**, then re-request state with the `Get-*` frames rather than assuming your cached view survived. Topics live on the connection, so a new socket starts silent again. If the server decides your view is stale it sends `Resync-Required` — treat it as an instruction to drop local state and re-snapshot. Frames are coalesced server-side under load: you may receive one merged update instead of several. Always apply a frame as the current truth for its key, never as a delta on top of what you had. --- # Market channel URL: https://docs.troncharts.xyz/docs/realtime/market/ `/ws/market` streams the live book and tape. Use [REST market data](/docs/trading/market-data/) for the initial snapshot, then keep it current here. Market data is public: no `Authenticate` frame is required on this channel. ## What it carries Six independent streams, each with its own subscribe verb and its own set of venues. A stream only accepts venues it can actually serve — asking for one it cannot is rejected as `invalid_frame` rather than accepted and left silent. | Stream | Subscribe with | Pushes | Venues | | --- | --- | --- | --- | | Depth (aggregated levels) | `Subscribe-Depth` | `Depth-Update` | `hyperliquid`, `aster`, `lighter`, `polymarket`, `kalshi`, `massive`, `b3` | | Order book (per order, L3) | `Subscribe-Depth-L3` | `Order-Book-Update` | `lighter`, `b3` | | Tape (trade prints) | `Subscribe-Tape` | `Tape-Update` | `hyperliquid`, `aster`, `massive`, `b3` | | Volume at price | `Subscribe-VAP` | `VAP-Update` | `hyperliquid`, `aster`, `massive`, `b3` | | Inside quote | `Subscribe-BBO` | `BBO-Update` | `hyperliquid`, `aster`, `lighter`, `polymarket`, `massive`, `b3` | | Candles | `Subscribe-Candles` | `Candle-Update` | `hyperliquid`, `aster`, `lighter`, `polymarket`, `kalshi`, `massive`, `b3` | `massive` is the venue key for listed futures; the same key is what `GET /api/v1/symbols/{venue}` enumerates them under. Each has a matching `Unsubscribe-*`; `Unsubscribe-All` drops the lot. `Ping` answers `Pong`, and every subscribe frame answers `Result`. `venue` and `symbol` are **separate fields** — there is no `venue:symbol` string on the wire: ```json { "type": "Subscribe-Depth", "venue": "hyperliquid", "symbol": "BTC" } ``` `Subscribe-Candles` and `Unsubscribe-Candles` additionally require `interval`, written as a count plus a unit — `s`, `m`, `h`, `d`, `w`, or `M` (`1m`, `15m`, `4h`, `1d`, `1w`). Custom resolutions are accepted and folded server-side from a finer base interval where the venue has no native bar. Each interval is its own subscription: unsubscribing `5m` leaves `1h` running. Prices and sizes arrive as decimal strings, and every frame echoes `venue` and `symbol` so one socket can multiplex the whole set. ## Batching High-rate venues can print far faster than any UI can render. The tape is **batched server-side into one frame per flush window**: a single `Tape-Update` carries a `trades` array holding every print that landed in the window, in wire order. Iterate it; do not assume one frame equals one trade. This matters most on futures venues, where an active session can produce hundreds of prints per second on one symbol. ## Encoding Like the other channels, `?encoding=msgpack` on the upgrade URL switches server→client frames to MessagePack. On a busy depth subscription this is the single cheapest performance win available to a client. ## Seeding correctly 1. Subscribe first. 2. Then fetch the REST snapshot. 3. Apply buffered updates on top. Doing it the other way round leaves a hole between the snapshot and your first frame. --- # Risk channel URL: https://docs.troncharts.xyz/docs/realtime/risk/ `/ws/risk` is the primary programmatic surface. It is bidirectional: the server pushes state, and you can act on the same socket instead of falling back to REST. Nothing is pushed until you `Subscribe` to the [topics](/docs/realtime/connecting/#subscribe-to-topics) you want. ## Frames the server pushes | Frame | Carries | | --- | --- | | `Account-State-Update` | The account's aggregate state. | | `Balance-Update` / `Margin-Update` / `Funding-Update` / `Risk-Update` | Balance, margin, funding, and risk as they change. | | `Position-Update` / `Position-Removed` / `Position-Snapshot` | Live position state; the snapshot seeds your view. | | `Open-Orders-Update` | The working-order book for the account. | | `Order-Intent` / `Order-Intent-Update` | Order lifecycle, from composed to terminal. | | `Order-Update` / `Trade-Update` | Order state and closed round trips, in the external vocabulary. | | `Fill` | One frame per execution — never coalesced, so nothing is collapsed away. | | `Account-Event` | Lifecycle events on the account. | | `Prop-Account-Update` | Challenge progress and status on a prop account. | | `Blocking-Update`, `IP-Update`, `LoggedOff` | Session-level notices. | | `Resync-Required` | Drop local state and re-snapshot. | There is no server-pushed bracket frame. TP/SL leg state reaches you through `Open-Orders-Update` and `Order-Intent-Update` like any other working order. ## Frames you can send Read requests — `Get-Margin`, `Get-Balance`, `Get-Risk-Update`, `Get-Position-Update`, `Get-open-orders`, `Request-Trade-History` — plus `Subscribe` / `Unsubscribe` to change topics and scope, and `Resume` to replay after a gap. Write frames mirror the [REST OMS](/docs/trading/orders/): `Order-Sign-Result`, `Cancel-Order-Intent`, `Replace-Order-Intent`, `Bracket-Insert`, `Bracket-Modify`, `Bracket-Cancel`, `Position-Close`. Each is [tier-gated](/docs/realtime/connecting/#tier-enforcement-per-frame). There is no place-order frame. New orders start at `POST /api/v1/oms/intents`; the socket carries the signature back (`Order-Sign-Result`) and then everything that happens to the order afterwards. Acting on a live order over the socket saves a round trip on a hot path; REST is fine everywhere else and easier to retry safely. `Position-Close` closes the **whole** position — passing `qty` is rejected with `partial_close_unsupported` — and it accepts only `hyperliquid`, `aster` and `polymarket`. Flatten anything else through `POST /api/v1/oms/flatten`. ## Sequence numbers — detect a gap Every pushed frame that leaves the per-socket outbox carries a monotonic integer `seq`, starting at 1 for the life of the connection. ```ts if (frame.seq !== undefined) { if (lastSeq !== null && frame.seq !== lastSeq + 1) { await resnapshot() // a frame was dropped or coalesced away } lastSeq = frame.seq } ``` A gap means one or more frames were dropped or **coalesced** — the server merges rapid updates for the same key under load. Re-request the relevant `Get-*` snapshot rather than trying to reconstruct the missing step. Control frames sent outside the outbox — `Pong` and the `Authenticated` handshake — are deliberately unsequenced. Only assert contiguity over frames that carry `seq`. A new connection restarts the lane at 1. ## Tracking an order to its outcome 1. `Subscribe { topics: ["Order-Intent-Changed"] }` **before** you place, so no transition happens while you are unsubscribed. 2. `POST /api/v1/oms/intents` returns an intent id. 3. `Order-Intent-Update` carries the venue order id, then the fills, then the terminal state. The stream is account-scoped, not per-intent — there is no `intent.` topic — so match each frame on the intent id you were handed. This is the intended pattern — polling an order endpoint in a loop will always lag the socket and costs you rate limit. --- # Quotes channel URL: https://docs.troncharts.xyz/docs/realtime/quotes/ `/ws/quotes` is a subscribe-only stream of marks. It exists so a client that needs a current price for many symbols doesn't have to hold a full depth subscription for each one. ## What it carries One `Quote-Update` per change, per venue and symbol: ```json { "type": "Quote-Update", "venue": "hyperliquid", "symbol": "BTC", "mid": "104213.5", "bid": null, "ask": null, "last": null, "source": "be_canonical", "ts": "2026-05-24T18:30:00.000Z" } ``` The mark is in `mid`, as a decimal **string**. `bid`, `ask` and `last` are reserved and always `null` today — take the inside quote from `BBO-Update` on [`/ws/market`](/docs/realtime/market/) instead. `ts` is an ISO-8601 timestamp, not an epoch number. Futures and B3 symbols additionally carry `marketStatus: "open" | "closed"`; its absence means open. ## Frames you can send | Frame | Purpose | | --- | --- | | `Subscribe-Quote { venue, symbol }` | Start pushing one symbol. | | `Subscribe-Quotes-Bulk { items }` | Up to 200 `{ venue, symbol }` pairs in one frame. | | `Unsubscribe-Quote { venue, symbol }` | Stop pushing one symbol. | | `Unsubscribe-All` | Drop every subscription on the socket. | | `Get-Quote { venue, symbol }` | One-shot read, no subscription. | | `Authenticate`, `Ping` | Optional handshake; heartbeat. | `venue` and `symbol` are separate fields — there is no `venue:symbol` string on the wire. Marks are public data, so no `Authenticate` frame is required; send one only to attach a scope to the connection. ## When to use it instead of `/ws/market` | You need | Use | | --- | --- | | A price for a watchlist, a portfolio valuation, a PnL readout | `/ws/quotes` | | The book, the tape, or bar updates | [`/ws/market`](/docs/realtime/market/) | Valuing 200 positions over the quotes channel is one subscription set. Doing it over depth subscriptions is 200 books you don't read. ## Marks are the settlement reference The mark on this channel is the platform's canonical price for a symbol — the same reference used for margin and for settling an internalised fill. Do not smooth or interpolate it before using it as a price of record; smooth it in your chart layer only. ## Encoding `?encoding=msgpack` applies here too. It is worth taking on a large bulk subscription, where one frame per symbol adds up. --- # Webhooks URL: https://docs.troncharts.xyz/docs/realtime/webhooks/ Webhooks are the push option for systems that shouldn't hold a socket open. The platform POSTs JSON to a URL registered for your tenant. :::note[Your operator provisions the subscription] There is no public endpoint for creating a webhook subscription — registration, the secrets, and rotation all live on the operator console. Give your operator the destination URL and the categories you want; they hand back two values: the `x-api-key` your receiver compares, and the **signing secret** it verifies the HMAC with. Both rotate in place on the same subscription; a rotated signing secret is shown once and takes effect immediately, with no grace window, so verification fails until you store the new value. Everything below describes what then arrives. ::: ## The envelope ```json { "eventId": "uuid-v4", "category": "trade.closed", "tenantId": "acme", "ts": "2026-05-24T18:30:00Z", "payload": { } } ``` ## Headers | Header | Meaning | | --- | --- | | `x-webhook-signature` | `sha256=` — HMAC-SHA256 over the exact request body, keyed with your subscription's signing secret. This is the provenance proof. | | `x-api-key` | The shared secret for this subscription, set by your operator. | | `x-webhook-event-id` | Matches `eventId` in the body. | | `x-webhook-event-category` | Matches `category`. | | `x-webhook-attempt` | 1-based attempt counter. | | `x-webhook-request-id` | A fresh UUID per attempt. | | `content-type` | Always `application/json`. | | `authorization` | Only when your subscription is configured with one — the value your operator set, passed through verbatim. | ## Categories These six have live producers and are the only ones you can subscribe to: | Category | Fires when | | --- | --- | | `account.created` | A trading account is provisioned. | | `account.balance_changed` | An account's balance moves. | | `account.status_changed` | An account is enabled, disabled, or a prop account changes state. | | `trade.closed` | An order intent reaches a terminal state — `filled`, `cancelled`, `rejected`, or `expired`. Despite the name it is not fill-only and carries no PnL; filter on the payload's `toState`. | | `tenant.plan_changed` | Your billing plan changes. | | `tenant.kyb_changed` | Your KYB verification status changes. | The contract also declares `position.overnight`, `subscription.created` and `subscription.updated`. **Do not build handlers for these** — they have no producer and will never arrive. They exist for forward compatibility, and the subscription surface refuses to register them, so there is no way to subscribe by mistake. If one gains a producer it moves into the list above. ## Delivery and retries Return any **2xx** on success. Anything else — including a connection failure — is retried with exponential backoff: 1s, 4s, 30s, 5m, 30m, 2h, 6h, 24h. Once the attempt budget is exhausted (8 by default) the subscription **auto-disables** — so an endpoint that is down for a day needs re-enabling, not just fixing. ## Writing a receiver 1. **Verify `x-webhook-signature`** before parsing the body. Recompute HMAC-SHA256 over the **raw body bytes** with your signing secret, hex-encode it, prefix `sha256=`, and compare in constant time. This is what proves the request came from us — `x-api-key` is a static bearer that anyone who captures one delivery can replay, so check it too, but not instead. 2. **Deduplicate on `eventId`.** Retries are at-least-once; the same `eventId` can arrive more than once, and `x-webhook-attempt` tells you it is a repeat. 3. **Return 200 fast**, then work. A slow receiver looks like a failing one and earns a retry you didn't need. 4. **Don't infer ordering.** Events can arrive out of order after a retry. Reconcile against the account reads rather than replaying webhooks as a sequence. ```ts import { createHmac, timingSafeEqual } from 'node:crypto' // Mount with a raw-body parser: the HMAC is over the bytes as sent. app.post('/hooks/troncharts', express.raw({ type: 'application/json' }), async (req, res) => { const expected = 'sha256=' + createHmac('sha256', process.env.HOOK_SIGNING_SECRET).update(req.body).digest('hex') const got = req.header('x-webhook-signature') ?? '' if (got.length !== expected.length || !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) { return res.sendStatus(401) } if (req.header('x-api-key') !== process.env.HOOK_SECRET) return res.sendStatus(401) const { eventId, category, payload } = JSON.parse(req.body.toString()) if (await alreadyHandled(eventId)) return res.sendStatus(200) res.sendStatus(200) await handle(category, payload) }) ``` --- # Launch a DEX URL: https://docs.troncharts.xyz/docs/launch/dex/ Your name, logo, theme, and enabled markets — on the platform's execution, market-data, and risk engine. Traders get a full trading front end; you get the storefront. DEX launch is **signup-then-configure**: you provision your tenant over the API, then brand and configure the storefront from the admin console. ``` signup ──▶ verify ──▶ tenant provisioned ──▶ brand & configure ──▶ live (API) (API) + admin invite (admin console) ``` ## 1. Sign up `capabilityLayers` defaults to `['L1_DEX']`, so a plain signup already asks for a DEX. ```bash curl -s https://api.troncharts.xyz/api/public/tenants/signup \ -H 'content-type: application/json' \ -d '{ "email": "founder@acme.xyz", "slug": "acme", "displayName": "Acme Markets" }' # → { "ok": true, "signupId": "…", "slug": "acme", "emailed": true, … } ``` ## 2. Verify → provision Use the token from the email: ```bash curl -s https://api.troncharts.xyz/api/public/tenants/verify \ -H 'content-type: application/json' \ -d '{ "token": "…" }' # → { "tenant": { "id": "…", "capabilityLayers": ["L1_DEX"], … }, # "adminInvite": { "username": "…", "inviteUrl": "…" }, # "operatorCredential": { "apiKey": "…", "apiSecret": "…" } | null } ``` Accept the `adminInvite` to get into your admin console. If an `operatorCredential` came back, that is your API credential — the secret is shown once. ## 3. Brand and configure From the console: | Setting | Where | | --- | --- | | Brand name, logo, colours | Studio → Branding | | Theme preset | Studio → Theme | | Feature flags | Studio → Feature flags | | Enabled venues and markets | Risk → Markets → Venues | Read the effective config back over the API at any time — handy for mirroring it into your own front end. Mint `$TOKEN` from your credential at `POST /api/auth/api-token`, and send `X-Tenant-Slug` so the call resolves to your tenant — without it you get `404 {"error":"unknown_tenant_origin"}`. See [Tenancy](/docs/auth/tenancy/). ```bash curl -s https://api.troncharts.xyz/api/v1/tenants/me \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { id, displayName, vertical, capabilityLayers, capitalMode, # branding, theme, flags, venues, plan, … } ``` Sub-resources: `/api/v1/tenants/me/branding`, `/theme`, `/flags`, `/venues`, `/plan`. These are read-only unless your credential carries `tenant:config`, in which case branding, theme, and flags become writable over `PATCH` — the "bring your own front end" path. The `operatorCredential` from step 2 does not carry it: it is minted with `firm:operate` and `prop:manage` only, so ask for a `tenant:config` credential from the console if you want programmatic writes. ## 4. Go live Once branding and venues are set, your trading front end is live at your configured domain. Traders authenticate, fund, and trade against the venues you enabled. The tenant is resolved from your domain, so no header wiring is needed in the browser — see [Tenancy](/docs/auth/tenancy/). ## Want a funded-trader program on top? A DEX and a prop firm compose — run both from the same tenant. See [Launch a prop firm](/docs/launch/prop-firm/). --- # Launch a prop firm URL: https://docs.troncharts.xyz/docs/launch/prop-firm/ Publish challenge templates, deploy your `PropInstance` contract, take challenge deposits, and approve payouts. Everything here is self-serve over a `firm:operate` credential. **Time to live:** about ten minutes of API calls, once your multisig is ready. ## What you need first | Prerequisite | Why | | --- | --- | | A **`firm:operate` credential** | Every call here is scoped by it. See [Credentials & tokens](/docs/auth/credentials/). | | A **multisig address** (`0x…`, 40 hex) | Owns your deployed `PropInstance`. Custody of firm funds stays there — the platform never holds it. | | A **fee config** | `entryFeeBps` and `payoutFeeBps` are required; forfeiture BPS are optional. | :::note[Shared rails are pre-provisioned] The factory, the settlement observer, and its whitelist are platform rails. They appear as readiness checks in step 3 — you don't configure them, but you do wait for them to go green before you deploy. ::: ## The lifecycle ``` create ──▶ readiness ──▶ deploy ──▶ go-live ──▶ [traders trade] ──▶ approve payouts pending_deploy (dry-run) active ``` ## 1. Authenticate ```bash curl -s https://api.troncharts.xyz/api/auth/api-token \ -H 'content-type: application/json' \ -d '{"apiKey":"'"$TC_API_KEY"'","apiSecret":"'"$TC_API_SECRET"'"}' # → { "token": "eyJ…", "tier": "…", "expiresAt": , … } ``` The mint endpoint is one of the few that resolves without a tenant. Every `/api/v1/*` call below must also say **which tenant** it is for: send `X-Tenant-Slug: `, or call from an origin registered on your tenant. Miss it and you get `404 {"error":"unknown_tenant_origin"}`. See [Tenancy](/docs/auth/tenancy/). ## 2. Create the firm The firm starts in `pending_deploy`. Your tenant comes from the token — you cannot create a firm for anyone else. ```bash curl -s https://api.troncharts.xyz/api/v1/firms \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "name": "Acme Funded", "multisigAddress": "0xYourGnosisSafe000000000000000000000000", "feeConfig": { "entryFeeBps": 500, "payoutFeeBps": 1000 }, "chainId": 42161, "supportEmail": "support@acmefunded.com", "withPaperShadow": true }' # → { "firm": { "id": "firm_…", "status": "pending_deploy", … }, # "paperShadowId": "…", "seededTemplates": [ … ] } ``` `withPaperShadow: true` (the default) seeds a paper twin of the firm so you can rehearse the whole trader experience with no real money. **SDK** `sdk.firms.create({ name, multisigAddress, feeConfig, chainId })` · **MCP** `create_firm` ## 3. Wait for readiness Readiness is your go-live checklist. Poll until `ready: true`. ```bash curl -s "https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/readiness?target=mainnet" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { "ready": false, "checks": [ # { "key": "factory_configured", "ok": true, "required": true }, # { "key": "observer_configured", "ok": true, "required": true }, # { "key": "deployer_configured", "ok": true, "required": false }, # { "key": "observer_whitelisted", "ok": true, "required": true }, # { "key": "instance_deployed", "ok": false, "required": true }, # { "key": "watcher_subscribed", "ok": false, "required": true }, # { "key": "vault_funding_configured", "ok": false, "required": false } ] } ``` | Check | Cleared by | | --- | --- | | `factory_configured`, `observer_configured`, `observer_whitelisted` | The platform (shared rails) | | `instance_deployed`, `watcher_subscribed` | **You**, by deploying in step 4 | | `deployer_configured` | The platform. This is the gas-only deployer key the step-4 broadcast signs with. | | `vault_funding_configured` | The platform. Only firm-wallet (firm-custody) programs need it — it is unrelated to the deployer. | :::caution[`ready: true` does not mean the deploy will succeed] `ready` aggregates only the checks marked `required`, and both `deployer_configured` and `vault_funding_configured` are reported as not-required. But a broadcast (`dryRun: false`) with `deployer_configured` red returns `503 deployer_unconfigured`. Check that key yourself before step 4, and ask the platform if it is red. ::: **SDK** `sdk.firms.readiness(firmId, 'mainnet')` · **MCP** `check_firm_readiness` ## 4. Deploy your PropInstance Always dry-run first — it runs the full preflight without broadcasting. ```bash # Preflight only curl -s https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/deploy \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "target": "mainnet", "dryRun": true }' # Broadcast — gas-only platform deployer; the instance is owned by your multisig curl -s https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/deploy \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "target": "mainnet", "dryRun": false }' # → { "deployed": true, "instanceAddress": "0x…", "txHash": "0x…", # "chainId": 42161, "whitelisted": true } ``` **SDK** `sdk.firms.deploy(firmId, { target, dryRun })` · **MCP** `deploy_prop_instance` ## 5. Go live ```bash curl -s https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/status \ -X PATCH \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "status": "active" }' ``` `status` accepts `pending_deploy | active | paused | migrated`. Pause any time to stop new challenges without tearing down the instance. **SDK** `sdk.firms.setStatus(firmId, 'active')` · **MCP** `set_firm_status` ## 6. Traders enter and trade Your traders — not your operator credential — drive this half: 1. **Start a challenge** — `POST /api/v1/prop-accounts { templateId }` creates a `pending_payment` account against one of your templates. 2. **Pay the entry fee** — the trader's wallet signs the deposit calldata from `POST /api/prop/deposit-calldata { propAccountId, amountUsdcUnits }`. Funds go straight to your `PropInstance`; the claim watcher flips the account to `active`. 3. **Trade** — the account trades against your risk rules. You manage what they trade against with your `prop:manage` scope: `/api/v1/prop-templates/*`, `/api/v1/risk-profiles/*`, `/api/v1/trading-groups/*`. See [Prop account model](/docs/launch/prop-accounts/). ## 7. Approve payouts When a trader passes, the payout lands in your worklist. Approving **records and queues** it — settlement is dispatched against your instance. You are the authorisation, not the on-chain sender. ```bash # Who's owed curl -s https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/payouts/pending \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # Decide curl -s https://api.troncharts.xyz/api/v1/firms/$FIRM_ID/payouts/$PROP_ACCOUNT_ID/decision \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "decision": "approved", "amountUsd": 4200, "reason": "passed evaluation" }' ``` **SDK** `sdk.firms.decidePayout(firmId, propAccountId, { decision, amountUsd })` · **MCP** `approve_payout` ## Do it all with one prompt The MCP server ships a scripted `launch-firm` prompt that runs create → readiness → dry-run deploy → broadcast → re-check → go-live. It needs the **stdio** server built from the monorepo — the firm-lifecycle tools it calls are not on the Streamable HTTP endpoint. See [MCP server](/docs/sdks/mcp/) and [Build with an AI agent](/docs/sdks/ai-agents/). ## Full contract Every field and response shape is in the [REST reference](/docs/reference/rest/) under `/api/v1/firms/*`. Fee BPS, forfeiture rules, and status transitions are authoritative there. --- # Prop account model URL: https://docs.troncharts.xyz/docs/launch/prop-accounts/ A prop account is an **evaluation contract over real signed trades**. The platform never holds the trader's trading funds — traders sign their own trades — while collateral and payouts settle on your firm's on-chain treasury. Each firm is one prop tenant with its own `PropInstance` contract, deployed by a shared factory. Your multisig owns the instance and its treasury. The platform's risk engine acts as the contract's **observer**: it marks pass, breach, and expiry, and sets payout caps. The trader deposits, quits, and requests payouts directly from their own wallet. Two program kinds share one engine: `challenge` (tier ladder, profit or points target) and `flash` (collateral-to-funding ratio, time as currency). ## Lifecycle Two paths reach `active`, and they don't overlap. An account the trader buys goes `pending_payment` → `active` when the deposit lands on-chain. A child account created by a tier-ladder or next-step promotion starts in `pending_agreement` and goes → `active` when the trader agrees. From there: `active` → `passed` → `paid_out`, with `breached`, `expired`, and `cancelled` as terminal branches. | Phase | What happens | | --- | --- | | **Catalog → buy** | The trader browses active templates and creates an account in `pending_payment` (`POST /api/v1/prop-accounts`). | | **Deposit** | The trader's wallet calls the instance's deposit function. The entry fee is skimmed, the remainder becomes collateral, and the account activates on-chain. The platform composes the calldata via `POST /api/prop/deposit-calldata`; the wallet broadcasts. | | **Deposit observed → active** | The claim watcher sees the on-chain `ChallengeDeposited` event and flips the account `pending_payment` → `active`. **No API call is involved** — this is the only path that activates a bought account. | | **Evaluation** | The engine re-evaluates every rule on each trade, position, and funding change, plus a UTC-midnight roll. | | **Breach** | Any rule returning breach is terminal. The on-chain forfeiture split applies and positions are flattened. | | **Pass** | Pass conditions met. **No money moves** — the observer records the on-chain payout ceiling. | | **Promotion → agree** | If the passed account's template carries a tier ladder or a next-step template, the engine creates a **child** account in `pending_agreement`. The trader accepts it with `POST /api/v1/prop-accounts/{id}/agree`, which moves that child to `active`. Called against any other status, `/agree` returns `409 invalid_state`. | | **Funded** | A passed account keeps trading. The trader requests payouts; your multisig approves. | | **Quit** | The trader voluntarily closes an active account; the quit forfeiture split applies and the remainder is refunded. Terminal. | | **Expiry** | The observer marks expiry; the expiry split applies and collateral remainder is refunded. Terminal. | ## Hard enforcement is live and default-on This is the core enforcement primitive, and it is not advisory. On **every order**, the platform evaluates a hypothetical fill against each active prop account on the trading account the order is placed from, and rejects a breaching intent with `prop_breach_would_occur` (HTTP `409`) — **before the venue ever sees the order**. `PATCH /api/v1/firms/{id}/hard-enforcement` writes a per-firm override, but program rules are enforced **by design**: with the platform's default settings the override is not consulted at all. Do not treat it as a way to run a firm in observe-only mode. The practical consequence: a trader cannot breach by racing the evaluator. The order simply doesn't reach the market. ## Rule catalog Rules are **snapshot-frozen at account creation**, so editing a template never retroactively passes or fails an in-flight account. Rule types include `daily_loss_cap_usd` · `max_drawdown_usd` · `min_trading_days` · `profit_target_usd` · `max_concentration_pct` · `consistency_pct` · `allowed_venues` · `forbidden_symbols` · `points_target` · `inactivity_timer_s` · `collateral_drawdown_pct`. The registry is larger than this list. Each rule evaluates to `{ type, status, detail, metric?, threshold? }`, where `status` is one of `ok`, `breach`, `pass`, `pending`, `na`, or `missing_evaluator`. `breach` is terminal. `pass` satisfies a pass condition. `pending` means a goal is defined but not reached yet (`min_trading_days` at 3 of 5) — it does not breach the account, but it is **not inert**: a single `pending` rule holds the pass back, and it blocks a payout the same way a `breach` does. `na` (the rule doesn't apply yet) and `missing_evaluator` (a rule type with no registered evaluator) never block anything. Render `pending` as in-progress, not as a failure — most in-flight challenges carry at least one. `GET /api/v1/prop-accounts/{id}` returns these objects verbatim as `results`. A client consuming the catalog also gets parsed headline thresholds (`maxDdUsd`, `dailyLossUsd`, `profitTargetUsd`) as first-class fields, so you can render a challenge card without interpreting the rule objects. Manage them with `prop:manage`: `/api/v1/prop-templates/*` for templates, `/api/v1/risk-profiles/*` for reusable rule sets, `/api/v1/trading-groups/*` for fee, leverage, and venue scoping. ## The funded-account payout model A passed account is a **funded account that keeps trading**. Four properties define the mechanic: 1. **The ceiling is profit × split.** At pass, the observer records a per-account payout ceiling equal to the trader's split-adjusted profit. A trader can never withdraw more than they earned. 2. **Two independent caps.** Every payout is gated by *both* the profit entitlement *and* treasury solvency. 3. **Multiple withdrawals, no close on payout.** Approving accumulates the payout total and leaves the account passed — the funded trader keeps trading. It only leaves that state via breach, quit, or expiry. 4. **Lazy ceiling raise.** When a funded trader requests a payout, their current entitlement is recomputed and the on-chain ceiling raised if they've earned more since passing. Raise-only. Settlement is two-step: the trader's request creates a pending on-chain request, and your multisig settles it. `POST /api/prop/payout-calldata` pre-checks the live ceiling, account, and treasury, returning `exceeds_profit_entitlement` (400) or `treasury_underfunded` (409) — though the contract enforces both regardless of what the API says. ## Blocking vs suspending Two operator controls that read alike and are not alike. Both need the `prop:manage` scope and are hard-scoped to your own tenant. | Call | What it does | | --- | --- | | `PATCH /api/v1/accounts/{id}/suspension` | Sets or clears the suspension. **Positions are untouched.** New opens are refused; the trader keeps what they hold. | | `POST /api/v1/accounts/{id}/block` | Suspends **and flattens every open position**, then writes an `account.block` audit row. | | `POST /api/v1/accounts/{id}/unblock` | Clears the suspension. **It does not restore anything.** | :::danger[A block is not reversible] `block` closes positions at the market. `unblock` only lifts the suspension — the positions it flattened stay flattened, and the realised PnL stands. If you want a reversible hold, use `PATCH /suspension`. Reach for `block` only when you intend to take the trader out of the market immediately. ::: `block` accepts an optional `reason`, which lands in the audit row alongside how many positions were flattened and any that failed to close. Read those errors — a flatten that partially failed leaves the account suspended with positions still open, which is the one state neither name describes. Both resolve the account by UUID **or** public account number. ## Diagnosing an evaluation `GET /api/v1/analytics/prop-diagnosis/{propAccountId}` answers "where does this challenge stand?" in one call, so you can build a trader-facing status screen without re-deriving the rules yourself. ```bash curl -s https://api.troncharts.xyz/api/v1/analytics/prop-diagnosis/$PROP_ACCOUNT_ID \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` Returns the account's `status`, its template (`templateId`, `templateName`, `programKind`), the lifecycle timestamps (`startedAt`, `expiresAt`, `closedAt`, `closedReason`), `payoutOwedUsd`, up to 50 `recentTrades` scoped to this account's own fills within the challenge window, and a `recommendation`. `recommendation` is one of `continue`, `pause`, `closed`, `claim_ready`. :::note[What the recommendation is, and isn't] It is a **status heuristic**, not rule analysis: terminal states map to `closed`, a passed account to `claim_ready`, and three or more consecutive recent losers to `pause`. It does not tell you which rule broke, how close the account is to a threshold, or which trade caused a breach — per-rule diagnostics are a later phase. Render it as a hint, and take the authoritative rule state from the account's frozen rule set and its [`/events`](/docs/trading/accounts/#history--reconciliation) history. ::: Scoped to the calling session's account: another account's prop record answers `404`. ## Reading account state | Endpoint | Returns | | --- | --- | | `GET /api/v1/prop-accounts` | Accounts you can see. | | `GET /api/v1/prop-accounts/{id}` | One account with its frozen rule set and live metrics. | | `GET /api/v1/prop-accounts/{id}/events` | The full state-transition history. | Live progress is also pushed as `Prop-Account-Update` on [`/ws/risk`](/docs/realtime/risk/). --- # How an account is configured URL: https://docs.troncharts.xyz/docs/launch/account-configuration/ Three objects shape what an account may trade and under what limits. They look overlapping from the outside, so this page states exactly what each one owns and which of them you write. Short version: **you write a trading group and, for prop, a challenge template.** A risk profile is something the server keeps for you unless you deliberately want to share one rule set across several templates. ## The provisioning order ``` trading group ──► challenge template ──► trader ──► account (required) (prop only) (required) ▲ risk profile (optional) ``` 1. **Trading group** — the bare minimum. A template cannot be created without one (`400 trading_group_required`), and every account resolves to one. 2. **Challenge template** — for a prop program. Carries the rules, and points at the group from step 1. 3. **Risk profile** — optional, and only when you want to share one rule set across templates, or to put limits on a non-prop account. Otherwise the server keeps a managed one for you. 4. **Trader** — `POST /api/v1/users`. An account needs an owner, and its `id` is the `ownerUserId` the account calls want. 5. **Account** — `POST /api/v1/accounts/create-prop` (prop) or `POST /api/v1/accounts` (plain). ### `ownerUserId` is the trader The name trips people up, so plainly: it is the **person who trades the account** — a `users.id`. It is not your tenant and not your prop firm. | Field | Is | Where it comes from | | --- | --- | --- | | `ownerUserId` | The **trader** | `POST /api/v1/users` | | `tenantId` | Your **platform tenant** | Forced from your credential — never sent | | `propTenantId` | One **prop firm** you operate | `GET /api/v1/firms` | Naming a trader from another tenant returns `404 owner_user_not_found` rather than confirming they exist. ```ts const { user } = await sdk.users.create({ email: 'ana@example.com', externalId: 'crm-4471' }) // user.id → pass as ownerUserId ``` Pass your own `externalId` and the create becomes **idempotent**: a retried provisioning run returns the same trader (`idempotent: true`) instead of splitting one person across two identities. Creating a trader does not authenticate them — they still sign in with a wallet or social auth; this only establishes the identity accounts hang off. ## What each one owns | | Owns | Answers | | --- | --- | --- | | **Trading group** | Venue and symbol allowlists, fee schedule and rebate share, leverage caps, spread skew and price bands, per-symbol and per-venue overrides | *Which markets, at what price* | | **Challenge template** | The prop product: account size, entry fee, payout split, duration, tier ladder, next-step chain, reset and activation fees — plus the rule set the run is judged against | *What the trader buys, and how they pass or fail* | | **Risk profile** | A named, reusable rule set (`trader`, `challenge` or `funded`) | *Which limits apply* | The layers are a **most-restrictive cascade**, not a hierarchy. A group's venue allowlist is enforced at compose time; the prop rules are enforced after the price is resolved. Both run on every order — neither overrides the other, and each can reject on its own. ## The rules live in one place A template's rules and its risk profile are the same rules. You write them **once, on the template**: ```ts await sdk.propTemplates.create({ id: 'acme-25k-1step', name: 'Acme 25K Evaluation', accountSizeUsd: '25000.00', feeUsd: '199.00', payoutSplitPct: '80', tradingGroupId: group.id, propTenantId: firm.id, rules: [ { type: 'max_drawdown_usd', value: 1250 }, { type: 'profit_target_usd', value: 2000 }, ], }) ``` The server materializes a **managed risk profile** holding those rules and binds it to the template. You never name it, create it, or bind it. It shows up in `GET /api/v1/risk-profiles` with `managedByTemplateId` set, and it is read-only there — `PATCH` and `archive` return `409 managed_by_template`. To change the rules, patch the template. That is the whole model for most integrations. The rest of this page is for the two cases where you touch a profile directly. ### Case 1 — sharing one rule set across templates Six templates (three sizes × two steps) often share two rule sets. Author the profile once and reference it, instead of repeating the rules six times and watching them drift on the next edit: ```ts const { profile } = await sdk.riskProfiles.create({ name: 'Acme 1-step', kind: 'challenge', rules: [{ type: 'max_drawdown_usd', value: 1250 }], }) for (const size of ['25000.00', '50000.00', '100000.00']) { await sdk.propTemplates.create({ /* … */ accountSizeUsd: size, riskProfileId: profile.id }) } ``` `rules` and `riskProfileId` are **mutually exclusive**. Sending both is `400 rules_and_profile_conflict` — a template has one source of truth for its rules, so neither one silently wins. When you send `riskProfileId`, the profile's rules are copied down into the template so the snapshot the engine freezes onto each account matches the profile exactly. While a template reads a shared profile, patching its `rules` returns `400 rules_locked_by_profile`. Either edit the profile (every template reading it follows), or send `riskProfileId: null` to move the rules back onto the template, where they become managed again. ### Case 2 — limits on a non-prop account A plain live or paper account has no template. To give one limits, author a `trader` profile and bind it: ```ts const { profile } = await sdk.riskProfiles.create({ name: 'Retail default', kind: 'trader', enforcement: 'hard', rules: [{ type: 'max_leverage', value: 20 }], }) await sdk.riskProfiles.bind(profile.id, accountUuid) ``` ## What lands on a new account `POST /api/v1/accounts` accepts `tradingGroupId` and `riskProfileId` directly, so an account starts on the right scope instead of being created on the tenant default and re-bound a moment later. Both are optional and both are validated against your tenant: an unknown group is `400 trading_group_not_found`, and a **managed** profile is `400 profile_is_managed` — it belongs to its template, so create the account from that template instead. Omit them and the account inherits the tenant's default group with no profile bound, exactly as before. :::note[This endpoint needs a user session] An api-client bearer has no user of its own, so it gets `403 user_session_required` here. Partner-side account provisioning goes through `POST /api/v1/accounts/create-prop` with an `ownerUserId`, or `POST /api/v1/sandbox/paper-account`. ::: `POST /api/v1/accounts/create-prop` takes a `templateId` and an `ownerUserId`, and the template carries the rest: its `tradingGroupId` and its risk profile are both propagated onto the new account, and the rule set is frozen onto the prop account at creation. So for a prop integration you configure **one** object — the template — and the group and profile ride along. A later edit to the template does **not** re-judge accounts already in flight; they keep the rules they were sold under. New accounts pick up the new rules. ## Which rules are enforced today | Bound to | Enforced by | Status | | --- | --- | --- | | A prop account (from a template) | The prop engine, against the frozen snapshot | **Hard, always on.** A breaching order is rejected with `409 prop_breach_would_occur` before the venue sees it | | A non-prop account (`trader` profile) | The order-time risk-profile gate | Advisory today — `ENFORCE_RISK_PROFILES` ships off, so a bound `trader` profile annotates rather than blocks | | Any account, via its trading group | The compose-time group gate | **Hard, always on** for venue and symbol scope | A managed profile is deliberately **not** evaluated on the risk plane: the prop engine already enforces the identical rules from the frozen snapshot, so evaluating them twice would report every rule a second time as an advisory warning alongside the real verdict. **Next:** [Create a challenge template](/docs/recipes/create-a-challenge-template/) · [Create a trading group](/docs/recipes/create-a-trading-group/) · [Create a risk profile](/docs/recipes/create-a-risk-profile/) · [Prop account model](/docs/launch/prop-accounts/) --- # Sandbox & testing URL: https://docs.troncharts.xyz/docs/sandbox/overview/ There is no separate test environment to learn. A sandbox account is a **real account on the real API** whose fills are matched by the platform's own engine instead of being dispatched to an exchange. The order path, the WebSocket frames, the reports — all identical. Only the counterparty changes. That is the point: an integration proven against a sandbox account is proven against the code that will carry real money. ## What you get One call provisions a paper account for your credential, seeds it with a balance, and (by default) opens a demo position so your first read has something in it: ```bash curl -s https://api.troncharts.xyz/api/v1/sandbox/paper-account \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{}' ``` The account is linked into your credential's ownership scope, so it appears in `GET /api/v1/accounts` and its state and positions are readable immediately — no operator step, no ticket. See [Paper accounts](/docs/sandbox/paper-accounts/) for the fields and the response. ## What it does not simulate Be clear-eyed about the boundary, because it decides what you still have to test in production: | Exercised faithfully | Not exercised | | --- | --- | | Order compose, sign, dispatch, and lifecycle | The venue's own matching and queue position | | Every `/ws/risk` frame, including fills and terminal states | Real slippage and partial-fill patterns under stress | | Margin, balance, and risk recomputation | Exchange outages and venue-side rejections | | Reports, analytics, and reconciliation reads | Actual withdrawal and custody paths | A sandbox fill is matched against a firm-canonical mark, not against a live order book. Your execution logic is proven; your assumptions about fill quality are not. ## Availability Sandbox provisioning is enabled **per deployment**. Where it is off, the endpoint answers `404` — deliberately indistinguishable from not existing, so a probe learns nothing. If you get a 404 and expect otherwise, ask the operator of your deployment rather than assuming the path is wrong. ## Where to go next - [Paper accounts](/docs/sandbox/paper-accounts/) — provision one, read it, stream it. - [Rehearsing a prop firm](/docs/sandbox/rehearsing-a-firm/) — a paper twin of a firm, before real deposits. - [Go-live checklist](/docs/sandbox/go-live-checklist/) — what to verify before real money. --- # Paper accounts URL: https://docs.troncharts.xyz/docs/sandbox/paper-accounts/ `POST /api/v1/sandbox/paper-account` ## Provision one Every field is optional — an empty body gets sensible defaults. ```bash curl -s https://api.troncharts.xyz/api/v1/sandbox/paper-account \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "baselineUsd": "50000", "symbol": "BTC.HL", "seedDemoPosition": true, "displayName": "integration rehearsal" }' ``` | Field | Default | Notes | | --- | --- | --- | | `baselineUsd` | `"50000"` | Starting balance, as a positive decimal string. | | `symbol` | `"BTC.HL"` | The symbol for the seeded demo position. | | `seedDemoPosition` | `true` | Opens one small position so your first read isn't empty. | | `displayName` | — | A label for your own bookkeeping. | :::caution[Use a venue-suffixed symbol] `BTC.HL` prices; a bare `BTC` does not. A symbol with no venue suffix has no pricing venue, so the seeded position's mark **stays frozen** and every P&L read off it is meaningless. Take symbols from `GET /api/v1/symbols/{venue}`. ::: ## The response `201`, with a fixed shape: ```json { "accountId": "…", "accountNumber": "000123", "propTenantId": "…", "templateId": "…", "mode": "paper", "demoPositionSeeded": true, "ws": { "hint": "Subscribe on the risk WS …" } } ``` `accountId` is the canonical identity — use it everywhere. `accountNumber` is bare zero-padded digits (a legacy prefixed form is still accepted on input, but not emitted). ## Who can call it Three gates, checked in this order: 1. **Deployment** — off means `404`, before anything else is looked at. 2. **Credential type** — an **API-client bearer** token. A browser cookie session gets `403 api_client_required`. 3. **Tier** — `fullTrading`. `readonly` and `liquidation` get `403 tier_insufficient`. No `prop:manage` scope is needed. Other failures: `400 invalid_body` with the offending fields, `402` where a card on file is required, and `422` when provisioning can't complete. ## Read it The account is in your credential's ownership scope from the moment it exists: ```bash curl -s https://api.troncharts.xyz/api/v1/accounts \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/state \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ## Stream it A `/ws/risk` socket carries **one active account at a time**, so after provisioning you re-subscribe with the new id explicitly: ```json { "type": "Subscribe", "id": "…", "topics": ["…"], "accountId": "" } ``` The account must be one your credential owns; anything else is rejected with `account_forbidden`. You'll then receive `Account-State-Update`, `Balance-Update`, and `Margin-Update` for it like any other account — see [Risk channel](/docs/realtime/risk/). ## Calling it more than once The supporting chain behind a sandbox account is reused across calls, but the **account itself is not deduplicated** — every call creates a new one. That's deliberate (a clean account per rehearsal), but it means a retry loop around this endpoint quietly accumulates accounts. Provision once per rehearsal and keep the `accountId`. ## What the sandbox enforces The sandbox runs a paper-only template, but the rules on it are enforced **exactly like a real prop account** — same engine, same gate, same rejection. The template carries two rules: | Rule | Threshold | At the `50000` default | | --- | --- | --- | | `max_drawdown_usd` | 10% of the baseline | `5000` | | `daily_loss_cap_usd` | 5% of the baseline | `2500` | An order that would breach either is rejected at compose with `prop_breach_would_occur` (HTTP `409`) — the venue never sees it. And a breach is **terminal**: the account moves to `breached`, after which any new non-reduce-only order on it is refused for `terminal_status`. Reduce-only orders still go through, so you can close what you hold. There is no un-breach. To keep rehearsing, provision a fresh account with another `POST /api/v1/sandbox/paper-account`. :::caution[The thresholds are fixed on your first call] The sandbox template is created once per tenant, from the `baselineUsd` of the **first** provisioning call, and is reused unchanged after that. A later call with a different `baselineUsd` changes the new account's paper balance but not the template's rule thresholds. Pick the baseline you want to rehearse at on the first call — or deliberately breach a throwaway account, to see the `409` your integration has to handle. ::: This is the same enforcement described in [Hard enforcement is live and default-on](/docs/launch/prop-accounts/#hard-enforcement-is-live-and-default-on), so the error handling you build here carries over to a real template unchanged. --- # Rehearsing a prop firm URL: https://docs.troncharts.xyz/docs/sandbox/rehearsing-a-firm/ Creating a firm with `withPaperShadow: true` (the default) seeds a **paper twin** alongside it. The twin runs the same engine against the same rules with no real money, so you can walk your traders' entire journey before anyone deposits. ## Get the twin It comes with the firm — nothing extra to call: ```bash curl -s https://api.troncharts.xyz/api/v1/firms \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "name": "Acme Funded", "multisigAddress": "0xYourGnosisSafe000000000000000000000000", "feeConfig": { "entryFeeBps": 500, "payoutFeeBps": 1000 }, "chainId": 42161, "withPaperShadow": true }' # → { "firm": { … }, "paperShadowId": "…", "seededTemplates": [ … ] } ``` Keep `paperShadowId`. It is the firm you rehearse against while the real one sits in `pending_deploy`. ## What you can exercise | Stage | Rehearsed on the twin | Needs the real firm | | --- | --- | --- | | Publish challenge templates | ✅ | | | Trader starts a challenge | ✅ | | | Pay the entry fee | | ⛔ on-chain deposit to your instance | | Rule evaluation on every trade | ✅ | | | Breach and pass transitions | ✅ | | | Payout lands in the worklist | ✅ | | | Settlement to the trader | | ⛔ your multisig approves on-chain | Everything up to money movement is real machinery. The two gaps are exactly the places where custody lives, and no rehearsal can stand in for them — which is the point of the [custody model](/docs/auth/trust-model/#prop-firms-custody-stays-at-your-multisig). ## A rehearsal worth running 1. **Publish a template** on the twin with the rules you intend to sell, via `/api/v1/prop-templates/*`. 2. **Start a challenge** — `POST /api/v1/prop-accounts { templateId }`. 3. **Trade it to a breach on purpose.** Put on a position that violates your daily loss cap and confirm the account goes terminal, positions flatten, and your systems hear about it. 4. **Then trade one to a pass** and confirm the payout ceiling is recorded as profit × split — no money moves at pass, and that surprises people. 5. **Work the payout queue** — `GET /api/v1/firms/{id}/payouts/pending`, then a decision. Confirm your operators can find it and act on it. 6. **Watch it all over `/ws/risk`** — `Subscribe { topics: ["Prop-Account-Changed"] }` before step 3, then read `Prop-Account-Update` plus the `Prop-Account-*` transition frames, since that is how your production dashboard will learn about it. The socket pushes nothing for a topic you never subscribed to, so an unsubscribed rehearsal looks exactly like one where nothing happened. Step 3 is the one teams skip and the one that matters: the breach path is where a trader's account ends and your support load begins. ## Before you point traders at the real firm The twin proves your rules behave. It does not prove your firm is deployable — that is [readiness](/docs/launch/prop-firm/#3-wait-for-readiness), and it is a separate checklist. Run both, then see the [go-live checklist](/docs/sandbox/go-live-checklist/). --- # Go-live checklist URL: https://docs.troncharts.xyz/docs/sandbox/go-live-checklist/ Every item here is something that has actually bitten an integration. Each one names the endpoint or frame that proves it, so this is a checklist you can execute rather than nod at. ## Credentials - [ ] The credential you ship with holds the **narrowest tier** that does the job. A reporting integration does not need `fullTrading`; a risk system that only flattens needs `liquidation`. `GET /api/v1/discovery` reports the tier for the ~40 endpoints it catalogs, but no scopes and not the whole surface — it cannot confirm a credential carries `firm:operate`, `prop:manage`, or `tenant:config`. Check the rest against the [OpenAPI spec](/docs/reference/specs/). - [ ] Your secret is **not** in the repository, the bundle, or a log line. It is shown once at issuance. - [ ] You have exercised **rotation** — issue a second credential, deploy it, revoke the first — at least once, before you need to do it under pressure. - [ ] A revoked token fails the way you expect: revoke one deliberately and watch your error path. ## Orders - [ ] Every order write sends an **`Idempotency-Key`**. A network timeout on placement is indistinguishable from a rejection, and a retry without a key can double a position. - [ ] You track orders by **intent id**, and take the terminal state from [`/ws/risk`](/docs/realtime/risk/) rather than polling. - [ ] `venue` and `symbol` come from `GET /api/v1/venues` and `GET /api/v1/symbols/{venue}`, not from constants. Both change per deployment without an API version bump. - [ ] Sizes and prices are sent as **decimal strings**. `0.1` does not survive a JSON double round-trip exactly. - [ ] You have handled `422` — the venue rejected it — distinctly from `400`. They mean different things and usually need different retries. ## Streams - [ ] You send **`Subscribe { topics: [...] }`** after `Authenticated`. `/ws/risk` pushes nothing until you do, and `topics` must be non-empty. - [ ] You assert **`seq` contiguity** on `/ws/risk` and re-snapshot on a gap. Frames coalesce under load; a gap is normal, not exceptional. - [ ] You handle **`Resync-Required`** by dropping local state, not by ignoring it. - [ ] Your heartbeat sends `Ping` every 30s. The socket idles out after 60s of inbound silence. - [ ] You seed correctly: **subscribe first**, then fetch the REST snapshot, then apply buffered updates. The other order leaves a hole. - [ ] Reconnect **mints a fresh bootstrap token, re-authenticates, re-`Subscribe`s to its topics, and re-requests state** rather than assuming the cached view survived. Topics live on the connection, so a new socket starts with an empty topic set and stays silent until it subscribes. ## Money and reconciliation - [ ] You compute volume from **fills** and P&L from **trades**. Mixing them is the most common reconciliation bug. - [ ] You know whether your accounts are **routed or internalised**, and you discriminate on the **account**, never on `venue: "paper"` — that sentinel covers both the sandbox and internalised real-money accounts. See [the execution model](/docs/auth/trust-model/#execution-routed-vs-internalised). - [ ] You do **not** build exchange-side reconciliation on `venueOrderId` for internalised rows; those ids are ours and resolve nowhere off-platform. ## Webhooks, if you use them - [ ] Your receiver **verifies `x-webhook-signature`** — HMAC-SHA256 over the raw body bytes, hex-encoded and `sha256=` prefixed, compared in constant time — before parsing the body, and checks `x-api-key` in addition, not instead. The signature is the provenance proof; `x-api-key` is a static bearer that anyone who captures one delivery can replay. See [Webhooks](/docs/realtime/webhooks/#writing-a-receiver). - [ ] It **deduplicates on `eventId`** — delivery is at-least-once. - [ ] It returns **200 fast**, then works. A slow receiver looks like a failing one and earns a retry. - [ ] You know a dead endpoint **auto-disables** after the retry budget (1s → 24h), and that re-enabling it is an operator action — so you have a way to notice, and someone to ask. ## Prop firms - [ ] You have driven a challenge to a **breach** on the paper twin and watched the account go terminal and positions flatten. - [ ] You have driven one to a **pass** and confirmed no money moves at pass — only the payout ceiling is recorded. - [ ] You have worked a payout **decision** end to end. - [ ] Readiness reports `ready: true` **and** `deployer_configured` is green — `ready` aggregates only the `required` checks and ignores that one, but a live broadcast without it returns `503 deployer_unconfigured`. Dry-run deploy before broadcasting; the dry run returns before the deployer key is ever checked, so it cannot catch this for you. ## The last one - [ ] Do all of the above against a [sandbox account](/docs/sandbox/paper-accounts/) first, then repeat the order path with the **smallest possible real size** before you scale it. The sandbox proves your code; a small live order proves your assumptions about fills. --- # Recipes URL: https://docs.troncharts.xyz/docs/recipes/ import { LinkCard, CardGrid } from '@astrojs/starlight/components' Each recipe answers one question — *how do I do X* — in three to five steps, with code you can paste. They build on the primitives the reference sections document; where a recipe skips detail, it links to the page that owns it. Everything below assumes you have a bearer token and are sending `X-Tenant-Slug` on every call. If you don't yet, start with the first one. ## Getting started ## Trading ## Real-time ## Run a prop firm ## Agents and tooling ## White-label --- # Mint a token and make your first call URL: https://docs.troncharts.xyz/docs/recipes/mint-a-token/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' Every REST call is authenticated with a short-lived bearer token you mint yourself from a long-lived credential. You end up with a token, the two headers every later call needs, and a response that proves both of them resolved. 1. ### Exchange the credential for a token `POST /api/auth/api-token` takes `{ apiKey, apiSecret }` and returns a 24-hour HS256 JWT in `token`. The credential pair itself is issued out of band — by an operator in the admin console, or by self-serve tenant signup where that is enabled. See [Credentials & tokens](/docs/auth/credentials/). ```bash curl -s https://api.troncharts.xyz/api/auth/api-token \ -H 'content-type: application/json' \ -d '{"apiKey":"'"$TC_API_KEY"'","apiSecret":"'"$TC_API_SECRET"'"}' # → { "token": "eyJ…", "tier": "fullTrading", "accountId": "…", # "expiresAt": 1754300000000, "enabledTools": null, … } ``` ```ts import { TronCharts } from '@tronchartsxyz/api-client' // The mint path authenticates from the body, so the minting client // needs no token of its own. const minter = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token: '' }) const { token } = await minter.auth.apiToken({ apiKey: process.env.TC_API_KEY!, apiSecret: process.env.TC_API_SECRET!, }) ``` `tier` is snapshotted at mint: a tier change on the credential needs a fresh token to take effect. 2. ### Send the token and the tenant together `GET /api/v1/_meta/test-connection` is the one round-trip that confirms the bearer, the tenant and the tier all resolved. It returns `{ ok, accountId, apiClientId, tier, serverTime }`. :::caution[Both headers, on every `/api/v1` call] `/api/v1/*` resolves a tenant *before* it reads your token, and an origin no tenant claims is refused with `404 unknown_tenant_origin`. Miss `x-tenant-slug` and you get a 404 that reads like a wrong URL — see [Tenancy](/docs/auth/tenancy/). ::: ```bash curl -s https://api.troncharts.xyz/api/v1/_meta/test-connection \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { "ok": true, "accountId": "…", "apiClientId": "…", # "tier": "fullTrading", "serverTime": "2026-08-04T12:00:00.000Z" } ``` ```ts const sdk = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token, tenantSlug: process.env.TC_TENANT_SLUG!, // sent as x-tenant-slug on every request }) // No typed resource wraps /_meta; sdk.client is the escape hatch. const who = await sdk.client.request<{ ok: boolean; accountId: string; tier: string }>( '/api/v1/_meta/test-connection', ) ``` 3. ### Read something back `GET /api/v1/accounts` lists every account the credential can reach and gives you the `accountId` that every `/api/v1/accounts/{id}/*` read needs. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { rootAccountId, activeAccountId, # accounts: [ { accountId, accountNumber, kind, capitalModel, # displayName, baseCurrency, wallets: [...] } ] } ``` ```ts const { accounts } = await sdk.accounts.list() // The route sends `accountId` on each row; the SDK's `id` field is not // on the wire in 0.3.0. const accountId = accounts[0]!.accountId as string ``` Revoke a token before its 24 hours are up with `POST /api/auth/api-token/revoke` and `{ token }`. **Next:** [Create a paper account](/docs/recipes/create-a-paper-account/) · [Read balance, positions and open orders](/docs/recipes/read-account-state/) · [Scopes & tiers](/docs/auth/scopes/) --- # Create a paper account to test against URL: https://docs.troncharts.xyz/docs/recipes/create-a-paper-account/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' One call provisions a paper account with a starting balance and a seeded position, so your first reads return real data and your first orders run the real pipeline without real money. 1. ### Provision the account `POST /api/v1/sandbox/paper-account`. Every field is optional and `{}` is a valid body — the defaults are `baselineUsd` `"50000"`, `symbol` `"BTC.HL"` and `seedDemoPosition` `true`. The credential must be `fullTrading`; this route compares the tier for exact equality, so `readonly` and `liquidation` both get `403 tier_insufficient`. No `prop:manage` scope is involved. :::caution[Off by default] `SANDBOX_PROVISIONING_ENABLED` defaults to `'false'` and the route returns a bare `404 not_found` until an operator turns it on. A 404 here means the flag, not a wrong path. ::: ```bash curl -s https://api.troncharts.xyz/api/v1/sandbox/paper-account \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"baselineUsd":"50000","symbol":"BTC.HL","displayName":"integration rehearsal"}' # → 201 { accountId, accountNumber, propTenantId, templateId, # mode: "paper", demoPositionSeeded: true, ws: { hint } } ``` ```ts const sandbox = await sdk.sandbox.paperAccount({ baselineUsd: '50000', symbol: 'BTC.HL', displayName: 'integration rehearsal', }) const accountId = sandbox.accountId ``` The new account is linked into the calling credential's ownership scope, so it shows up in `GET /api/v1/accounts` and you can address it by `accountId` from here on. Field-by-field detail lives on [Paper accounts](/docs/sandbox/paper-accounts/). 2. ### Read the seeded state back The account is funded and holding the demo position the moment the 201 lands. `/state` returns balance and equity per venue; `/positions` returns what it holds. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/state \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, # perVenue: [ { venue, exchange, equityUsd, balanceUsd, marginUsedUsd, # marginAvailableUsd, unrealizedPnlUsd, … } ] } ``` ```ts const state = await sdk.accounts.state(accountId) const { positions } = await sdk.accounts.positions(accountId) console.log(state.perVenue, positions.length) ``` 3. ### Point your order flow at it Send the sandbox `accountId` in the OMS body. It is optional on `POST /api/v1/oms/intents` and defaults to the session's active account, which a bearer credential does not have — so name it explicitly. Use `"venue": "paper"`; against a non-paper account that is rejected `400 paper_venue_requires_paper_account`. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"accountId":"'"$ACCOUNT_ID"'","venue":"paper","symbol":"BTC.HL", "side":"buy","type":"market","qty":"0.01"}' ``` The paper engine matches synchronously, so a market order comes back as `201 { intentId, state: "filled", syncFill: { qty, avgPrice } }`. The full body schema is on [Orders & OMS](/docs/trading/orders/). **Next:** [Place your first order](/docs/recipes/place-your-first-order/) · [Read balance, positions and open orders](/docs/recipes/read-account-state/) · [Stream account updates](/docs/recipes/stream-account-updates/) --- # Read balance, positions and open orders URL: https://docs.troncharts.xyz/docs/recipes/read-account-state/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' Everything readable about an account hangs off `/api/v1/accounts/{id}/*` — what it is worth, what it holds, and what is still working. `{id}` takes the account UUID or the bare account number (`PH-000123`, or the digits), and a `readonly` credential is enough for every call here. 1. ### Read balance and equity `GET /accounts/{id}/state` returns one row per venue the account has traded on. Amounts are USD decimal strings. :::note[There is no account total] `/state` returns no top-level `equityUsd`, `balanceUsd` or `buyingPower` — the response is `{ accountId, accountNumber, perVenue }` and nothing else. Sum `perVenue` yourself for an account-wide figure. ::: ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/state \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, # perVenue: [ { venue, exchange, equityUsd, balanceUsd, marginUsedUsd, # marginAvailableUsd, unrealizedPnlUsd, source, … } ] } ``` ```ts const state = await sdk.accounts.state(accountId) const equityUsd = state.perVenue .reduce((sum, v) => sum + Number(v.equityUsd), 0) ``` 2. ### Read open positions `GET /accounts/{id}/positions` returns `{ accountId, accountNumber, positions }`. `unrealizedPnlUsd` is computed from `markPrice` and is `null` whenever the mark is unavailable — treat it as nullable, not zero. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/positions \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → positions: [ { venue, exchange, symbol, side, qty, entryPrice, markPrice, # liquidationPrice, leverage, unrealizedPnlUsd, # openedAt, updatedAt } ] ``` ```ts const { positions } = await sdk.accounts.positions(accountId) const open = positions.filter((p) => Number(p.qty) !== 0) ``` 3. ### Read orders `GET /accounts/{id}/orders` reads the OMS order intents, so `status` is the intent state and `side` comes back `long` / `short`, not `buy` / `sell`. Filters: `venue`, `symbol`, `cursor`, `limit` (1–1000, default 100). Page by passing `nextCursor` back until it is `null`. For orders working *at the venue* — including any placed outside the OMS — use `GET /accounts/{id}/order-history?status=open`. It is the only one of the two that reads a `status` filter; `/orders` ignores it silently. :::caution[The SDK's response type is wrong here] SDK 0.3.0 types `accounts.orders` and `accounts.trades` as `Page`, i.e. `{ data, nextCursor, hasMore }`. The routes return `{ orders, nextCursor }` and `{ trades, total, nextCursor }` — read the named array, never `data`. ::: ```bash curl -s "https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/orders?limit=50" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, nextCursor, # orders: [ { orderId, venueOrderId, venue, exchange, symbol, side, # type, status, qty, price, triggerPrice, timeInForce, # reduceOnly, kind, parentIntentId, composedAt } ] } ``` ```ts const page = await sdk.accounts.orders(accountId, { limit: 50 }) as unknown as { orders: { orderId: string; venueOrderId: string | null; status: string }[] nextCursor: string | null } console.log(page.orders.length, page.nextCursor) ``` 4. ### Read closed round-trips `GET /accounts/{id}/trades` aggregates entry and exit into one row per round-trip, with realized P&L and the fee split. `sdk.accounts.trades(accountId)` is the SDK equivalent, with the caveat from step 3. ```bash curl -s "https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/trades?limit=50" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, total, nextCursor, # trades: [ { tradeId, symbol, side, qty, avgEntryPrice, avgClosePrice, # openTime, closeTime, pnlUsd, pnlPct, durationMs, # fees: { total, maker, taker } } ] } ``` **Next:** [Accounts & positions](/docs/trading/accounts/) · [Stream account and position updates](/docs/recipes/stream-account-updates/) · [Create a paper account](/docs/recipes/create-a-paper-account/) --- # Place your first order URL: https://docs.troncharts.xyz/docs/recipes/place-your-first-order/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' Every order is an *intent*: you compose it, the OMS risk-gates it, then it dispatches. Placing on a paper account runs that entire pipeline with no venue key, so you end up with a filled order and a position you can read back. 1. ### Compose the order on paper `venue`, `symbol`, `side`, `type` and `qty` are the only required fields. `type` is one of `market`, `limit`, `stop`, `stop_limit`, `take_profit`. The route branches on the *account kind*, so `venue: "paper"` against a live account is rejected with `paper_venue_requires_paper_account`. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"venue":"paper","symbol":"BTC.HL","side":"buy","type":"market", "qty":"0.01","accountId":"'"$ACCOUNT_ID"'"}' ``` ```ts const { intentId, state, syncFill } = await sdk.oms.composeIntent( { venue: 'paper', symbol: 'BTC.HL', side: 'buy', type: 'market', qty: '0.01', accountId }, crypto.randomUUID(), // idempotency key — retries replay instead of re-firing ) ``` :::note[Paper is not a lower tier] Placing needs a `fullTrading` credential on paper exactly as on live, so a challenge can never be scored on orders the credential could not have placed for real. Provisioning a paper account self-serve is off by default (`SANDBOX_PROVISIONING_ENABLED`) — see [Create a paper account](/docs/recipes/create-a-paper-account/). ::: 2. ### Read the fill off the response The paper engine matches synchronously, so the `201` already carries the outcome. `intentId` is the handle for every call after this one. ```json { "intentId": "3f2a…", "state": "filled", "syncFill": { "qty": "0.01", "avgPrice": "64210.5" } } ``` 3. ### Send the same call to a live venue Swap `venue` to `hyperliquid` or `aster`. Type-specific fields are enforced at compose: `limit` needs `price`, the trigger types need `triggerPrice`, and `stop_limit` needs `stopLimitPrice` on top of that. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"venue":"hyperliquid","symbol":"BTC.HL","side":"buy","type":"limit", "qty":"0.01","price":"60000","timeInForce":"Gtc"}' ``` ```ts const { intentId } = await sdk.oms.composeIntent( { venue: 'hyperliquid', symbol: 'BTC.HL', side: 'buy', type: 'limit', qty: '0.01', price: '60000', timeInForce: 'Gtc' }, crypto.randomUUID(), ) ``` 4. ### Confirm the live intent reached the venue Read the intent back and check `state`. Anything terminal (`filled`, `rejected`, `canceled`) is final; `dispatched` means it is resting at the exchange. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents/$INTENT_ID \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` :::caution[A 201 is not a dispatch] On a live venue the `201` means the intent was composed and risk-gated, not that it reached the exchange. If the account owner has no provisioned venue agent it stays at `intent_pending` — sign `actionToSign` yourself and POST it to `/api/v1/oms/intents/{id}/sign-result`. See [Order lifecycle & signing](/docs/trading/order-lifecycle/). ::: **Next:** [Attach a take-profit and stop-loss](/docs/recipes/attach-a-bracket/) · [Cancel an order or close a position](/docs/recipes/cancel-and-flatten/) · [Orders & OMS](/docs/trading/orders/) --- # Attach a take-profit and stop-loss URL: https://docs.troncharts.xyz/docs/recipes/attach-a-bracket/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' A take-profit and a stop-loss have to behave as one unit — if the survivor outlives the leg that fired, it re-opens the exposure it was supposed to close. This attaches both to an already-open position as reduce-only legs sharing a single OCO group. 1. ### Find the position The server resolves the position from your account plus the `symbol` you send, so that is the only thing you need off this read. No open position on that symbol gives `404 no_position_for_symbol`. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/positions \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { accountId, accountNumber, positions: [ { symbol, venue, side, qty, entryPrice, … } ] } ``` ```ts const { positions } = await sdk.accounts.positions(accountId) const pos = positions.find((p) => p.symbol === 'BTC.HL') ``` 2. ### Attach both legs At least one of `tpPrice` / `slPrice` must be present, or you get `400 no_legs_requested`. Send them as JSON **numbers** — unlike the compose schema, this one does not coerce decimal strings. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/brackets/attach-to-position \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"symbol":"BTC.HL","tpPrice":72000,"slPrice":58500}' ``` ```ts const bracket = await sdk.oms.attachBracketsToPosition( { symbol: 'BTC.HL', tpPrice: 72000, slPrice: 58500 }, crypto.randomUUID(), ) ``` :::caution[Three venues only] Hyperliquid, Aster and paper. Anything else returns `400 venue_unsupported` — Polymarket and Kalshi brackets ship with their own OMS arc. ::: 3. ### Read the OCO group off the response Both legs carry `ocoGroupId` as their `parentIntentId`; that linkage is what makes them one unit. `side` is the *closing* side (a long position gives `sell`), and `qty` is the full position size — each leg covers all of it. ```json { "ok": true, "ocoGroupId": "9c41…", "tpIntentId": "1a2b…", "tpIntentIds": ["1a2b…"], "slIntentId": "7d8e…", "side": "sell", "qty": "0.01" } ``` The legs dispatch immediately — a position already exists to reduce, so there is nothing to wait for. From here the server owns the lifecycle: when one leg fills it cancels the survivor, and when the position closes by any route it cancels both. **Next:** [Split a take-profit across several targets](/docs/recipes/place-a-scale-out-ladder/) · [Cancel an order or close a position](/docs/recipes/cancel-and-flatten/) · [Order lifecycle & signing](/docs/trading/order-lifecycle/) --- # Split a take-profit across several targets URL: https://docs.troncharts.xyz/docs/recipes/place-a-scale-out-ladder/ import { Steps } from '@astrojs/starlight/components' A single take-profit is all-or-nothing. Swapping `tpPrice` for `tpLegs` on the same endpoint gives you up to three rungs — each a reduce-only limit at its own price and size — sharing one OCO group with the stop, so the stop still covers whatever is left. 1. ### Size the rungs against the position You compute the split; the server does not. Read the live size first and divide it — for `0.9 BTC` in thirds, three rungs of `0.3`. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/positions \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { positions: [ { symbol: "BTC.HL", qty: "0.9", … } ] } ``` The rung quantities are read as RATIOS, not absolute sizes. The server re-sizes the ladder against the live position, so a set that sums to more than the position is scaled down rather than over-closing, and a rung the position cannot fund is dropped instead of being sent as a zero. Send `sizedAgainstQty` when your ratios were computed against a different size — without it the ladder is read as covering the whole position. 2. ### Attach the ladder `tpLegs` takes one to three entries, each with a required positive `price` and `qty`. It is mutually exclusive with `tpPrice`. `tag` is optional (`tp1` / `tp2` / `tp3`) and is display metadata only — the OMS does not persist it, so the functional split is the price and qty you send. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/brackets/attach-to-position \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{ "symbol": "BTC.HL", "tpLegs": [ { "price": 68000, "qty": 0.3, "tag": "tp1" }, { "price": 72000, "qty": 0.3, "tag": "tp2" }, { "price": 78000, "qty": 0.3, "tag": "tp3" } ], "slPrice": 58500 }' ``` 3. ### Read the per-leg intent ids `tpIntentIds` is the array you want — one id per rung, in the order you sent them. `tpIntentId` (singular) is back-compat and only ever carries the **first** rung. The response `qty` is the full protected position size, not a per-rung number. ```json { "ok": true, "ocoGroupId": "9c41…", "tpIntentId": "1a2b…", "tpIntentIds": ["1a2b…", "4c5d…", "6e7f…"], "slIntentId": "7d8e…", "side": "sell", "qty": "0.9" } ``` :::note[Rungs re-size themselves] Each leg is stamped with the position size it was sized against (`sizedAgainstQty`). When the position later grows or shrinks the server re-sizes every rung fraction-preserving — a one-third rung stays one-third — instead of inflating it to full cover. ::: :::caution[REST only] The TypeScript SDK's `AttachToPositionBody` declares `symbol`, `tpPrice` and `slPrice` and has no `tpLegs`, and its response type has no `tpIntentIds`. Call this one over REST until the SDK catches up. ::: **Next:** [Attach a take-profit and stop-loss](/docs/recipes/attach-a-bracket/) · [Cancel an order or close a position](/docs/recipes/cancel-and-flatten/) · [Orders & OMS](/docs/trading/orders/) --- # Cancel an order or close a position URL: https://docs.troncharts.xyz/docs/recipes/cancel-and-flatten/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' Three different ways out, three different endpoints. Cancelling kills a resting order, flattening closes what is already open, and reversing closes it and opens the opposite leg. 1. ### Get the venue order id Cancel keys off the **venue** order id, so read it off the order rows first. It is `null` before dispatch and on an order that never dispatched. ```bash curl -s "https://api.troncharts.xyz/api/v1/accounts/$ACCOUNT_ID/orders?symbol=BTC.HL" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { orders: [ { orderId, venueOrderId, symbol, type, state, … } ] } ``` 2. ### Cancel the working order `POST /oms/cancel` composes a cancel intent against the resolved parent and returns `201 { intentId }` — the id of the *cancel*, not of the order you killed. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/cancel \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"venueOrderId":"'"$VENUE_ORDER_ID"'"}' ``` ```ts await sdk.oms.cancel(venueOrderId, crypto.randomUUID()) ``` :::caution[venueOrderId, not intentId] `{ venueOrderId }` is the entire schema — sending `intentId` fails with `invalid_body` every time. The route resolves the parent intent from the venue id itself; `modify` is the endpoint that takes an intent id. ::: 3. ### Flatten the position Both fields are optional and independent: `symbol` narrows to one market, `accountId` targets an owned account other than the token's own. An empty body acts on the whole scope. Flatten cancels the symbol's working orders *first*, then closes — so a resting order can't fire against a now-flat account. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/flatten \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"symbol":"BTC.HL"}' # → { ok, cancelIntentIds, closeIntentId, alreadyFlat } ``` ```ts await sdk.oms.flatten({ symbol: 'BTC.HL' }) ``` :::note[The SDK's flatten response type is wrong] `flatten()` is typed as resolving `{ intentIds }`, a key the route does not return. Read `cancelIntentIds` and `closeIntentId` off the JSON until the type is corrected. ::: 4. ### Reverse instead of closing Same body, opposite intent: `/oms/reverse` closes the position and opens the other side. `openIntentId` comes back `null` with `reverseOpenDeferred: true` — the open leg is composed at the *realized* close size once the close terminalizes, so it can never oversize the flip. It surfaces on the `/ws/risk` Position-Update frame. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/reverse \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"symbol":"BTC.HL"}' ``` ```ts const { closeIntentId, reverseOpenDeferred } = await sdk.oms.reverse({ symbol: 'BTC.HL' }) ``` Cancel and flatten only reduce exposure, so a `liquidation` credential can make them. Reverse re-establishes it and needs `fullTrading`. See [Scopes & tiers](/docs/auth/scopes/). **Next:** [Place your first order](/docs/recipes/place-your-first-order/) · [Stream account and position updates](/docs/recipes/stream-account-updates/) · [Accounts & positions](/docs/trading/accounts/) --- # Stream account and position updates URL: https://docs.troncharts.xyz/docs/recipes/stream-account-updates/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' Polling `/api/v1/accounts/{id}/state` gives you a number that is already stale by the time you read it. `/ws/risk` pushes the same state as it changes — one socket carries balance, positions, order lifecycle and fills for the account behind your credential. 1. ### Mint a handshake token `POST /api/auth/bootstrap` with the bearer you already hold and no body at all. The socket URL and its token are under `data.riskEngineWss`. ```bash curl -sX POST https://api.troncharts.xyz/api/auth/bootstrap \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" | jq '.data.riskEngineWss' # { "endpoint": "wss://api.troncharts.xyz/ws/risk", "token": "rt_…", "expiresAt": 1785196800000 } ``` ```ts const { data } = await sdk.auth.bootstrap() const { endpoint, token } = data.riskEngineWss ``` Bootstrap is tenant-scoped like every `/api/v1` call — without `x-tenant-slug` it answers `404 unknown_tenant_origin`. The token lives 60 seconds, the socket that uses it burns it, and the mint is capped at 10 per minute per API key. 2. ### Authenticate as the very first frame `Authenticate` is the only frame accepted before authentication. On success the account is at `scope.accountId` — nested, not top-level. On `Result { ok: false, error: "token_invalid" }` the socket stays open, so retry with a fresh token instead of reconnecting. ```json { "type": "Authenticate", "token": "rt_9f3c…" } ``` ```json { "type": "Authenticated", "ok": true, "scope": { "sessionId": "apiclient:…", "accountId": "8f1c…", "walletAddress": null, "isAdmin": false } } ``` ```ts import { RiskEngineClient } from '@tronchartsxyz/api-client' const ws = new RiskEngineClient({ url: endpoint, token, // burned by this connect onFrame: (frame) => console.log(frame.type), }) await ws.connect() // sends Authenticate, resolves on Authenticated ``` :::caution[Mint a fresh token for every connect] `RiskEngineClient` reconnects with the same `token` it was constructed with, and bootstrap tokens are single-use — every automatic reconnect fails `token_invalid`. Bootstrap again and build a new client rather than relying on the built-in reconnect. ::: 3. ### Subscribe, then ask for the snapshots `topics` is a fixed enum of topic names — never symbols — and must be non-empty. Subscribing snapshots `Account-State-Changed` only: positions and working orders that already exist arrive solely in reply to `Get-Position-Update` (a `Position-Snapshot` frame) and `Get-open-orders` (an `Open-Orders-Update`). Skip those two and the stream looks empty on an account holding positions. ```json { "type": "Subscribe", "topics": ["Account-State-Changed", "Position-Changed", "Balance-Changed"] } { "type": "Get-Position-Update" } { "type": "Get-open-orders" } ``` ```ts ws.send({ type: 'Subscribe', topics: ['Account-State-Changed', 'Position-Changed', 'Balance-Changed'] }) ws.send({ type: 'Get-Position-Update' }) ws.send({ type: 'Get-open-orders' }) ``` 4. ### Hold the socket open Send `Ping` every 30 seconds for a `Pong { serverTime }`; `Ping` and `Alive` are the only frames exempt from the per-tier frame rate limit. Every other frame carries a per-socket monotonic `seq` — on a forward jump send `Resume { lastSeq }` to replay the ring buffer, and on `Resync-Required` re-send the two `Get-*` frames above. The SDK client does the heartbeat, gap detection and `Resume` for you. If your credential is `strict-single`, a second authenticated socket closes the first with code `1008 replaced_by_new_session`. **Next:** [Follow an order from placement to fill](/docs/recipes/track-an-order/) · [Risk channel](/docs/realtime/risk/) · [Connecting](/docs/realtime/connecting/) --- # Stream depth and the trade tape URL: https://docs.troncharts.xyz/docs/recipes/stream-market-data/ import { Steps } from '@astrojs/starlight/components' `/ws/market` is the public half of the platform: no bearer, no handshake token, no tenant header. Open it, send two subscribe frames, and you have the live book and every print for a symbol on one connection. 1. ### Open the socket Nothing precedes the first subscribe. The TypeScript SDK ships no market-data client, so drive this channel with any WebSocket implementation. Append `?encoding=msgpack` to switch server frames to MessagePack — on a busy book it is the cheapest win available. ```js const ws = new WebSocket('wss://api.troncharts.xyz/ws/market') ws.addEventListener('message', (e) => handle(JSON.parse(e.data))) ws.addEventListener('open', () => { /* subscribe here */ }) ``` :::note[No credential, and no `x-tenant-slug`] The `/ws/market` upgrade short-circuits before the tenant middleware, so the header every `/api/v1` call needs is neither read nor required here. An `Authenticate` frame is accepted but only attaches a scope that is echoed back — subscribe frames are never gated on it. ::: 2. ### Subscribe to the book `venue` and `symbol` are separate fields; there is no `venue:symbol` string on the wire. You get `Result { ok: true }`, and a `Depth-Update` snapshot immediately after it when the server's cache is already warm. Depth serves `hyperliquid`, `aster`, `lighter`, `polymarket`, `kalshi`, `massive` and `b3`. ```json { "type": "Subscribe-Depth", "venue": "hyperliquid", "symbol": "BTC.HL" } ``` Every level is `{ px, sz }` as decimal strings, plus `ct` — the resting-order count — on the venues that expose one. ```json { "type": "Depth-Update", "venue": "hyperliquid", "symbol": "BTC.HL", "bids": [{ "px": "64210.0", "sz": "1.842" }], "asks": [{ "px": "64211.5", "sz": "0.311" }], "source": "be_canonical", "ts": "2026-08-04T12:00:00.000Z" } ``` 3. ### Subscribe to the tape The tape's venue set is narrower than depth's — `hyperliquid`, `aster`, `massive`, `b3`, the four with a trade-print producer. Asking for any other venue is rejected as `invalid_frame` rather than accepted and left silently blank. ```json { "type": "Subscribe-Tape", "venue": "hyperliquid", "symbol": "BTC.HL" } ``` Handle both wire shapes. Prints are batched into a `trades` array — one frame per flush window, not per print, because B3 alone was measured at 612 prints per second — while the legacy flattened single-print frame is still part of the contract. ```js function onTape(f) { const prints = 'trades' in f ? f.trades : [f] for (const p of prints) console.log(p.side, p.px, p.sz, p.ts) } ``` 4. ### Add the channels you need The same socket multiplexes `Subscribe-Depth-L3` for the per-order book (`lighter` and `b3` only) → `Order-Book-Update`, `Subscribe-BBO` → `BBO-Update`, `Subscribe-VAP` → `VAP-Update`, and `Subscribe-Candles` with an `interval` written as a count plus `s`, `m`, `h`, `d`, `w` or `M` (`1m`, `4h`, `1d`) → `Candle-Update`. Each has a matching `Unsubscribe-*`, `Unsubscribe-All` drops everything, and `Ping` answers `Pong`. Past the per-socket subscription cap you get `Result { ok: false, error: "subscription_limit" }`. **Next:** [Market channel](/docs/realtime/market/) · [Market data](/docs/trading/market-data/) · [Stream account and position updates](/docs/recipes/stream-account-updates/) --- # Follow an order from placement to fill URL: https://docs.troncharts.xyz/docs/recipes/track-an-order/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' `POST /api/v1/oms/intents` answers `201 { intentId }` and says nothing about execution — the verdict streams over `/ws/risk`. Subscribe before you place and you watch the order composed, dispatched, and filled without polling anything. 1. ### Subscribe to the order topics first On an authenticated socket — see [Stream account and position updates](/docs/recipes/stream-account-updates/) for the handshake. Four topics cover the whole lifecycle, and subscribing before you place is what makes it real-time. ```json { "type": "Subscribe", "topics": ["Order-Intent-Changed", "Order-Changed", "Fill", "Trade-Changed"] } ``` ```ts import type { RiskTopic } from '@tronchartsxyz/api-client' // 'Fill' is live on the server but has not landed in the SDK's RiskTopic union yet. const topics = ['Order-Intent-Changed', 'Order-Changed', 'Trade-Changed', 'Fill'] as RiskTopic[] ws.send({ type: 'Subscribe', topics }) ``` 2. ### Place the order Send `clientOrderId` — your own correlation id, echoed back on the `201` and on the `Order-Intent-Update` and `Open-Orders-Update` frames, so you can adopt the order before its intent id reaches you. `Idempotency-Key` (8–200 characters) makes a retry replay the cached response instead of placing twice. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -H "idempotency-key: $(uuidgen)" \ -d '{"venue":"paper","symbol":"BTC.HL","side":"buy","type":"limit","qty":"0.01", "price":"60000","clientOrderId":"my-order-1","accountId":"'"$ACCOUNT_ID"'"}' ``` ```ts const { intentId } = await sdk.oms.composeIntent( { venue: 'paper', symbol: 'BTC.HL', side: 'buy', type: 'limit', qty: '0.01', price: '60000', clientOrderId: 'my-order-1', accountId }, crypto.randomUUID(), ) ``` 3. ### Read the lifecycle off the frames `Order-Intent` lands first, carrying the composed `actionToSign`. `Order-Intent-Update` follows on every transition — `intent_pending`, `signed_pending_dispatch`, `dispatched`, `acknowledged`, `partially_filled`, `filled`, and the terminal `canceled` / `rejected` / `expired` — and binds `venueOrderId` once the venue answers. `Order-Update` restates the same thing in the external vocabulary, keyed on the intent id: ```json { "type": "Order-Update", "orderId": "3f2a…", "venueOrderId": "88214417", "venue": "paper", "symbol": "BTC.HL", "side": "long", "orderType": "limit", "status": "partially_filled", "qty": "0.01", "filledQty": "0.004", "price": "60000", "updatedAt": "2026-08-04T12:00:00.000Z" } ``` `Fill` fires once per execution and is never coalesced, so its `qty` and `price` are that execution's own and summing them is safe. `Trade-Update` fires only on a closed round trip — it never fires on an entry. :::note[Live venues need more than a tier] A `fullTrading` credential is enough on paper. On `hyperliquid` or `aster` the bearer path also requires a linked agent wallet, or the call is rejected `credential_missing_agent_wallet` before any frame is emitted. See [Order lifecycle & signing](/docs/trading/order-lifecycle/). ::: 4. ### Reconcile over REST when you need to `GET /api/v1/oms/intents/{id}` is the authoritative read, scoped to the credential's own account — an intent belonging to another account is `404`. That includes one you placed with an explicit `accountId` for a paper child, so read it back with the credential that owns that account. `GET /api/v1/oms/intents` returns the last 50. Discovery advertises `GET /api/v1/orders/{id}`. That path is not mounted; use the OMS one. ```bash curl -s https://api.troncharts.xyz/api/v1/oms/intents/$INTENT_ID \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` **Next:** [Place your first order](/docs/recipes/place-your-first-order/) · [Order lifecycle & signing](/docs/trading/order-lifecycle/) · [Risk channel](/docs/realtime/risk/) --- # Create a prop firm challenge template URL: https://docs.troncharts.xyz/docs/recipes/create-a-challenge-template/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' A template is what a trader actually buys: an account size, an entry fee, a payout split, and the rule set the engine judges the run against. When this is done you have an `active` template that can be sold as a [paper prop account](/docs/recipes/sell-a-paper-challenge/) immediately. 1. ### Collect the firm and trading group ids A template belongs to one firm (`propTenantId`) and allocates to one trading group (`tradingGroupId`). Both are required on create. ```bash curl -s https://api.troncharts.xyz/api/v1/trading-groups \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { "groups": [ { "id": "…", "slug": "crypto-perps", "isDefault": true, … } ] } ``` ```ts const { groups } = await sdk.tradingGroups.list() const { firms } = await sdk.firms.list() // needs firm:operate ``` :::note[Two different scopes] `GET /api/v1/firms` needs `firm:operate`, while everything else here needs `prop:manage`. With only `prop:manage`, read `propTenantId` off any row from `GET /api/v1/prop-templates` — creating a firm seeds starter templates. ::: 2. ### Write the rule set `rules` is a discriminated union keyed on `type`, one to 32 entries of `{ type, value }`. An unknown `type` fails the body parse with a 400 `invalid_body`, so pick from the catalog rather than inventing a field. ```json [ { "type": "max_drawdown_usd", "value": 1250 }, { "type": "profit_target_usd", "value": 2000 }, { "type": "min_trading_days", "value": 3 } ] ``` `_usd` rules take a plain number in the template's currency. Every `_pct` rule takes a 0..1 fraction — `max_concentration_pct: 0.3` is 30%. The template is the only place you write these. The server materializes a **managed** risk profile holding them and binds it, so you never create or name a profile for a single program — see [How an account is configured](/docs/launch/account-configuration/). 3. ### Create the template `id` is yours to choose and must be lower-kebab-case. `accountSizeUsd`, `feeUsd` and `payoutSplitPct` are decimal **strings**, not numbers. ```bash curl -s https://api.troncharts.xyz/api/v1/prop-templates \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "id": "acme-25k-two-step", "name": "Acme 25K Evaluation", "accountSizeUsd": "25000.00", "feeUsd": "199.00", "payoutSplitPct": "80", "durationDays": 30, "propTenantId": "'"$FIRM_ID"'", "tradingGroupId": "'"$GROUP_ID"'", "rules": [ { "type": "max_drawdown_usd", "value": 1250 }, { "type": "profit_target_usd", "value": 2000 } ] }' # → 201 { "template": { "id": "acme-25k-two-step", "active": true, … } } ``` ```ts const { template } = await sdk.propTemplates.create({ id: 'acme-25k-two-step', name: 'Acme 25K Evaluation', accountSizeUsd: '25000.00', feeUsd: '199.00', payoutSplitPct: '80', propTenantId: firmId, tradingGroupId: groupId, rules: [ { type: 'max_drawdown_usd', value: 1250 }, { type: 'profit_target_usd', value: 2000 }, ], }) ``` :::caution[`propTenantId` is optional in the schema, required in practice] A provider credential is never super-scoped, so omitting `propTenantId` returns 403 `forbidden`. Omitting `tradingGroupId` returns 400 `trading_group_required`. ::: 4. ### Confirm it is sellable The response carries `active: true`. Re-read the catalogue to see it beside the rest, then hand `id` and `propTenantId` to `create-prop`. ```bash curl -s "https://api.troncharts.xyz/api/v1/prop-templates?activeOnly=true" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ```ts const { templates } = await sdk.propTemplates.list({ activeOnly: true }) ``` ## Sharing one rule set across templates Instead of `rules`, send `riskProfileId` to point several templates at one authored profile — useful when three account sizes share the same limits. The profile's rules are copied down into the template, so the snapshot each account freezes still matches exactly. ```ts const { profile } = await sdk.riskProfiles.create({ name: 'Acme 1-step', kind: 'challenge', rules: [{ type: 'max_drawdown_usd', value: 1250 }], }) await sdk.propTemplates.create({ /* … */ riskProfileId: profile.id }) ``` :::caution[`rules` and `riskProfileId` are mutually exclusive] Sending both returns 400 `rules_and_profile_conflict` — a template has one source of truth for its rules. While a template reads a shared profile, patching `rules` returns 400 `rules_locked_by_profile`; send `riskProfileId: null` to move the rules back onto the template. Other rejections: 400 `risk_profile_not_found` (not in your tenant), 400 `invalid_profile_kind` (must be `challenge` or `funded`), 400 `profile_is_managed` (that profile belongs to another template), 400 `profile_rules_incompatible` (a template needs 1..32 rules). ::: Other optional fields worth knowing: `programKind` (`challenge` or `flash`), and either `nextStepTemplateId` or `tierLadder` — never both. **Next:** [Sell a paper prop account](/docs/recipes/sell-a-paper-challenge/) · [Create a trading group](/docs/recipes/create-a-trading-group/) · [Prop account model](/docs/launch/prop-accounts/) --- # Create a risk profile and bind it to an account URL: https://docs.troncharts.xyz/docs/recipes/create-a-risk-profile/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' A risk profile is a named rule set you write once and attach to many accounts. When this is done, one account carries `riskProfileId` and the order-time gate reads that profile on every compose. :::note[For a single prop program, you don't need this page] Write the rules on the challenge template instead — the server materializes and maintains a **managed** profile for you. Author a profile here only to give a **non-prop** account limits, or to share one rule set across several templates. See [How an account is configured](/docs/launch/account-configuration/). Managed profiles appear in the list with `managedByTemplateId` set and are read-only: `PATCH` and `archive` return 409 `managed_by_template`, naming the template to edit instead. Filter on `!managedByTemplateId` to see only the profiles you author. ::: 1. ### Lint the rule set `POST /api/v1/risk-profiles/validate` parses a rule array and returns a verdict. It never touches the database and always answers 200, so it is the cheapest way to check a rule shape before you commit to it. ```bash curl -s https://api.troncharts.xyz/api/v1/risk-profiles/validate \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"kind":"trader","rules":[{"type":"max_leverage","value":20}]}' # → { "valid": true, "ruleCount": 1 } ``` ```ts const verdict = await sdk.riskProfiles.validate( [{ type: 'max_leverage', value: 20 }], 'trader', ) ``` 2. ### Create the profile `name` and `kind` are the only required fields. `kind` is one of `trader`, `challenge` or `funded` — a prop template can bind only the latter two. `enforcement` is `off`, `soft` or `hard`; `rules` holds up to 64 entries. ```bash curl -s https://api.troncharts.xyz/api/v1/risk-profiles \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "name": "Retail default", "kind": "trader", "enforcement": "hard", "rules": [ { "type": "max_leverage", "value": 20 }, { "type": "max_concentration_pct", "value": 0.3 } ] }' # → 201 { "profile": { "id": "…", "kind": "trader", … } } ``` ```ts const { profile } = await sdk.riskProfiles.create({ name: 'Retail default', kind: 'trader', enforcement: 'hard', rules: [ { type: 'max_leverage', value: 20 }, { type: 'max_concentration_pct', value: 0.3 }, ], }) ``` 3. ### Bind it to an account Binding writes `accounts.risk_profile_id` and busts the resolver cache, so the next order composes against the new rules. ```bash curl -s https://api.troncharts.xyz/api/v1/risk-profiles/$PROFILE_ID/bind \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"accountId":"'"$ACCOUNT_UUID"'"}' # → { "ok": true, "accountId": "…", "riskProfileId": "…" } ``` ```ts await sdk.riskProfiles.bind(profile.id, accountUuid) ``` :::caution[`accountId` is the UUID, not the account number] The bind matches on `accounts.id`. Passing a human account number such as `100042` returns 404 `account_not_found`, even though the account reads fine at `GET /api/v1/accounts/100042`. ::: 4. ### Swap or remove the binding An account holds exactly one profile. Binding a second profile replaces the first; binding the literal id `none` clears it. ```bash curl -s https://api.troncharts.xyz/api/v1/risk-profiles/none/bind \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"accountId":"'"$ACCOUNT_UUID"'"}' ``` ```ts await sdk.riskProfiles.unbind(accountUuid) ``` :::note[`trader` profiles are advisory in production today] Risk-profile enforcement ships off (`ENFORCE_RISK_PROFILES` defaults `false`), so a bound `trader` profile annotates rather than blocks. Challenge and funded programs are unaffected: a prop account is judged by the rule snapshot on `prop_accounts`, which is always enforced. ::: **Next:** [Create a challenge template](/docs/recipes/create-a-challenge-template/) · [Create a trading group](/docs/recipes/create-a-trading-group/) · [Scopes & tiers](/docs/auth/scopes/) --- # Create a trading group and add an account URL: https://docs.troncharts.xyz/docs/recipes/create-a-trading-group/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' A trading group is where an account's fees, leverage cap and venue scope come from at compose time. When this is done you have a named group and at least one account resolving its trading economics through it. 1. ### Create the group `slug` and `name` are the only required fields. The slug is kebab-case, up to 32 characters, and unique per tenant — a collision returns 409 `slug_taken`. ```bash curl -s https://api.troncharts.xyz/api/v1/trading-groups \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"slug":"crypto-perps","name":"Crypto Perps","description":"HL + Aster desk"}' # → 201 { "group": { "id": "…", "slug": "crypto-perps", "isDefault": false, … } } ``` ```ts const { group } = await sdk.tradingGroups.create({ slug: 'crypto-perps', name: 'Crypto Perps', description: 'HL + Aster desk', }) ``` 2. ### Set fees, leverage and scope Everything economic lives under `config`. A PATCH replaces the whole object rather than merging, so send the full config each time. ```bash curl -s -X PATCH https://api.troncharts.xyz/api/v1/trading-groups/$GROUP_ID \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "config": { "fees": { "defaultMakerBps": 2, "defaultTakerBps": 5 }, "leverage": { "defaultCap": 20 }, "venuesAllowlist": ["hyperliquid", "aster"], "riskLimits": { "dailyLossCapUsd": 5000 } } }' ``` ```ts await sdk.tradingGroups.update(group.id, { config: { fees: { defaultMakerBps: 2, defaultTakerBps: 5 }, leverage: { defaultCap: 20 }, venuesAllowlist: ['hyperliquid', 'aster'], riskLimits: { dailyLossCapUsd: 5000 }, }, }) ``` :::caution[`config` is a strict schema] Any key outside the catalogue — including a typo like `venueAllowlist` — fails the whole request with 400 `invalid_body`. The valid keys are `venuesAllowlist`, `symbolsAllowlist`, `fees`, `leverage`, `maxContractsPerOrder`, `riskLimits`, `perSymbol`, `perVenue`, `skew`, `bands` and `bridgeMarkupBps`. ::: 3. ### Add an account Binding writes `accounts.trading_group_id`, which is what compose reads for fees, leverage and scope on the next order. ```bash curl -s https://api.troncharts.xyz/api/v1/trading-groups/$GROUP_ID/bind \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{"accountId":"'"$ACCOUNT_UUID"'"}' # → { "ok": true, "accountId": "…", "tradingGroupId": "…" } ``` ```ts await sdk.tradingGroups.bind(group.id, accountUuid) ``` `accountId` is the account UUID. A human account number such as `100042` returns 404 `account_not_found`. Bind the literal id `none` to release an account back to the tenant default group. 4. ### Make it the tenant default The default group is what every unbound account inherits, including accounts created later. The flip is atomic within your tenant. ```bash curl -s -X POST https://api.troncharts.xyz/api/v1/trading-groups/$GROUP_ID/make-default \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ```ts await sdk.tradingGroups.makeDefault(group.id) ``` :::note[Allowlists are advisory in production today] `ENFORCE_TRADING_GROUP_ALLOWLIST` defaults `false`, so a populated `venuesAllowlist` or `symbolsAllowlist` annotates rather than blocks an order. Fees, leverage caps and risk limits apply regardless. ::: **Next:** [Create a challenge template](/docs/recipes/create-a-challenge-template/) · [Create a risk profile](/docs/recipes/create-a-risk-profile/) · [Orders & OMS](/docs/trading/orders/) --- # Sell a paper prop account from a template URL: https://docs.troncharts.xyz/docs/recipes/sell-a-paper-challenge/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' `create-prop` provisions the account, the prop account and the paper trading state in one transaction. When this is done a trader has an `active` evaluation they can place orders against immediately — no agreement step, no deposit. 1. ### Pick the template You need two ids from the same row: the template `id` and the firm that owns it, `propTenantId`. Only `active` templates can be sold. ```bash curl -s "https://api.troncharts.xyz/api/v1/prop-templates?activeOnly=true" \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" # → { "templates": [ { "id": "acme-25k-two-step", "propTenantId": "…", … } ] } ``` ```ts const { templates } = await sdk.propTemplates.list({ activeOnly: true }) ``` 2. ### Identify the owner user A bearer credential has no user session, so it must name the user the new account belongs to. `ownerUserId` must be a user in your tenant — omit it and you get 400 `owner_user_required`; pass a stranger's id and you get 404 `owner_user_not_found`. :::note[There is no public endpoint that creates a user] Users arrive through your tenant's own sign-up flow or through an operator in the admin console. Read the id from the user record you already hold — this recipe assumes one exists. ::: 3. ### Create the account `propTenantId` must be a UUID (the firm id from step 1). `baselineUsd` seeds the simulated cash balance and defaults to `10000` — it is separate from the template's `accountSizeUsd`, which is what the rules judge. Pass it explicitly if you want the two to match. ```bash curl -s https://api.troncharts.xyz/api/v1/accounts/create-prop \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H 'content-type: application/json' \ -d '{ "templateId": "acme-25k-two-step", "propTenantId": "'"$FIRM_ID"'", "mode": "paper", "ownerUserId": "'"$USER_ID"'", "displayName": "Acme 25K — run 1" }' # → 201 { "mode": "paper", "accountId": "…", "accountNumber": "100042", # "propAccount": { "status": "active", "startedAt": "…", … } } ``` ```ts const res = await sdk.accounts.createProp({ templateId: 'acme-25k-two-step', propTenantId: firmId, mode: 'paper', ownerUserId, }) as { accountId: string; accountNumber: string } ``` The prop account lands `status: 'active'` with `startedAt` set. Rejections to expect: 404 `template_not_found`, 400 `template_inactive`, and 400 `template_mode_unsupported` when the template is marked live-only. 4. ### Reset the run when you need a clean slate Paper accounts are resettable: balance and positions go back to the account size, breach state clears, and the evaluation window restarts at its original length. Live and funded accounts reject with 400 `not_paper`. ```bash curl -s -X POST https://api.troncharts.xyz/api/v1/prop-accounts/$PROP_ACCOUNT_ID/reset \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" ``` ```ts await sdk.propAccounts.reset(propAccountId) ``` :::caution[`mode: "live"` is not an API-only flow] A live sale binds a wallet and lands `pending_payment`; a bearer credential has no wallet, so it returns `wallet_required_for_live`. The account only reaches `active` when the trader's on-chain USDC deposit emits `ChallengeDeposited` — a signed transaction, not a call you can make here. ::: :::note[Want a throwaway account with no firm or template?] `POST /api/v1/sandbox/paper-account` provisions the whole chain in one call, but it ships disabled (`SANDBOX_PROVISIONING_ENABLED` defaults `false`) and 404s until an operator enables it on your deploy. ::: **Next:** [Create a challenge template](/docs/recipes/create-a-challenge-template/) · [Prop account model](/docs/launch/prop-accounts/) · [Launch a prop firm](/docs/launch/prop-firm/) --- # Connect the MCP server to your dev tool URL: https://docs.troncharts.xyz/docs/recipes/connect-the-mcp-server/ import { Steps, Tabs, TabItem } from '@astrojs/starlight/components' The MCP server turns the provider API into tools your assistant can call, so it places an order or reads a firm's readiness by invoking `place_order` rather than reconstructing an HTTP request. Your API key stays in the server's environment — it is never pasted into a chat. 1. ### Get a credential The server authenticates with an API key and secret, the same pair the [token recipe](/docs/recipes/mint-a-token/) uses. It mints and refreshes its own bearer, so you do not hand it a token. ```bash # you will paste these into the config below echo "$TRON_CHARTS_API_KEY" "$TRON_CHARTS_API_SECRET" ``` 2. ### Register the server with your client Every MCP client takes the same three things: a command to run, its arguments, and the environment. `npx -y` fetches the published package, so there is nothing to install first. ```bash claude mcp add \ --env TRON_CHARTS_API_KEY=your-api-key \ --env TRON_CHARTS_API_SECRET=your-api-secret \ --env TRON_CHARTS_BASE_URL=https://api.troncharts.xyz \ --transport stdio troncharts \ -- npx -y @tronchartsxyz/mcp-server ``` The `--` matters: everything before it is Claude Code's own options, and everything after is the command it runs untouched. ```json { "mcpServers": { "troncharts": { "command": "npx", "args": ["-y", "@tronchartsxyz/mcp-server"], "env": { "TRON_CHARTS_API_KEY": "your-api-key", "TRON_CHARTS_API_SECRET": "your-api-secret", "TRON_CHARTS_BASE_URL": "https://api.troncharts.xyz" } } } } ``` ```json { "mcpServers": { "troncharts": { "command": "npx", "args": ["-y", "@tronchartsxyz/mcp-server"], "env": { "TRON_CHARTS_API_KEY": "your-api-key", "TRON_CHARTS_API_SECRET": "your-api-secret", "TRON_CHARTS_BASE_URL": "https://api.troncharts.xyz" } } } } ``` :::caution[The credential decides which tools exist at all] Tools are filtered by tier when the server starts, not when you call them. A `readonly` key registers only the read tools — `place_order` and the rest are **absent from the list**, not present-and-failing. Raising the tier means restarting the server, because the tool set is resolved once at boot. If the credential also carries an `enabledTools` allowlist, that narrows the list further. Give the server the narrowest credential that covers the job. ::: 3. ### Confirm it connected Restart the client, then ask it to read something harmless. `get_agent_context` reports which account and tier the server resolved, which is the fastest way to tell a working credential from a typo. ```text Use the troncharts MCP server: call get_agent_context and tell me which account and tier it resolved. ``` A connected server answers with your account id and tier. If no tools are offered at all, suspect the credential first. The server mints a token before it registers anything, so a bad key/secret makes it exit with `[tron-charts-mcp] fatal: invalid_credentials` — the client sees a process that died, not an error from a running server. A wrong command path looks identical from the client's side, so check the server's stderr to tell the two apart. If *some* tools are missing rather than all of them, that is the tier doing its job, not a fault. 4. ### Put it to work From here the assistant calls tools directly. Reads are the safe place to start: ```text Get the orderbook for BTC.HL, then show me my open positions. ``` Writes go through the same tiering as the REST surface — `place_order`, `cancel_order`, `flatten_position`, `attach_brackets`, `create_firm`, `approve_payout` and the rest. ## Hosting it over HTTP Default transport is stdio, which is what every client config above expects. To run the server as a shared process instead, set `TRON_CHARTS_MCP_TRANSPORT=http` along with `TRON_CHARTS_MCP_PORT`, `TRON_CHARTS_MCP_HOST` and `TRON_CHARTS_MCP_TOKEN`. Clients then connect over Streamable HTTP rather than spawning their own copy. **Next:** [MCP server reference](/docs/sdks/mcp/) · [Build with an AI agent](/docs/sdks/ai-agents/) · [Mint a token](/docs/recipes/mint-a-token/) --- # Brand a tenant URL: https://docs.troncharts.xyz/docs/recipes/brand-a-tenant/ import { Steps } from '@astrojs/starlight/components' A tenant is one white-label deployment, and everything cosmetic about it — name, logos, colors, which surfaces exist — is config you can write from your own tooling. By the end you have a tenant that renders as your brand instead of the default one. These three writes need a credential carrying the [`tenant:config` scope](/docs/auth/scopes/); an operator grants it on the credential, and a cookie session cannot stand in for it. 1. ### Read what is set today `GET /api/v1/tenants/me` returns the whole config in one round-trip — `branding`, `theme`, `flags`, `venues` and `plan`. The path id must be `me` or your own tenant id; anything else answers `403 cross_tenant_forbidden`. ```bash curl -s https://api.troncharts.xyz/api/v1/tenants/me \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ | jq '{branding, theme, flags}' ``` 2. ### Set the name and logos `brandName` is 1–64 characters. The three URL fields are validated as absolute URLs, so a relative path like `/logo.svg` is rejected with `400 invalid_body`. Ship two logo variants and the chrome swaps between them per color mode. ```bash curl -s -X PATCH https://api.troncharts.xyz/api/v1/tenants/me/branding \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "content-type: application/json" \ -d '{ "brandName": "Northwind Markets", "logoUrl": "https://cdn.northwind.xyz/logo.svg", "logoDarkUrl": "https://cdn.northwind.xyz/logo-dark.svg", "faviconUrl": "https://cdn.northwind.xyz/favicon.png" }' ``` :::caution Each PATCH replaces its whole section rather than merging into it. Omitting `faviconUrl` here clears the stored favicon, so read the section first and send the full object back with your edits applied. ::: 3. ### Apply your theme tokens Every field is optional. The colors (`bg0`, `panel`, `border`, `text`, `up`, `dn`, `primary` and the rest) take any CSS color string up to 64 characters, `radius` is an integer 0–24, and `presetId` selects a full palette that the individual tokens then layer on top of. Font stacks are applied verbatim — loading the family is your job. ```bash curl -s -X PATCH https://api.troncharts.xyz/api/v1/tenants/me/theme \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "content-type: application/json" \ -d '{ "primary": "#4f7cff", "bg0": "#0b0d10", "up": "#22c55e", "dn": "#ef4444", "radius": 8, "fontSans": "Inter, system-ui, sans-serif" }' ``` 4. ### Choose which surfaces exist Flags are a flat map of key to boolean, and the response echoes the stored map back. Anything you leave out falls back to the platform default rather than staying at its previous value. ```bash curl -s -X PATCH https://api.troncharts.xyz/api/v1/tenants/me/flags \ -H "authorization: Bearer $TOKEN" \ -H "x-tenant-slug: $TC_TENANT_SLUG" \ -H "content-type: application/json" \ -d '{"journal": true, "rewards": true, "news": true, "predictions": false}' ``` :::note Flipping a regulated or money-moving flag from `false` to `true` — Polymarket, Kalshi, Lighter, HIP-4, copy-trading fees — answers `403 compliance_flag_forbidden` over the API no matter which scopes you hold. Turning one off, or re-saving one already on, goes through. ::: Every write is audited against `provider:`, and `venues` is deliberately read-only — enable a venue from the console. **Next:** [Scopes](/docs/auth/scopes/) · [Tenancy](/docs/auth/tenancy/) · [Launch a DEX](/docs/launch/dex/) --- # TypeScript SDK URL: https://docs.troncharts.xyz/docs/sdks/typescript/ `@tronchartsxyz/api-client` wraps the REST surface in typed resources and ships a WebSocket client for `/ws/risk`. ## Install ```bash npm install @tronchartsxyz/api-client ``` Ships as ESM with bundled type declarations; Node 20 or newer. The package has no runtime dependencies. The REST surface and the [OpenAPI spec](/docs/reference/specs/) remain the stable contract — the SDK is a convenience over them, not a replacement, and anything it does not cover you can call directly. ## Instantiate ```ts import { TronCharts } from '@tronchartsxyz/api-client' const sdk = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token: process.env.TRON_CHARTS_TOKEN!, tenantSlug: 'acme', // required unless you call from a registered origin — see Tenancy }) ``` `tenantSlug` is optional in the type signature only. The SDK sends it as `X-Tenant-Slug` when present, and every `/api/v1/*` call needs either that header or an origin registered on your tenant — otherwise the request 404s with `unknown_tenant_origin` before your token is read. See [Tenancy](/docs/auth/tenancy/). Mint the token first. There is no unauthenticated mode — `token` is required by the constructor — but `auth.apiToken` authenticates from the body, so the minting client can pass an empty string: ```ts const minter = new TronCharts({ baseUrl: 'https://api.troncharts.xyz', token: '' }) const { token } = await minter.auth.apiToken({ apiKey, apiSecret }) ``` The mint path is one of the few exempt from the tenant gate, so the minting client needs no `tenantSlug` either. Every client you build *after* it does. ## Resources | Resource | Covers | | --- | --- | | `sdk.auth` | Bootstrap tokens, bearer minting. | | `sdk.accounts` | State, positions, orders, trades, lifecycle. | | `sdk.oms` | Compose, cancel, modify, brackets. | | `sdk.market` | Quotes, depth (flat + grouped), FX. | | `sdk.reports` | Historical reconciliation reads. | | `sdk.sor` | Routing decisions and savings summary. | | `sdk.venues` | Venue capability discovery. | | `sdk.propAccounts`, `sdk.propTemplates`, `sdk.riskProfiles`, `sdk.tradingGroups` | The prop surface. | | `sdk.firms` | Firm lifecycle — create, readiness, deploy, status, payouts. | | `sdk.tenant` | Your tenant's config. | | `sdk.kyc`, `sdk.referrals`, `sdk.analytics`, `sdk.indicators`, `sdk.backtests`, `sdk.copyTrading`, `sdk.sandbox` | The rest of the surface. | `sdk.client` is the escape hatch — a raw typed request method for anything the resources don't cover yet. ## End to end ```ts const sdk = new TronCharts({ baseUrl, token, tenantSlug }) // Rehearse against a sandbox account first. const { accountId } = await sdk.sandbox.paperAccount() const { intentId } = await sdk.oms.composeIntent( { venue: 'hyperliquid', symbol: 'BTC.HL', side: 'buy', type: 'limit', qty: '0.01', price: '60000' }, crypto.randomUUID(), // idempotency key ) await sdk.oms.attachBrackets({ parentIntentId: intentId, tpPrice: 64000, slPrice: 58000 }) const state = await sdk.accounts.state(accountId) ``` ## WebSocket client The socket does not take your REST bearer. Mint a single-use handshake token with `auth.bootstrap()` and pass that: ```ts import { RiskEngineClient } from '@tronchartsxyz/api-client' const { data } = await sdk.auth.bootstrap() const ws = new RiskEngineClient({ url: data.riskEngineWss.endpoint, token: data.riskEngineWss.token, onFrame: (frame) => { if (frame.type === 'Position-Update') applyPosition(frame) }, }) await ws.connect() ws.send({ type: 'Subscribe', topics: ['Position-Changed', 'Open-Orders'] }) ``` `onFrame` is required — there is no `.on()` event API; every server frame goes through that one handler. `connect()` sends `Authenticate` and nothing else, so subscribe explicitly for the topics you want. The handshake token is single-use and expires in 60 seconds, so each connect needs a fresh `bootstrap()`. The WS client speaks JSON only. If you want the [msgpack encoding](/docs/realtime/connecting/#binary-encoding), drive the socket yourself. ## Honest coverage note The typed resources track the REST surface but are not a complete mirror of it — market candles and the trade tape, for one, are REST-only today. When something is missing, `sdk.client.request()` reaches it without waiting for an SDK release. The [REST reference](/docs/reference/rest/) renders `openapi.yaml`, which is hand-maintained and does not cover every `/api/v1` route either — see [Raw specs](/docs/reference/specs/). --- # MCP server URL: https://docs.troncharts.xyz/docs/sdks/mcp/ `@tronchartsxyz/mcp-server` exposes the provider API as MCP **tools** and **prompts**, so an agent operates your firm by calling tools rather than composing HTTP requests. Your credential lives in the server's environment; agent keys never leave the process. You can also build the server from the monorepo and point your MCP client at the built entry point: ```bash cd mcp-server && bun install && bun run build ``` ```json "command": "node", "args": ["/absolute/path/to/mcp-server/dist/index.js"] ``` Everything else on this page — the env contract, the tools, the transports — is exactly the same either way. ## Configure Claude Desktop, Claude Code, or Cursor: ```json { "mcpServers": { "troncharts": { "command": "npx", "args": ["-y", "@tronchartsxyz/mcp-server"], "env": { "TRON_CHARTS_API_KEY": "your-api-key", "TRON_CHARTS_API_SECRET": "your-api-secret", "TRON_CHARTS_BASE_URL": "https://api.troncharts.xyz" } } } } ``` Default transport is stdio. Set `TRON_CHARTS_MCP_TRANSPORT=http` (with `TRON_CHARTS_MCP_PORT` and `TRON_CHARTS_MCP_TOKEN`) to host it over HTTP instead. ## Firm-lifecycle tools | Tool | Does | | --- | --- | | `list_firms` / `get_firm` | Read your firms. | | `create_firm` | Create a firm (`name`, `multisigAddress`, `feeConfig`, …). | | `check_firm_readiness` | The go-live checklist. | | `deploy_prop_instance` | Dry-run or broadcast the deploy. | | `set_firm_status` | Go live, pause, migrate. | | `list_payouts` / `approve_payout` | Work the payout queue. | The trading and liquidation tool sets are filtered by your credential's [tier](/docs/auth/scopes/). The firm-lifecycle tools above are listed at every tier and gated server-side instead: a credential without `firm:operate` gets a 403 on the call. Either way an agent cannot reach past what your credential is allowed to do, which is the point. ## The one-shot prompt The server ships a scripted **`launch-firm`** prompt that runs the whole lifecycle — create → readiness → dry-run deploy → broadcast → re-check → go live — from two inputs (`name`, `multisigAddress`). Invoke it and approve each step. ## Streamable HTTP transport The platform also exposes an MCP endpoint over Streamable HTTP / SSE for clients that speak the protocol directly rather than spawning a local server. The tool catalog is tier-filtered per session, exactly as with stdio. That endpoint carries the read-only, liquidation and trading tools **only** — the firm-lifecycle tools on this page are registered by the stdio server, so run that one to operate a firm. The `launch-firm` prompt is offered on both transports, but over Streamable HTTP every step it scripts calls a tool that is not there. ## MCP or plain REST? - **MCP** when an agent should *operate* the firm live — watching readiness, deploying, working the payout queue. - **REST or the [SDK](/docs/sdks/typescript/)** when you want code you own and run yourself. Both share the same credential and scopes. --- # Build with an AI agent URL: https://docs.troncharts.xyz/docs/sdks/ai-agents/ These docs are written to be **read by an agent**. There are two ways to hand over the integration. ## Option A — feed the docs Two machine-readable index files sit at the API root: | File | What it is | | --- | --- | | [`/llms.txt`](https://api.troncharts.xyz/llms.txt) | A compact map of the docs — sections and links. Point an agent here first. | | [`/llms-full.txt`](https://api.troncharts.xyz/llms-full.txt) | Every doc concatenated into one file. Paste it into a chat and ask for the integration. | Both follow the [llms.txt standard](https://llmstxt.org). A prompt that works: > Read https://api.troncharts.xyz/llms.txt, then write me a TypeScript script > that calls the REST API to create a prop firm, waits for readiness, dry-run > deploys, then goes live. My multisig is 0x… and my fee config is 5% entry / > 10% payout. Because the docs carry the exact endpoints, scopes, and request shapes, an agent can produce a working integration without guessing at them. ## Option B — connect the MCP server Give the agent tools instead of prose. It calls `create_firm` and `check_firm_readiness` rather than writing HTTP, the trading catalog is filtered by your credential's tier, and the backend enforces scopes on every call — so it cannot reach past what you granted. See [MCP server](/docs/sdks/mcp/). ## Which to use - **Feeding docs** is best for generating code you own and run yourself. - **MCP** is best for an agent that operates the firm live — monitoring readiness, deploying, working the payout queue. Both share the same credential and scopes. ## Guardrails worth setting Whichever route you take, an agent with a `fullTrading` credential can place real orders. Two habits make that safe: 1. **Rehearse on a sandbox account.** `POST /api/v1/sandbox/paper-account` exercises the identical order path with no real money. 2. **Issue the narrowest tier.** An agent that only reports doesn't need more than `readonly`. Over REST, `liquidation` reaches exactly `/api/v1/oms/cancel`, `/api/v1/oms/cancel-all` and `/api/v1/oms/flatten`; every other mutating OMS call comes back `403 tier_insufficient`. On the MCP route the split is narrower — a `liquidation` credential gets `cancel_order` and `cancel_all_orders` only, so an agent that must flatten needs `fullTrading` there. --- # WebSocket reference URL: https://docs.troncharts.xyz/docs/reference/websocket/ The narrative guides live under [Real-time](/docs/realtime/connecting/). This page is the flat index — every frame, in one place. The authoritative schema for each payload is the [AsyncAPI document](/docs/reference/specs/). ## Endpoints | Environment | URL | | --- | --- | | Production | `wss://api.troncharts.xyz` | Channels: `/ws/risk`, `/ws/market`, `/ws/quotes`. Append `?encoding=msgpack` to any of them for binary server→client frames. ## `/ws/risk` — client → server | Frame | Tier | Purpose | | --- | --- | --- | | `Authenticate` | `readonly` | First message on the socket. | | `Ping` | `readonly` | Heartbeat. Server replies `Pong`. | | `Alive` | `readonly` | Legacy heartbeat, no reply. | | `Resume` | `readonly` | Replay buffered frames after `lastSeq`. | | `Subscribe` / `Unsubscribe` | `readonly` | Add or drop `topics`; `Subscribe` optionally re-scopes to an owned `accountId`. | | `Get-Margin` | `readonly` | Margin snapshot. | | `Get-Balance` | `readonly` | Balance snapshot. | | `Get-Risk-Update` | `readonly` | Risk snapshot. | | `Get-Position-Update` | `readonly` | Position snapshot. | | `Get-open-orders` | `readonly` | Working-order snapshot. | | `Request-Trade-History` | `readonly` | Recent trades. | | `Order-Sign-Result` | `liquidation` | Return a client-side signature. | | `Cancel-Order-Intent` | `liquidation` | Cancel a working order. | | `Replace-Order-Intent` | `liquidation` | Re-price or re-size a working order. | | `Bracket-Insert` | `liquidation` | Attach TP/SL legs. | | `Bracket-Modify` | `liquidation` | Move a leg. | | `Bracket-Cancel` | `liquidation` | Remove a leg. | | `Position-Close` | `liquidation` | Close a position. | The tier column is the **minimum**; a higher tier always passes. The socket only distinguishes `readonly` from the rest — every write frame above is open to `liquidation` and `fullTrading` alike, and `readonly` is rejected with `Result { ok: false, error: "tier_readonly" }`. The REST `/api/v1/oms/*` surface draws a finer line; see [tiers](/docs/auth/scopes/). `Subscribe` requires a non-empty `topics` array. The socket receives no push frames until it subscribes — see [topics](/docs/realtime/connecting/#subscribe-to-topics) for the full list and the frames each one unlocks. ## `/ws/risk` — server → client | Frame | Carries | | --- | --- | | `Authenticated` | Handshake success. Unsequenced. | | `Result` | Per-frame outcome, including rejections. | | `Pong` | Heartbeat reply. Unsequenced. | | `Account-State-Update` | Aggregate account state. | | `Balance-Update` | Balance. | | `Margin-Update` | Margin. | | `Funding-Update` | Funding and fee rows. | | `Risk-Update` | Risk metrics. | | `Blocking-Update` | Trading blocks on the account. | | `Position-Update` | One position changed. | | `Position-Removed` | A position closed. | | `Position-Snapshot` | Full position set — seeds your view. | | `Open-Orders-Update` | Working-order book. | | `Order-Intent` | An intent was accepted. | | `Order-Intent-Update` | Lifecycle progress, venue ids, fills, terminal state. | | `Order-Update` | Order state in the external vocabulary. | | `Trade-Update` | A round trip closed. | | `Fill` | One execution. Never coalesced. | | `Trade-History-Result` | Reply to `Request-Trade-History`. | | `Account-Event` | Account lifecycle event. | | `Prop-Account-Update` | Challenge progress and status. | | `Prop-Account-Status-Changed` | A prop account changed state. | | `Companion-Event` | Derived risk / position note. | | `IP-Update` | Session IP changed. | | `LoggedOff` | The session ended server-side. Carries `reason` and a `ts`. | | `Resync-Required` | Drop local state and re-snapshot. | Note `LoggedOff` has no hyphen on the wire. There is **no** `Bracket-Info` push: bracket-leg state arrives on `Open-Orders-Update` and `Order-Intent-Update` like any other working order. Every frame that leaves the outbox carries a monotonic `seq`; `Pong` and `Authenticated` do not. See [sequence numbers](/docs/realtime/risk/#sequence-numbers--detect-a-gap). ## `/ws/quotes` Public — no `Authenticate` required. Client → server: `Subscribe-Quote { venue, symbol }`, `Subscribe-Quotes-Bulk { items }` (up to 200 pairs), `Unsubscribe-Quote`, `Unsubscribe-All`, `Get-Quote`, `Authenticate`, `Ping`. Server → client: `Quote-Update`, `Authenticated`, `Pong`, `Result`. `Quote-Update` carries `{ venue, symbol, mid, bid, ask, last, source, ts }`, plus `marketStatus` on futures and B3. The mark is `mid`, a decimal string; `bid`, `ask` and `last` are reserved and always `null`. `ts` is ISO-8601. ## `/ws/market` Public — no `Authenticate` required. Client → server: `Subscribe-Depth`, `Subscribe-Depth-L3`, `Subscribe-Tape`, `Subscribe-VAP`, `Subscribe-BBO`, `Subscribe-Candles`, each with its matching `Unsubscribe-*`, plus `Unsubscribe-All`, `Authenticate`, `Ping`. Every subscribe frame takes `venue` and `symbol` as separate fields; the candle pair also takes `interval`. Server → client: `Depth-Update`, `Order-Book-Update`, `Tape-Update`, `VAP-Update`, `BBO-Update`, `Candle-Update`, `Authenticated`, `Pong`, `Result`. Each stream serves its own set of venues and rejects the rest as `invalid_frame` — see [Market channel](/docs/realtime/market/) for the table. The tape is batched into one frame per flush window; iterate `trades`. ## Webhooks Not a socket, but documented alongside the frames because it answers the same need. See [Webhooks](/docs/realtime/webhooks/) for the envelope, headers, retry schedule, signature verification, and the six subscribable event categories. --- # Raw specs URL: https://docs.troncharts.xyz/docs/reference/specs/ The specs are the contract. Everything on this site is written against them: if a page and the spec disagree, trust the spec — and if the spec and the running API disagree, trust the API. | Document | Covers | Download | | --- | --- | --- | | **OpenAPI 3.1** | The REST surface | [`openapi.yaml`](/docs/openapi.yaml) | | **AsyncAPI 2.6** | WebSocket frames + outbound webhook payloads | [`asyncapi.yaml`](/docs/asyncapi.yaml) | `openapi.yaml` is hand-maintained, not generated from the routes, and it does not yet document every `/api/v1` path. Treat a missing path as undocumented, not as absent — check the running API before concluding an endpoint doesn't exist. ## Generate a client ```bash curl -sO https://docs.troncharts.xyz/docs/openapi.yaml npx @openapitools/openapi-generator-cli generate \ -i openapi.yaml -g typescript-fetch -o ./generated ``` Any generator that reads OpenAPI 3.1 works — Python, Go, Rust, Java. The spec is also directly importable into Postman and Insomnia. ## Discover at runtime instead If you'd rather not parse a spec, `GET /api/v1/discovery` returns plain JSON in one round-trip: a curated index of the most-used endpoints with the credential tier each needs, the WebSocket streams, and usage notes. It reads no session. It is an index, not the whole surface — roughly forty endpoints against the spec's hundred-plus. Firms, reports, routing (`/sor`), indicators and referrals are in `openapi.yaml` only, so parse the spec when you need the complete contract. See [Base URLs & discovery](/docs/start/base-urls/). ## Versioning The REST surface is versioned in the path (`/api/v1/*`). Additive changes — new endpoints, new optional fields — ship without a version bump, so build clients that ignore unknown fields rather than rejecting them. Which venues are wired and which symbols trade are **deployment** properties, not API-version properties. They change without notice, which is why the [discovery endpoints](/docs/start/base-urls/#dont-hardcode--discover) exist. --- # How verification works URL: https://docs.troncharts.xyz/docs/verify/how-it-works/ Every live firm publishes an on-chain proof page that anyone can read without logging in. The point is that you should not have to take the platform's word — or the firm's — for where the money is. [**Verify a firm →**](/docs/verify/) ## What the page shows | Section | Where it comes from | | --- | --- | | **Addresses** | The firm's `PropInstance` contract and the multisig that owns it, each linked to a block explorer so you can read them yourself. | | **On-chain treasury** | Read live from the chain: treasury, collateral, and available balances. | | **Ledger totals** | The platform's record: pay-ins, payouts, forfeits. | | **Reconciliation** | The two compared, dollar for dollar. | | **Solvency** | A classification derived from on-chain treasury versus what the ledger says should be there. | The reconciliation is the part that matters. Ledger totals alone are a claim; an on-chain balance alone lacks context. Setting them side by side is what makes the claim checkable. ## The data behind it Two public endpoints, no auth: | Endpoint | Returns | | --- | --- | | `GET /api/prop/transparency` | Every live firm's bundle. Cached 30s server-side; capped at 200 firms. | | `GET /api/prop/transparency/{firmId}` | One firm. 404 if it doesn't exist or isn't live. | ```bash curl -s https://api.troncharts.xyz/api/prop/transparency | jq '.firms[] | {name, solvency}' ``` The proof page is a thin client over these — it fetches the same JSON you can. Build your own dashboard against it if you'd rather. ## What it does not claim - **It is not an audit.** It is a live read of public chain state next to the platform's ledger. - **A firm that isn't deployed has nothing to verify.** Those show a `not_deployed` reconciliation rather than a reassuring zero. - **Custody is the multisig's, not the platform's.** Verification doesn't change that; it just lets you confirm it. See [Trust & custody model](/docs/auth/trust-model/). ## For firm operators You get this by deploying — there is nothing to opt into. Once your firm is live and its instance is deployed, it appears in the index automatically with the addresses you supplied at create time. See [Launch a prop firm](/docs/launch/prop-firm/).