# Discovery and errors

> The OAuth metadata documents STX publishes, and the errors the OAuth endpoints and the API return.

Source: https://docs.stxapp.io/oauth/discovery-and-errors/

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

## Discovery

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.

### Authorization server metadata

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

```bash
curl -s "$STX/.well-known/oauth-authorization-server"
```

```json
{
  "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](/oauth/scopes/). `registration_endpoint` is added only while [dynamic registration](/oauth/tokens-and-security/#dynamic-registration) is enabled.

### Protected resource metadata

`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](#api-errors)).

```json
{
  "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.

## Authorize errors

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](/oauth/scopes/#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.

## Token endpoint errors

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

```json
{"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/tokens-and-security/#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.

## Registration errors

`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](/oauth/tokens-and-security/#dynamic-registration). |
| `429` | `rate_limited` | Too many registrations from your address this hour. The response carries `Retry-After`. |
| `404` | | Dynamic registration is off. |

## API errors

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](#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](/oauth/scopes/#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.
