Partner Authentication
Partner authentication uses OAuth client credentials for your service and authorization code with PKCE for each member. Both use the same confidential OAuth client. Token requests are form-encoded POSTs to https://app.crosstrade.io/v1/oauth/token; responses are JSON with Cache-Control: no-store.
The resulting member token is what opens the existing Trading and Management APIs for that user. Your partner token handles onboarding and seat administration. Keep the two token types separate in your application.
How the authentication flow works
Two credentials serve different purposes:
| Credential | How you get it | What it opens | Lifetime |
|---|---|---|---|
Partner token xt_at_... | client_credentials with your client id and secret | /v1/api/partner/* (members, events, usage, your policy) | 1 hour, request a new one |
Member token xt_at_... plus refresh token xt_rt_... | authorization_code with PKCE, after the member allows your app on hosted Connect | /v1/api/tv/* and /v1/api/manage/* for that member, within what they approved | 1 hour; refresh for up to 90 days |
Blue: your service's partner credentials. Gold: the member's hosted consent. Green: that member's API access. Numbers match the code guide walkthrough. Open full-size diagram.
Three rules apply to every integration:
- The client authenticates with HTTP Basic on every token request. A secret in the form body is refused with
invalid_client. - Member tokens exist only because the member pressed Allow on a page CrossTrade hosts. Your app never sees a CrossTrade password, and it cannot mint a member token from the partner token.
- Every token is bound to an audience. A partner token on
/v1/api/tvand a member token on/v1/api/partnerboth answer401.
Partner tokens (your service)
Your confidential client authenticates with HTTP Basic (client_secret_basic) and requests the client_credentials grant:
POST /v1/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=partner:read%20partner:provision&resource=https://app.crosstrade.io/v1/api/partner
{
"access_token": "xt_at_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "partner:read partner:provision"
}
Partner tokens live one hour, carry only the partner audience and are used as Authorization: Bearer xt_at_... on /v1/api/partner/*. Request another client-credentials token when needed; this grant does not issue a refresh token. Client secrets rotate with a planned overlap (both secrets valid for up to 24 hours) or, in an emergency, immediately. Emergency rotation invalidates tokens issued under the old client version and stops your app's copiers until explicitly re-enabled under current authority. Protective monitors continue enforcing their limits.
Member tokens (your app, acting for a member)
Member tokens come only from the hosted Connect flow. You never see the member's password. The flow is authorization_code with PKCE (S256 only):
- Create the member (or request a
connect-link) with your PKCEcode_challenge, thescopeyou want, your registeredredirect_uri, astateand thedeliverymode. - The member completes the hosted page. With
delivery: redirectthe code arrives at yourredirect_uriwithstateandiss; withdelivery: pickupyou fetch it withPOST /v1/api/partner/users/{external_id}/authorizations/{flow_id}/codeusing your partner token. - Exchange the code within 300 seconds:
Register it under Partner Platform, API Keys: an HTTPS page on your own domain, never a CrossTrade address. With delivery: redirect the member's browser lands there carrying the code; with delivery: pickup nobody visits it, but the code is still bound to it and the exchange below must send the same value. Hosted Connect has the full comparison.
POST /v1/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=xt_ac_...&code_verifier=...&redirect_uri=https://partner.example/oauth/callback
{
"access_token": "xt_at_...",
"refresh_token": "xt_rt_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "tv:read tv:trade manage:read"
}
Scopes
Use a member access token from the exchange above directly on either approved API:
GET /v1/api/tv/accounts/DEMO12345678/positions
Authorization: Bearer <MEMBER_ACCESS_TOKEN>
GET /v1/api/manage/tradovate/monitors
Authorization: Bearer <MEMBER_ACCESS_TOKEN>
The member is selected by the token, and the account in the first URL must belong to an identity that member shared. See the code guide for runnable examples.
| Scope | Audience | Grants |
|---|---|---|
partner:read | /v1/api/partner | Read members, usage, events, your policy |
partner:provision | /v1/api/partner | Create, link, revoke and reopen members (includes read) |
tv:read | /v1/api/tv | Read the member's consented Tradovate identities |
tv:trade | /v1/api/tv | Place, change and cancel orders there (includes tv:read) |
manage:read | /v1/api/manage | Read the member's Tradovate monitors, copiers and ATM templates |
manage:write | /v1/api/manage | Create and change the automation your app owns (includes manage:read) |
A member token cannot exceed your client's registered member scopes, the authorization request or the member's approval. Read the returned scope rather than assuming every requested permission was granted.
Omit resource at code exchange to keep the audiences covered by the approved scopes. To narrow access, send an exact, absolute resource URL: https://app.crosstrade.io/v1/api/tv or https://app.crosstrade.io/v1/api/manage. Scopes for excluded audiences are dropped, and refreshing cannot restore that access. The authorization request can also narrow audiences through its resources array.
For partner tokens, scope=partner:read gives read-only access; an unregistered scope is invalid_scope. The only valid partner resource is https://app.crosstrade.io/v1/api/partner. Starting a monitor or copier needs manage:write and tv:trade on a member token carrying both Management and Trading audiences.
Refresh rotation
POST /v1/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=xt_rt_...
{
"access_token": "xt_at_...",
"refresh_token": "xt_rt_...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "tv:read tv:trade manage:read"
}
Every successful rotation returns a new pair, retires the presented refresh token and deactivates its associated access token. Serialize refreshes per member and atomically replace the stored pair before using it.
An identical refresh request retried less than 30 seconds after rotation returns the same token pair only while its successor refresh token is still active. If that successor was already used, the retry returns invalid_grant; it does not follow the chain to the newest pair. Reusing a retired refresh token at or after 30 seconds revokes the whole family. On invalid_grant, stop retrying that token and start a new authorization if you have no current pair. Families expire at most 90 days after issuance; rotation does not extend that deadline. Use expires_in for the access-token lifetime, which can be shorter near that deadline.
Revocation
POST /v1/oauth/revoke uses HTTP Basic client authentication and a form-encoded token parameter. An access token revokes that access token only. A refresh token revokes its entire family, including access tokens in that family. Unknown or already-revoked tokens also return 200 with an empty object:
{}
Token revocation does not end a funded seat. Use the member revoke endpoint to end funding and revoke the member's grant and credentials. Managed members request this through your service because they cannot sign in to My Account while their seat is open. A user with ordinary dashboard access can also disconnect a connected app there.
Grant revocation stops your app's copiers; protective monitors keep enforcing their limits. Refresh-family revocation emits authorization.family_revoked; a dashboard disconnect emits authorization.revoked. Revoking a seat emits member.revoked and also ends the grant. Revoking a single access token does not emit those grant or family events.
Failures
The token endpoint answers failures in the RFC 6749 shape; a 401 adds WWW-Authenticate: Basic realm="crosstrade":
{
"error": "invalid_client",
"error_description": "client authentication method mismatch"
}
| Status | error | Meaning |
|---|---|---|
| 401 | invalid_client | Unknown client, wrong secret, or the secret was sent in the form body instead of the Basic header |
| 400 | invalid_grant | The code is expired, used, or bound to another client or redirect_uri; the PKCE verifier does not match; the refresh token is retired or its family is revoked |
| 400 | invalid_scope | A scope the client is not registered for |
| 400 | invalid_target | A resource the client may not address |
| 400 | unauthorized_client | Your client is not registered for that grant type |
| 400 | unsupported_grant_type | Anything but client_credentials, authorization_code and refresh_token |
| 400 | invalid_request | A missing or malformed parameter; error_description names it |
| 503 | temporarily_unavailable | Token or policy storage is temporarily unavailable |
| 500 | server_error | The token request failed unexpectedly |
Bearer failures on the APIs carry an RFC 6750 WWW-Authenticate challenge with the resource metadata URL. Common codes:
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_token / unauthorized | Missing, expired, revoked or wrong-audience token |
| 403 | insufficient_scope | The token lacks the scope this operation needs |
| 403 | partner_suspended, partner_disabled, partner_not_active | Your service state does not allow this operation |
| 503 | auth_unavailable, policy_unavailable | CrossTrade could not verify the token; retry shortly |