Skip to main content
Failures use one envelope. Retry only when error.retryable is true.
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Please slow request rate and retry.",
    "retryable": true
  }
}
code is the stable identifier to branch on; message is human-readable and may change. The table below carries the live messages captured against production.
CodeHTTPRetryableWhat to do
UNAUTHORIZED401NoSend a valid key in the X-API-Key header. Message: “Missing or invalid X-API-Key header.” A revoked key lands here within about a minute of revocation. Do not retry the same key.
PLAN_UPGRADE_REQUIRED403NoThe route is gated above the key’s plan. Message: “This endpoint requires Starter or Builder. Free includes search, coverage, snapshot, and valuation.” Upgrade or stay on the four Free routes. See plans and limits.
SYMBOL_INVALID400NoThe symbol failed grammar. Message: “Symbol must match ^[A-Z0-9-]{1,8}$ with dash-suffix share classes like BRK-B.” Normalize before sending (BRK.B -> BRK-B).
SYMBOL_UNKNOWN404NoWell-formed symbol, not in the universe. Message: “Symbol ‘ZZZQ’ is not in the supported universe. Use /v2/search to discover supported tickers.” Resolve it via /v2/search or /v2/coverage.
NOT_FOUND404NoThe request path is not a StockContext route, a typo or the wrong base path. Message: “Endpoint not found.” Distinct from SYMBOL_UNKNOWN (which is a valid route with an unknown symbol). This 404 fires before key verification, so it carries no rate-limit headers.
PARAM_INVALID400NoA query parameter is missing, unknown, or out of range. The message names the offending parameter and, for enum/range errors, lists the allowed values: e.g. “range ‘99z’ is not a recognized window; allowed: 1m, 3m, 6m, …”, “Unknown query param(s): ‘bogusparam’. Allowed params: symbol.”, “Invalid request parameters: query param ‘symbol’: field required.” Branch on the named parameter. Duplicate params are tolerated (last value wins: ?symbol=AAPL&symbol=MSFT returns MSFT).
RATE_LIMITED429YesPer-minute, daily, or monthly limit hit. Honor Retry-After (seconds), lower concurrency, and cache repeat reads. Limits are in plans and limits.
UPSTREAM_UNAVAILABLE503YesA market-data or SEC-backed source is briefly unavailable with no usable cache. Retry with backoff; keep first-hit SEC paths tolerant of latency.
AUTH_UPSTREAM_UNAVAILABLE503YesKey verification upstream is briefly unavailable. Retry shortly; the response sends Retry-After: 5.
PREREQUISITE_MISSING409NoMCP resources only, never a REST endpoint. A resource was read before the tool call that populates it. Run the prerequisite tool first, then read the resource.
The plan gate runs before parameter validation: on a Free key, a bad parameter on a paid route returns 403 PLAN_UPGRADE_REQUIRED, not 400. PARAM_INVALID now names the offending parameter (and usually its allowed values), so you can branch on it; the per-endpoint pages remain the authority on the full contract: allowed parameter names, ranges, and enum values (form lists, range/cadence values, the 2–12 symbol bound on /v2/compare, and so on).

The 429, with headers

Successful responses and every post-auth error (400 PARAM_INVALID, 403 PLAN_UPGRADE_REQUIRED, 404 SYMBOL_UNKNOWN, 429 RATE_LIMITED) carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (Unix epoch seconds); track your budget from all of them, not just successes. Only failures that never reach key verification omit them: a pre-auth 401 UNAUTHORIZED and an unmatched-route 404 NOT_FOUND. A 429 adds Retry-After in seconds. This is a live rate-limit response from /v2/snapshot:
HTTP 429: headers + body
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1780669200
Retry-After: 21

{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Please slow request rate and retry.",
    "retryable": true
  }
}
Retry-After: 21 is the live-captured wait; sleep at least that long before retrying. If Retry-After is absent on a retryable error, fall back to exponential backoff with jitter.

A retry loop that honors Retry-After

Retry only when retryable is true, prefer the server’s Retry-After, and cap attempts so a sustained outage does not spin forever.
retry.py
import time
import httpx

RETRYABLE = {"RATE_LIMITED", "UPSTREAM_UNAVAILABLE", "AUTH_UPSTREAM_UNAVAILABLE"}


def get(client: httpx.Client, path: str, params: dict, max_attempts: int = 4) -> dict:
    for attempt in range(max_attempts):
        r = client.get(path, params=params)
        body = r.json()
        if "data" in body:
            return body["data"]

        err = body["error"]
        last = attempt == max_attempts - 1
        if err["code"] not in RETRYABLE or last:
            raise RuntimeError(f"[{err['code']}] {err['message']}")

        retry_after = r.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)

    raise RuntimeError("exhausted retries")

MCP surfaces the same errors

The MCP tools call the same REST endpoints and fail with the same code, message, and retryable. A tool error renders as a single string:
[RATE_LIMITED] Rate limit exceeded. Please slow request rate and retry. (retryable: true)
Use the REST envelope and the OpenAPI spec as the contract of record for status codes and shapes.