Code guide
The Partner API supplies the onboarding and authentication layer for your trading platform. This guide takes one user from account creation and hosted consent to real Trading and Management API reads using that user's member token. Your backend calls those APIs directly once the member has authorized it.
The Python snippets build up to a complete client at the end. The walkthrough also shows JavaScript (Node 18 or newer, built-in fetch) and cURL. Snippets run on your backend and build on earlier variables within each language; they are not one concatenated JavaScript file. Responses are illustrative; shortened responses are labeled.
What you will do, in order:
- Get a partner token for your service (client credentials).
- Create a member: a CrossTrade account your service funds, plus a one-time Connect link.
- Send the member to hosted Connect, where they accept the terms, link Tradovate and allow your app. No sign-in: the link is their access.
- Collect the authorization code (pickup delivery, server to server).
- Exchange the code for a member token and a refresh token.
- Use the member token on the other APIs: read Tradovate accounts and open positions through the Trading API, then discover Management operations and read monitors. Use the partner token separately for seat and connection status.
- Follow the lifecycle through the events feed or signed webhooks.
- End the seat when the member leaves, and reopen it if they come back.
Every value starting with xt_ in this guide is a placeholder; real ones are longer. Replace member@example.com, th_8831 and DEMO12345678 with your own data.
Before you start
From the partner dashboard (Partner, API Keys) you need:
| What | Where it comes from | Notes |
|---|---|---|
Client id xt_client_... | API Keys, shown on the client card | Public identifier |
Client secret xt_cs_... | Shown once when the client is created or rotated | Keep it in a secrets store or an environment variable, never in a browser or a form body |
| Redirect URI | Registered on the client | Required in every authorization, even with pickup delivery, and checked again at code exchange |
| Partner scopes | Registered on the client | partner:read, partner:provision |
| Member scopes | Registered on the client | The ceiling for what a member can grant your app: tv:read, tv:trade, manage:read, manage:write |
Your business name must be set, or member creation answers 409 partner_tag_required. These examples use Tradovate demo accounts. Check /me for your enabled environments and live_enabled before implementing live access.
The examples read the client credentials from the environment:
export XT_CLIENT_ID="xt_client_..."
export XT_CLIENT_SECRET="xt_cs_..."
export XT_REDIRECT_URI="https://partner.example/oauth/callback"
The Python examples require requests. For cURL, retain the returned values in PARTNER_TOKEN, FLOW_ID, CODE, MEMBER_TOKEN and REFRESH_TOKEN as you progress. PARTNER_TOKEN comes from step 1; MEMBER_TOKEN comes from step 5. They share the xt_at_ prefix but cannot be substituted for each other.
For the visual flow, token comparison and authentication rules, see Partner Authentication.
Step 1: Get a partner token
POST https://app.crosstrade.io/v1/oauth/token
| Header | Value |
|---|---|
| Authorization | Basic base64(client_id:client_secret) |
| Content-Type | application/x-www-form-urlencoded |
| Form field | Value |
|---|---|
grant_type | client_credentials |
scope | partner:read partner:provision (or just partner:read for a read-only token) |
resource | https://app.crosstrade.io/v1/api/partner (optional; this is the only resource a partner token can carry) |
- Python
- JavaScript
- cURL
import os
import requests
TOKEN_URL = "https://app.crosstrade.io/v1/oauth/token"
CLIENT_ID = os.environ["XT_CLIENT_ID"]
CLIENT_SECRET = os.environ["XT_CLIENT_SECRET"]
response = requests.post(
TOKEN_URL,
auth=(CLIENT_ID, CLIENT_SECRET), # HTTP Basic: never put the secret in the body
data={
"grant_type": "client_credentials",
"scope": "partner:read partner:provision",
},
timeout=15,
)
response.raise_for_status()
token = response.json()
partner_token = token["access_token"] # valid for token["expires_in"] seconds (3600)
print(token["scope"], token["expires_in"])
const TOKEN_URL = "https://app.crosstrade.io/v1/oauth/token";
const basic = Buffer.from(`${process.env.XT_CLIENT_ID}:${process.env.XT_CLIENT_SECRET}`).toString("base64");
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "partner:read partner:provision",
}),
});
if (!response.ok) throw new Error(`token request failed: ${response.status} ${await response.text()}`);
const token = await response.json();
const partnerToken = token.access_token; // valid for token.expires_in seconds (3600)
curl -s -X POST "https://app.crosstrade.io/v1/oauth/token" \
-u "$XT_CLIENT_ID:$XT_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=partner:read partner:provision"
Response
{
"access_token": "xt_at_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "partner:read partner:provision"
}
Cache the token and request a new one a minute before expires_in runs out. A Partner API 401 unauthorized can indicate expiry, but also a wrong token type or audience: check the selected credential before retrying once with a new partner token.
| Status | error | Why |
|---|---|---|
| 401 | invalid_client | Wrong id or secret, or the secret was sent in the form body instead of the Basic header |
| 400 | invalid_scope | A scope your client is not registered for |
| 400 | invalid_target | A resource other than /v1/api/partner |
Token-endpoint errors use the OAuth shape, error and error_description, not the house envelope.
Check the token: your own policy
A good first call. GET /v1/api/partner/me needs only partner:read and tells you whether your service may open seats right now.
- Python
- JavaScript
- cURL
API = "https://app.crosstrade.io/v1/api/partner"
headers = {"Authorization": f"Bearer {partner_token}"}
response = requests.get(f"{API}/me", headers=headers, timeout=15)
response.raise_for_status()
me = response.json()
partner = me["partner"]
print(partner["slug"], partner["status"], partner["open_seats"], "/", partner["seat_cap"])
assert partner["policy"]["allows_new_seats"], "service state blocks new seats"
const API = "https://app.crosstrade.io/v1/api/partner";
const headers = { Authorization: `Bearer ${partnerToken}` };
const response = await fetch(`${API}/me`, { headers });
if (!response.ok) throw new Error(`policy read failed: ${response.status}`);
const me = await response.json();
console.log(me.partner.slug, me.partner.status, me.partner.open_seats, "/", me.partner.seat_cap);
curl -s "https://app.crosstrade.io/v1/api/partner/me" \
-H "Authorization: Bearer $PARTNER_TOKEN"
Shortened response (see Members for the full shape):
{
"success": true,
"partner": {
"id": "p_...",
"slug": "acme_signals",
"name": "Acme Signals, LLC",
"status": "active",
"environments": [
"demo"
],
"seat_cap": 500,
"open_seats": 12,
"limits": {
"...": "..."
},
"stripe_mode": "live",
"live_enabled": false,
"billing": {
"status": "active",
"verified": true,
"grace_expires_at": null
},
"policy": {
"allows_new_seats": true,
"allows_entries": true,
"allows_reads": true
}
},
"meta": {
"requestId": "preq_..."
}
}
Step 2: Create a member
Creating a member needs two things prepared on your side: a PKCE pair and an Idempotency-Key.
Generate the PKCE pair
PKCE ties the authorization code to your server. You send the challenge now and prove you hold the verifier at exchange time (step 5). Generate a fresh pair for every authorization and store the verifier with the member record until the exchange is done.
- Python
- JavaScript
- cURL
import base64
import hashlib
import secrets
def pkce_pair():
verifier = secrets.token_urlsafe(48) # 64 characters, kept server-side
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") # sent to CrossTrade
return verifier, challenge
code_verifier, code_challenge = pkce_pair()
import { createHash, randomBytes } from "node:crypto";
function pkcePair() {
const verifier = randomBytes(48).toString("base64url"); // kept server-side
const challenge = createHash("sha256").update(verifier).digest("base64url"); // sent to CrossTrade
return { verifier, challenge };
}
let { verifier: codeVerifier, challenge: codeChallenge } = pkcePair();
CODE_VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
# Retain CODE_VERIFIER securely for step 5.
The create request
POST https://app.crosstrade.io/v1/api/partner/users
| Header | Value |
|---|---|
| Authorization | Bearer xt_at_... (partner token with partner:provision) |
| Content-Type | application/json |
| Idempotency-Key | A key unique to this create, for example create-th_8831-1. Required. |
| Body field | Meaning |
|---|---|
external_id | Your permanent id for this member: 1–64 letters, digits, ., _ or -. |
email | A new address gets a managed CrossTrade account; an existing CrossTrade address returns 409 email_in_use. |
name | Display name |
environments | Environment names within your partner profile; these examples request demo. |
authorization.scope | What your app will ask the member to allow, within your client's member scopes |
authorization.redirect_uri | One of your registered redirect URIs |
authorization.code_challenge, code_challenge_method | The PKCE challenge and S256 |
authorization.state | A random value you check later (redirect delivery) or use to correlate (pickup) |
authorization.delivery | pickup (your server fetches the code) or redirect (the browser brings it to redirect_uri) |
metadata | Optional JSON object of at most 1,024 serialized bytes, returned with the member |
- Python
- JavaScript
- cURL
import json
import secrets
external_id = "th_8831"
state = secrets.token_urlsafe(16)
body = {
"external_id": external_id,
"email": "member@example.com",
"name": "Jamie Rivera",
"environments": ["demo"],
"authorization": {
"scope": "tv:read manage:read",
"redirect_uri": os.environ["XT_REDIRECT_URI"],
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"state": state,
"delivery": "pickup",
},
}
response = requests.post(
f"{API}/users",
headers={
**headers,
"Content-Type": "application/json",
"Idempotency-Key": f"create-{external_id}-1",
},
json=body,
timeout=30,
)
answer = response.json()
if response.status_code not in (200, 201):
raise RuntimeError(
f"{response.status_code} {answer.get('error')}: {answer.get('detail')} "
f"(request {answer.get('meta', {}).get('requestId')})"
)
member, connect = answer["member"], answer["connect"]
if connect:
print("seat open since", member["seat"]["started"], "flow", connect["flow_id"])
# store: external_id, connect["flow_id"], code_verifier, state
else:
print("member:", member["status"], "request a Connect link, or reopen if revoked")
import { randomBytes } from "node:crypto";
const externalId = "th_8831";
const state = randomBytes(16).toString("base64url");
const body = {
external_id: externalId,
email: "member@example.com",
name: "Jamie Rivera",
environments: ["demo"],
authorization: {
scope: "tv:read manage:read",
redirect_uri: process.env.XT_REDIRECT_URI,
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
delivery: "pickup",
},
};
const response = await fetch(`${API}/users`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": `create-${externalId}-1`,
},
body: JSON.stringify(body),
});
const answer = await response.json();
if (response.status !== 200 && response.status !== 201) {
throw new Error(`${response.status} ${answer.error}: ${answer.detail}`);
}
const member = answer.member;
let connect = answer.connect;
if (connect) {
console.log("seat open since", member.seat.started, "flow", connect.flow_id);
// store: externalId, connect.flow_id, codeVerifier, state
} else {
console.log("member:", member.status, "request a Connect link, or reopen if revoked");
}
curl -s -X POST "https://app.crosstrade.io/v1/api/partner/users" \
-H "Authorization: Bearer $PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-th_8831-1" \
-d "{
\"external_id\": \"th_8831\",
\"email\": \"member@example.com\",
\"name\": \"Jamie Rivera\",
\"environments\": [
\"demo\"
],
\"authorization\": {
\"scope\": \"tv:read manage:read\",
\"redirect_uri\": \"$XT_REDIRECT_URI\",
\"code_challenge\": \"$CODE_CHALLENGE\",
\"code_challenge_method\": \"S256\",
\"state\": \"a1b2c3\",
\"delivery\": \"pickup\"
}
}"
Response, new email (201 Created)
{
"success": true,
"member": {
"external_id": "th_8831",
"uid": "k3Qz9pLa",
"email": "member@example.com",
"name": "Jamie Rivera",
"status": "provisioned",
"ownership_verified": false,
"version": 1,
"environments": [
"demo"
],
"metadata": {},
"seat": {
"started": "2026-10-15T14:02:11Z",
"ended": null
},
"created": "2026-10-15T14:02:11Z",
"authorization": {
"status": "pending",
"flow_id": "pcf_...",
"purpose": "authorize",
"generation": 1
},
"grant": null
},
"connect": {
"flow_id": "pcf_...",
"purpose": "authorize",
"url": "https://app.crosstrade.io/v1/connect/xt_tk_...",
"expires": "2026-10-15T14:12:11Z"
},
"meta": {
"requestId": "preq_...",
"etag": "\"v1\""
}
}
The response also carries ETag: "v1", the membership version. Billing for the seat starts now, whether or not the member ever finishes Connect; revoke the member if they never do.
Other answers to the same request:
| Status | Meaning | What to do |
|---|---|---|
409 email_in_use | That email already has a CrossTrade account. No new account or seat is created. | Ask the member for a different email address. |
200 | The external_id already exists; you get its current state and connect is null. An idempotent replay retains the original status and adds Idempotent-Replayed: true. | Need a new link? Use connect-link (step 3). Revoked? Use reopen (step 8). |
400 validation_failed | A field is wrong; fields names it. | Fix and resend under a new key. |
409 external_id_conflict | That external_id belongs to a member with a different email or name. | Pick another id. |
409 idempotency_conflict | Same key, different body. | Use a new key per distinct request. |
409 seat_cap_reached | Your seat limit. | Revoke unused seats or ask CrossTrade to raise the cap. |
Retain the request body, PKCE verifier and idempotency key together. Retry that exact request with the same key after a lost response. Recorded results are replayable for 24 hours, but connect becomes null once the original 10-minute link expires. Replaying does not issue a fresh link.
Step 3: Send the member to hosted Connect
Hand connect.url to the member however your product reaches them (your own email, an in-app button, a support chat). The link is valid for 10 minutes and for one browser; opening it consumes nothing, pressing Continue on it does. From there the member:
- accepts the required agreements (unless the current versions were already accepted),
- links a Tradovate login,
- chooses which linked identities your app may use and presses Allow.
There is no separate email code or CrossTrade sign-in. Possession and redemption of the private link is the ownership proof, so deliver it only to the intended member. Their Tradovate authentication happens through the hosted broker flow.
You can watch it happen. GET /v1/api/partner/users/{external_id} is cheap, needs only partner:read, and authorization.status moves from pending to code_ready when the member allows (or denied if they decline). The events feed (step 7) carries the same progress as broker.ready and authorization.granted.
If the link expired before the member used it, request a new one. Generate and retain a fresh PKCE pair for the new flow. A new link of the same purpose closes earlier unfinished flows, including an unexchanged code.
- Python
- JavaScript
- cURL
code_verifier, code_challenge = pkce_pair()
state = secrets.token_urlsafe(16)
body["authorization"].update(
code_challenge=code_challenge,
state=state,
)
link_key = f"link-{external_id}-{secrets.token_hex(8)}"
# Retain the body, verifier, state and key before sending; reuse them for retries.
response = requests.post(
f"{API}/users/{external_id}/connect-link",
headers={
**headers,
"Content-Type": "application/json",
"Idempotency-Key": link_key,
},
json={
"purpose": "authorize",
"authorization": body["authorization"],
},
timeout=30,
)
response.raise_for_status()
connect = response.json()["connect"]
# Retain this flow_id with the NEW code_verifier before sending its URL.
const nextPkce = pkcePair();
codeVerifier = nextPkce.verifier;
body.authorization.code_challenge = nextPkce.challenge;
body.authorization.state = randomBytes(16).toString("base64url");
const linkKey = `link-${externalId}-${randomBytes(8).toString("hex")}`;
// Retain the body, verifier, state and key before sending; reuse them for retries.
const link = await fetch(`${API}/users/${externalId}/connect-link`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": linkKey,
},
body: JSON.stringify({
purpose: "authorize",
authorization: body.authorization,
}),
});
if (!link.ok) throw new Error(`connect-link failed: ${link.status}`);
({ connect } = await link.json());
// Retain this flow_id with codeVerifier before sending its URL.
# Generate a new CODE_VERIFIER and CODE_CHALLENGE as in step 2 first.
curl -s -X POST "https://app.crosstrade.io/v1/api/partner/users/th_8831/connect-link" \
-H "Authorization: Bearer $PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: link-th_8831-2" \
-d "{
\"purpose\": \"authorize\",
\"authorization\": {
\"scope\": \"tv:read manage:read\",
\"redirect_uri\": \"$XT_REDIRECT_URI\",
\"code_challenge\": \"$CODE_CHALLENGE\",
\"code_challenge_method\": \"S256\",
\"state\": \"a1b2c3\",
\"delivery\": \"pickup\"
}
}"
purpose: relink (omit authorization) repairs a previously consented broker identity, for example after a password change. It also requires Idempotency-Key, but does not issue a new authorization code or member token.
Step 4: Collect the authorization code
With delivery: pickup, your server asks CrossTrade for the code. The call is safe to repeat.
POST https://app.crosstrade.io/v1/api/partner/users/{external_id}/authorizations/{flow_id}/code
| Status | authorization.status | Meaning |
|---|---|---|
202 | pending | The member has not pressed Allow yet. Ask again later. |
200 | code_ready | authorization.code (xt_ac_...) and expires. Exchange it within 300 seconds. |
200 | exchanged | The code was already exchanged; no credentials are returned again. |
410 flow_closed | Expired, denied, revoked, or replaced by a newer link. Request a new link. | |
404 not_found | Not your flow, or not this member's. |
Poll every few seconds while the member is on the page, or wait for the authorization.granted event and then pick the code up once.
- Python
- JavaScript
- cURL
import time
def wait_for_code(external_id, flow_id, timeout=900):
url = f"{API}/users/{external_id}/authorizations/{flow_id}/code"
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
response = requests.post(url, headers=headers, timeout=15)
answer = response.json()
if response.status_code == 200 and answer["authorization"]["status"] == "code_ready":
return answer["authorization"]["code"]
if response.status_code == 200: # exchanged: nothing more to pick up
raise RuntimeError("code already exchanged")
if response.status_code != 202: # 410 flow_closed, 404 ...
raise RuntimeError(f"{response.status_code} {answer.get('error')}: {answer.get('detail')}")
time.sleep(5)
raise TimeoutError("the member did not allow the app in time")
code = wait_for_code(external_id, connect["flow_id"])
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForCode(externalId, flowId, timeoutMs = 900_000) {
const url = `${API}/users/${externalId}/authorizations/${flowId}/code`;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const response = await fetch(url, { method: "POST", headers });
const answer = await response.json();
if (response.status === 200 && answer.authorization.status === "code_ready") return answer.authorization.code;
if (response.status === 200) throw new Error("code already exchanged");
if (response.status !== 202) throw new Error(`${response.status} ${answer.error}: ${answer.detail}`);
await sleep(5000);
}
throw new Error("the member did not allow the app in time");
}
const code = await waitForCode(externalId, connect.flow_id);
curl -s -X POST "https://app.crosstrade.io/v1/api/partner/users/th_8831/authorizations/$FLOW_ID/code" \
-H "Authorization: Bearer $PARTNER_TOKEN"
Responses
{
"success": true,
"authorization": {
"status": "pending",
"flow_id": "pcf_..."
},
"meta": {
"requestId": "preq_..."
}
}
{
"success": true,
"authorization": {
"status": "code_ready",
"flow_id": "pcf_...",
"code": "xt_ac_...",
"expires": "2026-10-15T14:13:52Z"
},
"meta": {
"requestId": "preq_..."
}
}
With delivery: redirect there is nothing to poll. After Allow, the browser is sent to your redirect_uri with code, state and iss; a decline arrives as error=access_denied with the same state. Verify the stored state and, for a successful callback, that iss equals https://app.crosstrade.io before exchanging the code. Pickup works without a browser callback, including when the member completes Connect on another device.
Step 5: Exchange the code for a member token
POST https://app.crosstrade.io/v1/oauth/token
| Form field | Value |
|---|---|
grant_type | authorization_code |
code | The xt_ac_... code from step 4 |
code_verifier | The PKCE verifier from step 2 |
redirect_uri | The same redirect_uri you put in the authorization, also with pickup delivery |
resource | Optional. Omit it to retain the approved audiences (both APIs for this guide's scopes). To narrow, supply the exact absolute Trading or Management resource URL. |
The client still authenticates with HTTP Basic.
- Python
- JavaScript
- cURL
response = requests.post(
TOKEN_URL,
auth=(CLIENT_ID, CLIENT_SECRET),
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"redirect_uri": os.environ["XT_REDIRECT_URI"],
},
timeout=15,
)
response.raise_for_status()
grant = response.json()
member_token = grant["access_token"] # 1 hour
refresh_token = grant["refresh_token"] # store encrypted, per member; rotates on every refresh
print("member scope:", grant["scope"])
const exchange = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
code_verifier: codeVerifier,
redirect_uri: process.env.XT_REDIRECT_URI,
}),
});
if (!exchange.ok) throw new Error(`exchange failed: ${exchange.status} ${await exchange.text()}`);
const grant = await exchange.json();
const memberToken = grant.access_token; // 1 hour
const refreshToken = grant.refresh_token; // store encrypted, per member; rotates on every refresh
curl -s -X POST "https://app.crosstrade.io/v1/oauth/token" \
-u "$XT_CLIENT_ID:$XT_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=$CODE" \
--data-urlencode "code_verifier=$CODE_VERIFIER" \
--data-urlencode "redirect_uri=$XT_REDIRECT_URI"
Response
{
"access_token": "xt_at_...",
"refresh_token": "xt_rt_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "tv:read manage:read"
}
scope is what the member actually allowed, intersected with what you asked for and what your client may hold. Read it rather than assuming.
Refreshing
When the member token expires, trade the refresh token for a new pair. Every refresh rotates: the old refresh token is retired and a new one is returned, so store the new pair before you use it, and keep refreshes single-flight per member.
- Python
- JavaScript
- cURL
def refresh(refresh_token):
response = requests.post(
TOKEN_URL,
auth=(CLIENT_ID, CLIENT_SECRET),
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
timeout=15,
)
if response.status_code == 400 and response.json().get("error") == "invalid_grant":
raise RuntimeError("refresh token is unusable; re-authorize if no current pair is stored")
response.raise_for_status()
pair = response.json()
return pair["access_token"], pair["refresh_token"] # persist both before using them
async function refresh(refreshToken) {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
const pair = await response.json();
if (response.status === 400 && pair.error === "invalid_grant") throw new Error("re-authorize");
if (!response.ok) throw new Error(`refresh failed: ${response.status}`);
return { accessToken: pair.access_token, refreshToken: pair.refresh_token }; // persist both before using them
}
curl -s -X POST "https://app.crosstrade.io/v1/oauth/token" \
-u "$XT_CLIENT_ID:$XT_CLIENT_SECRET" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token=$REFRESH_TOKEN"
An identical refresh retry less than 30 seconds after rotation returns the same pair only while the successor refresh token remains active. If the successor has already rotated, the old retry returns invalid_grant. Reusing a retired token at or after 30 seconds revokes the family. Stop retrying a token that returns invalid_grant; if you have no current pair, send a new authorization link. See refresh rotation.
Step 6: Read about the member
The authentication handoff is complete. Keep a record such as external_id → access_token, refresh_token, expires_at, scope in your backend. For a request from Jamie (th_8831), load Jamie's pair and send Jamie's access token. For another member, load that member's pair. The Trading and Management APIs infer the CrossTrade user from the token; do not send external_id or uid to switch users.
Steps 2–5 requested tv:read manage:read, so the token can read both APIs. It cannot place orders or change automation. Add the appropriate scopes to a new consent request when your app needs those features.
With the partner token: the seat and the authorization
GET https://app.crosstrade.io/v1/api/partner/users/{external_id}
- Python
- JavaScript
- cURL
response = requests.get(f"{API}/users/{external_id}", headers=headers, timeout=15)
response.raise_for_status()
member = response.json()["member"]
etag = response.headers["ETag"] # Current version; revoke will increment it.
print(member["status"], member["ownership_verified"], member["authorization"]["status"], member["grant"])
const read = await fetch(`${API}/users/${externalId}`, { headers });
if (!read.ok) throw new Error(`member read failed: ${read.status}`);
const { member } = await read.json();
const etag = read.headers.get("etag"); // Current version; revoke will increment it.
console.log(member.status, member.ownership_verified, member.authorization.status, member.grant);
curl -s -i "https://app.crosstrade.io/v1/api/partner/users/th_8831" \
-H "Authorization: Bearer $PARTNER_TOKEN"
{
"success": true,
"member": {
"external_id": "th_8831",
"uid": "k3Qz9pLa",
"email": "member@example.com",
"name": "Jamie Rivera",
"status": "connected",
"ownership_verified": true,
"version": 1,
"environments": [
"demo"
],
"metadata": {},
"seat": {
"started": "2026-10-15T14:02:11Z",
"ended": null
},
"created": "2026-10-15T14:02:11Z",
"authorization": {
"status": "exchanged",
"flow_id": "pcf_...",
"purpose": "authorize",
"generation": 1
},
"grant": {
"version": 1,
"active": true
},
"events_high_water": 42
},
"meta": {
"requestId": "preq_...",
"etag": "\"v1\""
}
}
status: connected records an approved authorization; grant.active reports whether that grant remains active. Neither proves that a token or broker session is currently usable.
GET /v1/api/partner/users?limit=50 lists members by creation time descending, then external_id descending (newest first). Pass next_cursor as cursor on subsequent requests; omit it on the first request.
With the partner token: every member's broker connection
One paginated endpoint summarizes stored broker connection health. brokers holds the logins shared under each member's active grant, with a status of ready, reauth_required (with a reason), unknown or unlinked; all_ready summarizes the member. Trading still requires the right member token, scopes, environments and service policy. Follow next_cursor to inspect more than one page.
- Python
- JavaScript
- cURL
response = requests.get(f"{API}/brokers", headers=headers, params={"limit": 200}, timeout=15)
response.raise_for_status()
page = response.json()
for member in page["members"]:
for login in member["brokers"]:
if login["status"] != "ready":
print(member["external_id"], login["environment"], login["user_id"], login["status"], login["reason"])
const response = await fetch(`${API}/brokers?limit=200`, { headers });
if (!response.ok) throw new Error(`broker status read failed: ${response.status}`);
const page = await response.json();
for (const member of page.members) {
for (const login of member.brokers) {
if (login.status !== "ready") console.log(member.external_id, login.environment, login.user_id, login.status, login.reason);
}
}
curl -s "https://app.crosstrade.io/v1/api/partner/brokers?limit=200" \
-H "Authorization: Bearer $PARTNER_TOKEN"
{
"success": true,
"members": [
{
"external_id": "th_8831",
"uid": "k3Qz9pLa",
"status": "connected",
"grant": {
"version": 1,
"active": true
},
"brokers": [
{
"environment": "demo",
"user_id": "4815162",
"name": "jamie_r",
"status": "ready",
"reason": null,
"expires": "2026-10-16T09:12:00Z",
"linked": "2026-10-15T14:09:02Z"
}
],
"all_ready": true
}
],
"next_cursor": null,
"as_of": "2026-10-16T01:00:00Z",
"meta": {
"requestId": "preq_..."
}
}
Confirmed login failures also produce broker.reauth_required events (step 7); this route gives you the current connection summary on demand.
With the member token: their Tradovate accounts
This call uses the member token from step 5, not the partner token. It requires tv:read and consent covering all of the member's currently linked identities, with every environment allowed. For this example, link and consent to one demo identity. If you already know a consented account name, you can instead go straight to the per-account positions read below.
GET https://app.crosstrade.io/v1/api/tv/accounts
- Python
- JavaScript
- cURL
member_headers = {"Authorization": f"Bearer {member_token}"}
response = requests.get(
"https://app.crosstrade.io/v1/api/tv/accounts",
headers=member_headers,
timeout=15,
)
response.raise_for_status()
accounts = response.json()
if accounts.get("success") is False:
raise RuntimeError(accounts.get("error"))
for account in accounts["data"]:
print(account["name"], account["environment"], account["accountId"])
const memberHeaders = { Authorization: `Bearer ${memberToken}` };
const response = await fetch("https://app.crosstrade.io/v1/api/tv/accounts", {
headers: memberHeaders,
});
if (!response.ok) throw new Error(`account read failed: ${response.status} ${await response.text()}`);
const accounts = await response.json();
if (accounts.success === false) throw new Error(accounts.error);
for (const account of accounts.data) console.log(account.name, account.environment, account.accountId);
curl -s "https://app.crosstrade.io/v1/api/tv/accounts" \
-H "Authorization: Bearer $MEMBER_TOKEN"
Shortened account-list response:
{
"success": true,
"data": [
{
"name": "DEMO12345678",
"accountId": 1234567,
"environment": "demo",
"userId": 4815162
}
]
}
/accounts is an aggregate route: it rejects partial consent rather than returning a filtered subset. A 403 identity_not_consented means you should use a known consented account's per-account route or request fresh consent covering all linked identities. See Delegated trading for the full rules.
With the same member token: one account's open positions
Choose an account from the successful list above, or an account name already known to belong to a consented identity. An account name is not a CrossTrade uid, your external_id, or a Tradovate login's userId.
GET https://app.crosstrade.io/v1/api/tv/accounts/{account}/positions
- Python
- JavaScript
- cURL
from urllib.parse import quote
account_name = "DEMO12345678" # Choose a real account belonging to this member.
response = requests.get(
f"https://app.crosstrade.io/v1/api/tv/accounts/{quote(account_name, safe='')}/positions",
headers={"Authorization": f"Bearer {member_token}"},
timeout=15,
)
response.raise_for_status()
positions = response.json()
if positions.get("success") is False:
raise RuntimeError(positions.get("error"))
print(json.dumps(positions, indent=2))
for position in positions["data"]:
print(position["contractId"], position["netPos"])
const accountName = "DEMO12345678"; // Choose a real account belonging to this member.
const response = await fetch(
`https://app.crosstrade.io/v1/api/tv/accounts/${encodeURIComponent(accountName)}/positions`,
{ headers: { Authorization: `Bearer ${memberToken}` } },
);
if (!response.ok) throw new Error(`positions read failed: ${response.status} ${await response.text()}`);
const positions = await response.json();
if (positions.success === false) throw new Error(positions.error);
console.log(JSON.stringify(positions, null, 2));
for (const position of positions.data) console.log(position.contractId, position.netPos);
curl -sS "https://app.crosstrade.io/v1/api/tv/accounts/DEMO12345678/positions" \
-H "Authorization: Bearer $MEMBER_TOKEN"
A successful response when that account has no open positions:
{
"success": true,
"data": []
}
When positions are open, data contains position records including accountId, contractId and signed netPos. Positive netPos is long; negative is short. This read needs tv:read and no Idempotency-Key. Use the same member-token pattern for other Trading endpoints, including that account's orders and fills.
With the member token: what your app may manage
GET /v1/api/manage returns delegated: true and the supported delegated operation catalog. It includes write operations even for a read-only token. Your token's scope and the current resource and service checks determine which calls are authorized.
- Python
- JavaScript
- cURL
response = requests.get("https://app.crosstrade.io/v1/api/manage", headers=member_headers, timeout=15)
response.raise_for_status()
capabilities = response.json()
print(capabilities["data"]["delegated"], capabilities["data"]["operations"])
response = requests.get("https://app.crosstrade.io/v1/api/manage/tradovate/monitors", headers=member_headers, timeout=15)
response.raise_for_status()
monitors = response.json()
print(json.dumps(monitors["data"], indent=2)) # Monitors on consented accounts.
const discovery = await fetch("https://app.crosstrade.io/v1/api/manage", { headers: memberHeaders });
if (!discovery.ok) throw new Error(`discovery failed: ${discovery.status}`);
const capabilities = await discovery.json();
console.log(capabilities.data.delegated, capabilities.data.operations);
const monitorRead = await fetch("https://app.crosstrade.io/v1/api/manage/tradovate/monitors", { headers: memberHeaders });
if (!monitorRead.ok) throw new Error(`monitor read failed: ${monitorRead.status}`);
const monitors = await monitorRead.json();
console.log(JSON.stringify(monitors.data, null, 2));
curl -s "https://app.crosstrade.io/v1/api/manage" -H "Authorization: Bearer $MEMBER_TOKEN"
curl -s "https://app.crosstrade.io/v1/api/manage/tradovate/monitors" -H "Authorization: Bearer $MEMBER_TOKEN"
Shortened discovery response (family availability and budgets depend on configuration):
{
"success": true,
"data": {
"version": "2026-10",
"elite": true,
"delegated": true,
"operations": [
"atm.create",
"atm.delete",
"atm.get",
"atm.list",
"atm.update",
"audit.list",
"index.get",
"tradovate.copiers.create",
"tradovate.copiers.delete",
"tradovate.copiers.get",
"tradovate.copiers.list",
"tradovate.copiers.update",
"tradovate.monitors.create",
"tradovate.monitors.delete",
"tradovate.monitors.get",
"tradovate.monitors.list",
"tradovate.monitors.restart",
"tradovate.monitors.update"
],
"families": [
{
"id": "tradovate_monitors",
"enabled": true,
"writable": true
}
],
"limits": {
"...": "..."
}
},
"meta": {
"requestId": "req_..."
}
}
The writable flag reports platform availability, not a permission granted to this token. With this guide's manage:read scope, reads work and writes return 403 insufficient_scope. Writes need manage:write; starting automation additionally needs tv:trade and both API audiences. See Delegated management.
Step 7: Follow the lifecycle
Poll the events feed
GET https://app.crosstrade.io/v1/api/partner/events?limit=200
The feed is a gap-free log with a per-partner sequence. Persist next_cursor after you process a page and pass it back as cursor; without one, the feed starts at the oldest retained event (30 days).
- Python
- JavaScript
- cURL
members_by_id = {} # Replace with your application's durable member store.
def reconcile_members():
cursor = None
resume_after = None
snapshot = {}
while True:
params = {"limit": 200}
if cursor is not None:
params["cursor"] = cursor
response = requests.get(f"{API}/users", headers=headers, params=params, timeout=15)
response.raise_for_status()
page = response.json()
if resume_after is None:
resume_after = page["events_high_water"] # Capture FIRST page, before the full scan.
for member in page["members"]:
snapshot[member["external_id"]] = member
cursor = page["next_cursor"]
if cursor is None:
members_by_id.clear()
members_by_id.update(snapshot)
return resume_after
def poll_events(cursor=None):
"""Process available events and return the cursor to persist."""
while True:
params = {"limit": 200}
if cursor is not None:
params["cursor"] = cursor
response = requests.get(f"{API}/events", headers=headers, params=params, timeout=15)
if response.status_code == 410: # cursor_expired: fell behind the 30-day floor
cursor = reconcile_members()
continue
response.raise_for_status()
page = response.json()
for event in page["events"]:
handle(event) # idempotent per event["id"]
cursor = page["next_cursor"]
if not page["has_more"]:
return cursor
def handle(event):
print(event["sequence"], event["type"], event.get("external_id"), event["data"])
cursor = poll_events(cursor=None) # store `cursor`; call again on a timer with it
const membersById = new Map(); // Replace with your application's durable store.
async function reconcileMembers() {
let cursor = null;
let resumeAfter = null;
const snapshot = new Map();
do {
const params = new URLSearchParams({ limit: "200" });
if (cursor !== null) params.set("cursor", cursor);
const response = await fetch(`${API}/users?${params}`, { headers });
if (!response.ok) throw new Error(`member reconciliation failed: ${response.status}`);
const page = await response.json();
if (resumeAfter === null) resumeAfter = page.events_high_water;
for (const member of page.members) snapshot.set(member.external_id, member);
cursor = page.next_cursor;
} while (cursor !== null);
membersById.clear();
for (const [id, member] of snapshot) membersById.set(id, member);
return resumeAfter;
}
async function pollEvents(cursor = null) {
for (;;) {
const params = new URLSearchParams({ limit: "200" });
if (cursor !== null) params.set("cursor", String(cursor));
const response = await fetch(`${API}/events?${params}`, { headers });
if (response.status === 410) {
cursor = await reconcileMembers();
continue;
}
if (!response.ok) throw new Error(`event read failed: ${response.status}`);
const page = await response.json();
for (const event of page.events) await handle(event); // idempotent per event.id
cursor = page.next_cursor;
if (!page.has_more) return cursor; // store it; call again on a timer with it
}
}
function handle(event) {
console.log(event.sequence, event.type, event.external_id, event.data);
}
curl -s "https://app.crosstrade.io/v1/api/partner/events?limit=200" \
-H "Authorization: Bearer $PARTNER_TOKEN"
{
"success": true,
"events": [
{
"id": "pev_...",
"sequence": 41,
"version": 1,
"type": "member.created",
"created": "2026-10-15T14:02:11Z",
"partner_id": "p_...",
"external_id": "th_8831",
"data": {
"status": "provisioned",
"version": 1
}
},
{
"id": "pev_...",
"sequence": 42,
"version": 1,
"type": "authorization.granted",
"created": "2026-10-15T14:08:52Z",
"partner_id": "p_...",
"external_id": "th_8831",
"data": {
"scope": "tv:read manage:read",
"version": 1
}
}
],
"next_cursor": 42,
"floor": 0,
"high_water": 42,
"has_more": false,
"as_of": "2026-10-15T14:10:00Z",
"meta": {
"requestId": "preq_..."
}
}
Event types and their meaning are listed in Events and webhooks.
Or receive signed webhooks
Configure a webhook URL in the dashboard (API Keys); the dashboard sends one signed webhook.test event and saves the URL once your endpoint answers 2xx. Each delivery is a POST with the event as the body and these headers:
| Header | Value |
|---|---|
X-CrossTrade-Event-Id | The event id, the same on every retry |
X-CrossTrade-Event-Type | The event type |
X-CrossTrade-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> (two v1 values during a secret rotation) |
Verify the signature over the raw body bytes, reject stale timestamps and durably enqueue the event before answering 2xx. Process it asynchronously and deduplicate by event id. The Flask and Express examples below are integration sketches: implement already_processed / alreadyProcessed and enqueue against your durable store and queue.
- Python
- JavaScript
- cURL
import hashlib
import hmac
import time
from flask import Flask, request, abort
WEBHOOK_SECRET = os.environ["XT_WEBHOOK_SECRET"].encode("utf-8")
def verify_signature(header, body: bytes, tolerance=300):
parts = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
timestamps = [value for key, value in parts if key == "t"]
if len(timestamps) != 1 or not timestamps[0].isascii() or not timestamps[0].isdigit():
return False
timestamp = int(timestamps[0])
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(WEBHOOK_SECRET, f"{timestamp}.".encode("ascii") + body, hashlib.sha256).hexdigest()
candidates = [value for key, value in parts if key == "v1"]
return any(
len(candidate) == 64
and all(c in "0123456789abcdef" for c in candidate)
and hmac.compare_digest(expected, candidate)
for candidate in candidates
)
# Flask, as an example of the boundary
app = Flask(__name__)
@app.post("/crosstrade/webhook")
def crosstrade_webhook():
if not verify_signature(request.headers.get("X-CrossTrade-Signature", ""), request.get_data()):
abort(401)
event = request.get_json()
if already_processed(event["id"]): # your store: retries carry the same id
return "", 200
enqueue(event) # Durable before acknowledging; process later.
return "", 200
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const secret = process.env.XT_WEBHOOK_SECRET;
function verifySignature(header, rawBody, tolerance = 300) {
const parts = header.split(",").map((p) => p.trim().split("="));
const timestamps = parts.filter(([k]) => k === "t");
if (timestamps.length !== 1 || !/^\d+$/.test(timestamps[0][1] ?? "")) return false;
const t = Number(timestamps[0][1]);
if (!Number.isSafeInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return parts.filter(([k]) => k === "v1").some(([, v]) =>
/^[0-9a-f]{64}$/.test(v ?? "") && timingSafeEqual(Buffer.from(v, "hex"), Buffer.from(expected, "hex")));
}
const app = express();
app.post("/crosstrade/webhook", express.raw({ type: "application/json" }), async (req, res, next) => {
try {
if (!verifySignature(req.get("X-CrossTrade-Signature") ?? "", req.body)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
await enqueue(event); // Durable before acknowledging; process later.
res.sendStatus(200);
} catch (error) {
next(error);
}
});
# Replay a delivery against your own endpoint while developing.
BODY='{
"id": "pev_test",
"sequence": 0,
"version": 1,
"type": "webhook.test",
"created": "2026-10-15T14:00:00Z",
"partner_id": "p_x",
"external_id": null,
"data": {}
}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$XT_WEBHOOK_SECRET" | awk '{print $NF}')
curl -s -X POST "https://partner.example/crosstrade/webhook" \
-H "Content-Type: application/json" \
-H "X-CrossTrade-Event-Id: pev_test" -H "X-CrossTrade-Event-Type: webhook.test" \
-H "X-CrossTrade-Signature: t=$T,v1=$SIG" \
--data-binary "$BODY"
Retries use the same event id with a fresh timestamp and signature. Network failures, 408, 429 and 5xx are retried with backoff; numeric Retry-After values are considered with a one-hour cap. Webhooks may arrive out of order. Recover missed lifecycle updates from the retained feed; see Events and webhooks for retention and delivery rules.
Step 8: End the seat, and reopen it
Revoke
POST https://app.crosstrade.io/v1/api/partner/users/{external_id}/revoke
The body needs a reason from the fixed list (subscription_canceled, payment_failed, member_request, partner_terminated, abuse, other). The seat and credentials end immediately. Any billable portion of that UTC day still counts as a seat-day. The first revoke increments the membership version; repeating it preserves the version and returns already_revoked: true.
- Python
- JavaScript
- cURL
response = requests.post(
f"{API}/users/{external_id}/revoke",
headers={**headers, "Content-Type": "application/json"},
json={
"reason": "subscription_canceled",
},
timeout=30,
)
response.raise_for_status()
answer = response.json()
print(answer["member"]["status"], answer["already_revoked"], response.headers["ETag"])
const revoke = await fetch(`${API}/users/${externalId}/revoke`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
reason: "subscription_canceled",
}),
});
if (!revoke.ok) throw new Error(`revoke failed: ${revoke.status}`);
const revoked = await revoke.json();
console.log(revoked.member.status, revoked.already_revoked, revoke.headers.get("etag"));
curl -s -i -X POST "https://app.crosstrade.io/v1/api/partner/users/th_8831/revoke" \
-H "Authorization: Bearer $PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "subscription_canceled"
}'
Shortened response:
{
"success": true,
"member": {
"external_id": "th_8831",
"status": "revoked",
"version": 2,
"seat": {
"started": "2026-10-15T14:02:11Z",
"ended": "2026-11-02T09:30:00Z"
},
"...": "..."
},
"already_revoked": false,
"meta": {
"requestId": "preq_..."
}
}
Reopen
A member who comes back keeps the same external_id. reopen starts a new seat and a new Connect flow; it requires If-Match with the member's current version (the ETag from a read or from the revoke) and an Idempotency-Key, and it takes a fresh authorization object with a new PKCE challenge.
- Python
- JavaScript
- cURL
code_verifier, code_challenge = pkce_pair()
authorization = {
**body["authorization"],
"code_challenge": code_challenge,
"state": secrets.token_urlsafe(16),
}
current = requests.get(f"{API}/users/{external_id}", headers=headers, timeout=15)
current.raise_for_status()
reopen_key = f"reopen-{external_id}-{secrets.token_hex(8)}"
# Retain authorization, code_verifier, the current ETag and reopen_key for retries.
response = requests.post(
f"{API}/users/{external_id}/reopen",
headers={
**headers,
"Content-Type": "application/json",
"If-Match": current.headers["ETag"],
"Idempotency-Key": reopen_key,
},
json={
"authorization": authorization,
},
timeout=30,
)
response.raise_for_status()
answer = response.json() # 200: member.version + 1, status provisioned, a new connect.url
connect = answer["connect"]
# Store connect["flow_id"] with code_verifier, then repeat steps 3–5.
const current = await fetch(`${API}/users/${externalId}`, { headers });
if (!current.ok) throw new Error(`member read failed: ${current.status}`);
const { verifier: newVerifier, challenge: newChallenge } = pkcePair();
const authorization = {
...body.authorization,
code_challenge: newChallenge,
state: randomBytes(16).toString("base64url"),
};
const reopenKey = `reopen-${externalId}-${randomBytes(8).toString("hex")}`;
// Retain authorization, newVerifier, the current ETag and reopenKey for retries.
const reopen = await fetch(`${API}/users/${externalId}/reopen`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"If-Match": current.headers.get("etag"),
"Idempotency-Key": reopenKey,
},
body: JSON.stringify({
authorization,
}),
});
if (!reopen.ok) throw new Error(`reopen failed: ${reopen.status} ${await reopen.text()}`);
const reopened = await reopen.json(); // 200: member.version + 1, status provisioned, a new connect.url
// Store reopened.connect.flow_id with newVerifier, then repeat steps 3–5.
# Generate a fresh CODE_VERIFIER and CODE_CHALLENGE first. Replace v2 with the current ETag.
curl -s -X POST "https://app.crosstrade.io/v1/api/partner/users/th_8831/reopen" \
-H "Authorization: Bearer $PARTNER_TOKEN" \
-H "Content-Type: application/json" \
-H 'If-Match: "v2"' \
-H "Idempotency-Key: reopen-th_8831-1" \
-d "{
\"authorization\": {
\"scope\": \"tv:read manage:read\",
\"redirect_uri\": \"$XT_REDIRECT_URI\",
\"code_challenge\": \"$CODE_CHALLENGE\",
\"code_challenge_method\": \"S256\",
\"state\": \"r1\",
\"delivery\": \"pickup\"
}
}"
A missing If-Match answers 428; a stale one 412 precondition_failed with the current version in detail. Re-read the member before deciding whether to reopen again, and use a new idempotency key for the changed request. Reopening a member who is not revoked answers 409.
Handling errors
Every control-plane answer is the house envelope. On failure:
{
"success": false,
"error": "validation_failed",
"detail": "One or more fields are invalid.",
"fields": [
{
"path": "external_id",
"code": "invalid"
}
],
"meta": {
"requestId": "preq_..."
}
}
- Log
meta.requestIdwith every failure; CrossTrade support looks requests up by it. 401: verify token type and audience first. Renew an expired partner token or refresh the affected member's token. A refresh cannot add missing audiences or scopes.403 insufficient_scope: the token lacks the scope;403 partner_*: your service state, readGET /v1/api/partner/me.409 idempotency_in_progress: the same key is still being processed; wait and retry with the same key.429 rate_limited: honorRetry-After(also in the body asretryAfter). Partner budgets are per partner, not per token, so a second token does not buy more.503: temporary; retry with backoff.- Trading commands that answer
409 outcome_unknownmay have reached the broker: followstatusUrl(orGET /v1/api/tv/operations/{operationId}), then inspect orders and positions. Do not resend with a fresh key. Every order command requires anIdempotency-Key.
The full code table is in Errors.
Complete Python example
This single-file example creates one funded member, waits for consent, stores their token pair under external_id, and uses that pair to read Trading accounts and positions and Management monitors. It also refreshes that member's pair before expiry.
Set the three client environment variables from the top of the guide, plus XT_MEMBER_EMAIL (an address with no existing CrossTrade account). Optionally set XT_EXTERNAL_ID, XT_MEMBER_NAME, and XT_ACCOUNT. A known consented XT_ACCOUNT lets you skip aggregate account discovery when consent covers only some linked identities. Otherwise, complete Connect with one demo identity and consent to it.
The example uses requests and keeps credentials in memory for one run. In your service, persist token pairs encrypted, retain pending requests with their PKCE verifier and idempotency key, and serialize refreshes per member. The script prints the Connect URL for you to deliver privately; do not send that URL to production logs. API errors fail with their status and available request or operation id.
"""crosstrade_partner.py: onboard one member, then read both member APIs."""
import base64
import hashlib
import json
import os
import secrets
import time
from urllib.parse import quote
import requests
HOST = "https://app.crosstrade.io"
TOKEN_URL = f"{HOST}/v1/oauth/token"
PARTNER_API = f"{HOST}/v1/api/partner"
MEMBER_APIS = {
"trading": f"{HOST}/v1/api/tv",
"management": f"{HOST}/v1/api/manage",
}
class PartnerApiError(Exception):
def __init__(self, status, payload):
self.status = status
self.error = payload.get("error")
self.detail = payload.get("detail") or payload.get("error_description")
self.request_id = (payload.get("meta") or {}).get("requestId")
self.operation_id = payload.get("operationId")
super().__init__(
f"{status} {self.error}: {self.detail} "
f"(request {self.request_id}, operation {self.operation_id})"
)
class CrossTradePartner:
def __init__(self, client_id, client_secret, redirect_uri):
self.auth = (client_id, client_secret) # HTTP Basic on every token request
self.redirect_uri = redirect_uri
self.session = requests.Session()
self._partner_token = None
self._partner_expires = 0.0
self.member_tokens = {} # external_id -> this member's OAuth pair
@staticmethod
def payload(response):
data = response.json()
if not response.ok or data.get("success") is False:
raise PartnerApiError(response.status_code, data)
return data
def token_request(self, form):
response = self.session.post(
TOKEN_URL,
auth=self.auth,
data=form, # OAuth uses form encoding, not JSON.
timeout=15,
)
return self.payload(response)
# Partner token: one service credential for the control plane.
def partner_token(self):
if self._partner_token is None or time.monotonic() >= self._partner_expires - 60:
started = time.monotonic()
token = self.token_request({
"grant_type": "client_credentials",
"scope": "partner:read partner:provision",
"resource": PARTNER_API,
})
self._partner_token = token["access_token"]
self._partner_expires = started + token["expires_in"]
return self._partner_token
# Member pairs: a separate authorization for each external_id.
def store_member_pair(self, external_id, pair, started):
self.member_tokens[external_id] = {
"access_token": pair["access_token"],
"refresh_token": pair["refresh_token"],
"expires_at": started + pair["expires_in"],
"scope": pair["scope"],
}
def exchange_code(self, external_id, code, code_verifier):
started = time.monotonic()
pair = self.token_request({
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"redirect_uri": self.redirect_uri,
# Omit resource to keep both approved API audiences.
})
self.store_member_pair(external_id, pair, started)
def refresh_member(self, external_id):
current = self.member_tokens[external_id]
started = time.monotonic()
try:
pair = self.token_request({
"grant_type": "refresh_token",
"refresh_token": current["refresh_token"],
})
except PartnerApiError as exc:
if exc.error == "invalid_grant":
del self.member_tokens[external_id] # Do not retry this retired or revoked token.
raise PermissionError(f"Start a new authorization for {external_id}") from exc
raise
# Replace the entire pair before issuing another API request.
self.store_member_pair(external_id, pair, started)
def member_token(self, external_id):
if external_id not in self.member_tokens:
raise PermissionError(f"No member authorization stored for {external_id}")
if time.monotonic() >= self.member_tokens[external_id]["expires_at"] - 60:
self.refresh_member(external_id)
return self.member_tokens[external_id]["access_token"]
# Control plane: always uses the service's PARTNER token.
def call(self, method, path, body=None, headers=None, params=None):
response = self.session.request(
method,
PARTNER_API + path,
headers={
**(headers or {}),
"Authorization": f"Bearer {self.partner_token()}",
},
params=params,
json=body,
timeout=30,
)
payload = self.payload(response)
payload["_status"] = response.status_code
payload["_headers"] = response.headers
return payload
@staticmethod
def pkce_pair():
verifier = secrets.token_urlsafe(48)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return verifier, challenge
def create_member(self, external_id, email, name):
verifier, challenge = self.pkce_pair()
body = {
"external_id": external_id,
"email": email,
"name": name,
"environments": ["demo"],
"authorization": {
"scope": "tv:read manage:read",
"redirect_uri": self.redirect_uri,
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": secrets.token_urlsafe(16),
"delivery": "pickup",
},
}
key = f"create-{external_id}-{secrets.token_hex(8)}"
# Production: persist body, verifier and key BEFORE sending this request.
answer = self.call("POST", "/users", body, headers={"Idempotency-Key": key})
return answer, verifier
def wait_for_code(self, external_id, flow_id, timeout=900, every=5):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
answer = self.call("POST", f"/users/{external_id}/authorizations/{flow_id}/code")
if answer["authorization"]["status"] == "code_ready":
return answer["authorization"]["code"]
if answer["authorization"]["status"] == "exchanged":
raise RuntimeError("the code was already exchanged")
time.sleep(every) # 202 pending
raise TimeoutError("the member did not allow the app in time")
def member(self, external_id):
answer = self.call("GET", f"/users/{external_id}")
return answer["member"], answer["_headers"].get("ETag")
def events(self, cursor=None, limit=200):
params = {"limit": limit}
if cursor is not None:
params["cursor"] = cursor
return self.call("GET", "/events", params=params)
def revoke(self, external_id, reason="subscription_canceled"):
return self.call("POST", f"/users/{external_id}/revoke", {
"reason": reason,
})
# Trading and Management: always use the selected MEMBER's token.
def member_get(self, external_id, api, path=""):
response = self.session.get(
MEMBER_APIS[api] + path,
headers={"Authorization": f"Bearer {self.member_token(external_id)}"},
timeout=30,
)
return self.payload(response)["data"]
def accounts(self, external_id):
return self.member_get(external_id, "trading", "/accounts")
def positions(self, external_id, account_name):
account_path = quote(account_name, safe="")
return self.member_get(external_id, "trading", f"/accounts/{account_path}/positions")
def monitors(self, external_id):
return self.member_get(external_id, "management", "/tradovate/monitors")
def main():
partner = CrossTradePartner(
os.environ["XT_CLIENT_ID"],
os.environ["XT_CLIENT_SECRET"],
os.environ["XT_REDIRECT_URI"],
)
me = partner.call("GET", "/me")["partner"]
print(f"{me['name']}: {me['status']}, {me['open_seats']}/{me['seat_cap']} seats")
if not me["policy"]["allows_new_seats"]:
raise SystemExit("Your service is not currently allowed to create seats.")
external_id = os.environ.get("XT_EXTERNAL_ID", "th_8831")
created, verifier = partner.create_member(
external_id,
os.environ["XT_MEMBER_EMAIL"],
os.environ.get("XT_MEMBER_NAME", "Jamie Rivera"),
)
if not created.get("connect"):
raise SystemExit(
f"Member already exists in state {created['member']['status']}. "
"Use connect-link for a fresh authorization, or reopen if revoked."
)
print("Deliver this private link to the member:", created["connect"]["url"])
flow_id = created["connect"]["flow_id"]
code = partner.wait_for_code(external_id, flow_id)
partner.exchange_code(external_id, code, verifier)
print("Member allowed:", partner.member_tokens[external_id]["scope"])
member, etag = partner.member(external_id)
print("seat:", member["status"], "grant active:", member["grant"] and member["grant"]["active"], "version:", etag)
# Each helper selects this member's token from member_tokens[external_id].
account_name = os.environ.get("XT_ACCOUNT")
if not account_name:
accounts = partner.accounts(external_id) # Requires all linked identities consented.
print("Trading accounts:", json.dumps(accounts, indent=2))
if not accounts:
raise SystemExit("No accounts synced yet. Read again after the broker sync completes.")
account_name = accounts[0]["name"]
positions = partner.positions(external_id, account_name)
print(f"Open positions for {account_name}:", json.dumps(positions, indent=2))
capabilities = partner.member_get(external_id, "management")
print("Delegated operation catalog:", json.dumps(capabilities["operations"], indent=2))
print("Management monitors:", json.dumps(partner.monitors(external_id), indent=2))
for event in partner.events()["events"]:
if event.get("external_id") == external_id:
print("event:", event["sequence"], event["type"])
if __name__ == "__main__":
main()
Endpoint quick reference
| Call | Token | Scope | Page |
|---|---|---|---|
POST /v1/oauth/token (client_credentials) | client id and secret (Basic) | Partner Authentication | |
GET /v1/api/partner/me | partner | partner:read | GET Partner Profile |
POST /v1/api/partner/users | partner | partner:provision | POST Create Member |
GET /v1/api/partner/users | partner | partner:read | GET Members |
GET .../users/{external_id} | partner | partner:read | GET Member |
GET /v1/api/partner/brokers | partner | partner:read | GET Broker Status |
GET .../users/{external_id}/brokers | partner | partner:read | GET Member Broker Status |
POST .../users/{external_id}/connect-link | partner | partner:provision | POST Create Connect Link |
POST .../users/{external_id}/authorizations/{flow_id}/code | partner | partner:provision | POST Pick Up Authorization Code |
POST /v1/oauth/token (authorization_code, refresh_token) | client id and secret (Basic) | Partner Authentication | |
POST /v1/oauth/revoke | client id and secret (Basic) | Partner Authentication | |
GET /v1/api/tv/accounts, GET .../accounts/{account}/positions | member | tv:read (implied by tv:trade) | Delegated trading |
| Trading order and position commands | member | tv:trade; Idempotency-Key required | Delegated trading |
GET /v1/api/manage, GET .../tradovate/monitors | member | manage:read (implied by manage:write) | Delegated management |
| Supported Management writes | member | manage:write; starting automation also requires tv:trade and both audiences | Delegated management |
GET /v1/api/partner/events | partner | partner:read | GET Events |
GET /v1/api/partner/usage (optional from and to dates) | partner | partner:read | GET Usage |
POST .../users/{external_id}/revoke | partner | partner:provision | POST Revoke Member |
POST .../users/{external_id}/reopen | partner | partner:provision | POST Reopen Member |