Skip to content

Discovery and errors

$STX in the examples is the base URL STX gives you with your invite.

STX publishes two metadata documents. Neither needs authentication. Both are built on the host you fetch them from, so read the endpoints from them rather than hard-coding them.

GET /.well-known/oauth-authorization-server (RFC 8414) lists the endpoints and what the server supports:

curl -s "$STX/.well-known/oauth-authorization-server"
{
"issuer": "https://<exchange>",
"authorization_endpoint": "https://<exchange>/oauth/authorize",
"token_endpoint": "https://<exchange>/oauth/token",
"revocation_endpoint": "https://<exchange>/oauth/revoke",
"introspection_endpoint": "https://<exchange>/oauth/introspect",
"scopes_supported": ["profile.read", "balance.read", "portfolio.read", "orders.read", "transfers.read", "orders.write", "terms.write", "market_data", "events"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "none"]
}

scopes_supported holds both the member scopes and the app scopes; see Scopes. registration_endpoint is added only while dynamic registration is enabled.

GET /.well-known/oauth-protected-resource (RFC 9728) describes the API as an OAuth resource and names the server that issues its tokens. A client that receives a 401 from the API finds it through the resource_metadata in the challenge (see API errors).

{
"resource": "https://<exchange>",
"authorization_servers": ["https://<exchange>"],
"scopes_supported": ["profile.read", "balance.read", "portfolio.read", "orders.read", "transfers.read", "orders.write", "terms.write", "market_data", "events"],
"bearer_methods_supported": ["header"],
"resource_name": "STX exchange API"
}

The exchange is its own authorization server, so resource and the single authorization_servers entry are the same host. bearer_methods_supported is header: send the token in the Authorization header, never in a query string or form body.

Errors in an authorize request come back to your redirect_uri as ?error=<code>&state=<your state>, with no screen shown to the member unless noted.

error When
access_denied The member chose Deny on the consent screen.
unsupported_response_type response_type is not code.
invalid_request PKCE is missing (code_challenge with code_challenge_method=S256 is required), or scope is missing.
invalid_scope scope names an unknown scope, or nothing is left after narrowing to your client’s allow-list; see how a request is narrowed.
login_required prompt=none and the member is not signed in to STX.
consent_required prompt=none and the member has no active grant covering the request.
server_error STX could not issue the code after the member allowed; start the authorize request again.

An unknown or suspended client_id, or a redirect_uri that does not exactly match one registered for the client, is never redirected. The member sees an error page on STX instead, so the flow cannot be turned into an open redirector.

/oauth/token, /oauth/revoke and /oauth/introspect answer errors in the RFC 6749 shape:

{"error": "invalid_grant", "error_description": "the refresh token has already been used"}
error Status When
invalid_request 400 A required parameter is missing: grant_type, or for its grant code, redirect_uri, code_verifier or refresh_token; token on revoke.
invalid_client 401 Client authentication failed or was not sent. The response carries WWW-Authenticate: Basic.
invalid_grant 400 The code or refresh token is invalid, expired or already used, or redirect_uri does not match the authorize request. error_description says which.
unsupported_grant_type 400 grant_type is not authorization_code, refresh_token or client_credentials.
invalid_scope 400 On client_credentials: the requested scope is unknown, not an app scope, or not allowed for your client.
ip_not_allowed 403 Your app has an IP allowlist and this request came from another address. Checked after client authentication, for every grant type.
temporarily_unavailable 503 The request was not judged. The response carries Retry-After; retry with the same code or token.

A Web app or Server-to-server app authenticates with client_secret_basic only; a secret sent in the form body is not accepted. How to recover from a failed refresh is covered under rotation and reuse detection.

/oauth/revoke answers 200 for a known, unknown or another client’s token alike, and /oauth/introspect answers {"active": false} for any token that is not a live access token of yours, so neither reveals whether a token was valid.

POST /oauth/register uses the same {"error", "error_description"} shape.

Status error When
400 invalid_redirect_uri redirect_uris is missing or empty where callbacks are needed, or a callback is not https, a reverse-domain custom scheme or http://localhost / 127.0.0.1 / [::1] (any port) for an installed app, or carries a fragment.
400 invalid_client_metadata Any other field is missing or not allowed, including a write scope (orders.write, terms.write), which needs an IP allowlist only STX sets; see the field table.
429 rate_limited Too many registrations from your address this hour. The response carries Retry-After.
404 Dynamic registration is off.

These are the errors a call made with a token gets from /api/v1 and the WebSocket.

A missing, expired or revoked token is 401, pointing at the protected resource metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://<exchange>/.well-known/oauth-protected-resource"
{"error": "Missing or invalid OAuth access token"}

A call outside the effective scope is 403 with a JSON body and an RFC 6750 challenge naming the scope that would cover it:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="orders.write"
{"error": "insufficient scope for this operation", "scope": "orders.write"}

Send the member back through /oauth/authorize asking for that scope; the grant only widens, so nothing already granted is lost. A route no scope covers gets the same 403 with no scope. A path that is not a route at all is 404.

A call from an address outside your app’s IP allowlist is 403:

HTTP/1.1 403 Forbidden
{"error": "ip_not_allowed", "error_description": "this app does not allow requests from this IP address"}

On the WebSocket, a join the token does not cover is refused with {"reason": "insufficient_scope"}, and a handshake from an address outside the allowlist is refused with 403 and the header x-stx-error: 5301 - ip_not_allowed.

Handle the HTTP cases distinctly: 401 means refresh, then send the member back through consent if the refresh fails; 403 insufficient_scope means ask for more scope; 403 ip_not_allowed means call from an address on your allowlist.

v1.5.9Changelogllms.txtllms-full.txt