Skip to main content

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:

  1. Get a partner token for your service (client credentials).
  2. Create a member: a CrossTrade account your service funds, plus a one-time Connect link.
  3. 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.
  4. Collect the authorization code (pickup delivery, server to server).
  5. Exchange the code for a member token and a refresh token.
  6. 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.
  7. Follow the lifecycle through the events feed or signed webhooks.
  8. 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:

WhatWhere it comes fromNotes
Client id xt_client_...API Keys, shown on the client cardPublic identifier
Client secret xt_cs_...Shown once when the client is created or rotatedKeep it in a secrets store or an environment variable, never in a browser or a form body
Redirect URIRegistered on the clientRequired in every authorization, even with pickup delivery, and checked again at code exchange
Partner scopesRegistered on the clientpartner:read, partner:provision
Member scopesRegistered on the clientThe 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

HeaderValue
AuthorizationBasic base64(client_id:client_secret)
Content-Typeapplication/x-www-form-urlencoded
Form fieldValue
grant_typeclient_credentials
scopepartner:read partner:provision (or just partner:read for a read-only token)
resourcehttps://app.crosstrade.io/v1/api/partner (optional; this is the only resource a partner token can carry)
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"])

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.

StatuserrorWhy
401invalid_clientWrong id or secret, or the secret was sent in the form body instead of the Basic header
400invalid_scopeA scope your client is not registered for
400invalid_targetA 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.

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"

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.

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()

The create request​

POST https://app.crosstrade.io/v1/api/partner/users

HeaderValue
AuthorizationBearer xt_at_... (partner token with partner:provision)
Content-Typeapplication/json
Idempotency-KeyA key unique to this create, for example create-th_8831-1. Required.
Body fieldMeaning
external_idYour permanent id for this member: 1–64 letters, digits, ., _ or -.
emailA new address gets a managed CrossTrade account; an existing CrossTrade address returns 409 email_in_use.
nameDisplay name
environmentsEnvironment names within your partner profile; these examples request demo.
authorization.scopeWhat your app will ask the member to allow, within your client's member scopes
authorization.redirect_uriOne of your registered redirect URIs
authorization.code_challenge, code_challenge_methodThe PKCE challenge and S256
authorization.stateA random value you check later (redirect delivery) or use to correlate (pickup)
authorization.deliverypickup (your server fetches the code) or redirect (the browser brings it to redirect_uri)
metadataOptional JSON object of at most 1,024 serialized bytes, returned with the member
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")

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:

StatusMeaningWhat to do
409 email_in_useThat email already has a CrossTrade account. No new account or seat is created.Ask the member for a different email address.
200The 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_failedA field is wrong; fields names it.Fix and resend under a new key.
409 external_id_conflictThat external_id belongs to a member with a different email or name.Pick another id.
409 idempotency_conflictSame key, different body.Use a new key per distinct request.
409 seat_cap_reachedYour 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:

  1. accepts the required agreements (unless the current versions were already accepted),
  2. links a Tradovate login,
  3. 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.

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.

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

Statusauthorization.statusMeaning
202pendingThe member has not pressed Allow yet. Ask again later.
200code_readyauthorization.code (xt_ac_...) and expires. Exchange it within 300 seconds.
200exchangedThe code was already exchanged; no credentials are returned again.
410 flow_closedExpired, denied, revoked, or replaced by a newer link. Request a new link.
404 not_foundNot 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.

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"])

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_..."
}
}
Redirect delivery instead

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 fieldValue
grant_typeauthorization_code
codeThe xt_ac_... code from step 4
code_verifierThe PKCE verifier from step 2
redirect_uriThe same redirect_uri you put in the authorization, also with pickup delivery
resourceOptional. 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.

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"])

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.

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

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}

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"])
{
"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.

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"])
{
"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

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"])

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

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"])

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.

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.

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).

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
{
"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:

HeaderValue
X-CrossTrade-Event-IdThe event id, the same on every retry
X-CrossTrade-Event-TypeThe event type
X-CrossTrade-Signaturet=<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.

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

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.

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"])

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.

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.

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.requestId with 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, read GET /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: honor Retry-After (also in the body as retryAfter). 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_unknown may have reached the broker: follow statusUrl (or GET /v1/api/tv/operations/{operationId}), then inspect orders and positions. Do not resend with a fresh key. Every order command requires an Idempotency-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​

CallTokenScopePage
POST /v1/oauth/token (client_credentials)client id and secret (Basic)Partner Authentication
GET /v1/api/partner/mepartnerpartner:readGET Partner Profile
POST /v1/api/partner/userspartnerpartner:provisionPOST Create Member
GET /v1/api/partner/userspartnerpartner:readGET Members
GET .../users/{external_id}partnerpartner:readGET Member
GET /v1/api/partner/brokerspartnerpartner:readGET Broker Status
GET .../users/{external_id}/brokerspartnerpartner:readGET Member Broker Status
POST .../users/{external_id}/connect-linkpartnerpartner:provisionPOST Create Connect Link
POST .../users/{external_id}/authorizations/{flow_id}/codepartnerpartner:provisionPOST Pick Up Authorization Code
POST /v1/oauth/token (authorization_code, refresh_token)client id and secret (Basic)Partner Authentication
POST /v1/oauth/revokeclient id and secret (Basic)Partner Authentication
GET /v1/api/tv/accounts, GET .../accounts/{account}/positionsmembertv:read (implied by tv:trade)Delegated trading
Trading order and position commandsmembertv:trade; Idempotency-Key requiredDelegated trading
GET /v1/api/manage, GET .../tradovate/monitorsmembermanage:read (implied by manage:write)Delegated management
Supported Management writesmembermanage:write; starting automation also requires tv:trade and both audiencesDelegated management
GET /v1/api/partner/eventspartnerpartner:readGET Events
GET /v1/api/partner/usage (optional from and to dates)partnerpartner:readGET Usage
POST .../users/{external_id}/revokepartnerpartner:provisionPOST Revoke Member
POST .../users/{external_id}/reopenpartnerpartner:provisionPOST Reopen Member