# Errors & rate limits

One error envelope across every endpoint.

```json
{
  "error": {
    "code": "invalid_query",
    "message": "Unknown field 'percent_change'. Did you mean 'day_change_pct'?",
    "request_id": "req_01J9PZA1F2C9V3XQ"
  }
}
```

Always include the `request_id` when you contact support.

## Error codes

| Code | Status | When you see it |
|------|--------|-----------------|
| `bad_request` | 400 | The request was malformed: missing required parameter, invalid format, value out of range, or unsupported combination. |
| `invalid_query` | 400 | The `q` clause failed to parse, references an unknown column, or uses a disallowed SQL feature (semicolons, comments, INSERT/UPDATE keywords). |
| `unknown_signal` | 404 | The signal name in the path is not a column on `ticker`. Check /api/schema and /api/flags for valid names. |
| `signal_not_available_at_interval` | 400 | You requested a fast resolution (1h or 1m) for a signal that is only computed daily. Slow-moving signals like SMAs, RSI, MACD, and fundamentals are daily-only. |
| `history_window_exceeded` | 400 | You asked for data older than your plan's historical depth. The response includes `max_history_days` and the earliest allowed cutoff so your code can clamp the request. Hobby has no history (live snapshot only). Pro: 5 years. Scale and Enterprise: all-time. Five demo tickers (AAPL, TSLA, NVDA, SPY, BTC-USD) get full history on every plan. |
| `unknown_tickers` | 400 | A universe POST/PATCH referenced symbols that are not on the active scanner. Check spelling and that the symbol is currently tracked. |
| `resource_limit_reached` | 403 | You've hit the plan cap on a resource (universes, rules, or webhooks). Delete an existing one or upgrade. |
| `slug_taken` | 409 | A POST tried to create a universe or rule with a slug that already exists in your account. |
| `universe_not_found` | 404 | A `?universe=` parameter referenced a slug that is neither a system universe (`top_10`, `top_100`) nor one your account owns. |
| `rule_not_found` | 404 | A `?rule=` parameter (or a webhook `rule_id`) references a rule that doesn't exist on your account. |
| `premium_signal_required` | 402 | Your `q`, `order`, `fields`, or signal path references a column gated to plans with premium signals (Scale or above). |
| `cadence_above_plan_max` | 403 | A webhook POST/PATCH asked for a faster cadence than the plan allows. The response includes the plan's maximum cadence so you can fall back. |
| `index_building` | 503 | A Firestore composite index needed for a list query is still building (typically <1 minute on initial deploy). Retry shortly. |
| `not_found` | 404 | The requested resource (ticker, webhook, delivery) does not exist or is not accessible to your API key. |
| `unauthenticated` | 401 | The Authorization header is missing, malformed, or carries a key that has been revoked or never existed. API keys are passed as `Authorization: Bearer tb_(test\|live)_<key>`. |
| `subscription_required` | 402 | The key is valid but the owning account has no active subscription. Pick a plan in the dashboard or contact sales for Enterprise. |
| `webhook_tier_required` | 403 | You tried to create a webhook on an account whose current plan tier does not include webhooks. Surfaces after a subscription is canceled and the account falls back to the no-webhook fallback tier. |
| `universes_tier_required` | 403 | You tried to create a universe on an account whose current plan tier does not include user-defined universes. Surfaces after a subscription is canceled and the account falls back to the no-universes fallback tier. |
| `rules_tier_required` | 403 | You tried to create a saved rule on an account whose current plan tier does not include saved rules. Surfaces after a subscription is canceled and the account falls back to the no-rules fallback tier. |
| `scan_asof_tier_required` | 403 | You passed `?asof=` to `/v2/scan` on an account whose current plan tier does not include historical scan. Historical scan is included on Pro (5 years) and Scale (all-time). Hobby is live-only, but the five demo tickers (AAPL, TSLA, NVDA, SPY, BTC-USD) get full asof history on every plan. |
| `webhook_limit_reached` | 403 | You've hit the maximum number of webhook subscriptions your plan allows. Delete an inactive one or upgrade to add another. |
| `rate_limited` | 429 | You exceeded your plan's per-minute request limit. The `Retry-After` header indicates seconds until the next minute boundary. |
| `internal` | 500 | Something went wrong on our side. Safe to retry with exponential backoff. Include the `request_id` if you contact support. |

## Rate limit

Per-key, per-minute window. The cap depends on your plan.

| Plan | Per-minute limit | Sustained equivalent |
|------|------------------|----------------------|
| Hobby | 60 / min | ~1 req/sec |
| Pro | 2,000 / min | ~33 req/sec |
| Scale | 10,000 / min | ~167 req/sec |
| Enterprise | custom | — |

The window is a rolling-minute bucket. When you cross the limit you get `429 rate_limited` with a `Retry-After` header pointing at the next minute boundary (always under 60s).

### Rate-limit headers

| Header | Description |
|--------|-------------|
| `X-RateLimit-Limit` | Per-minute request limit for the API key (60/2,000/10,000 for Hobby/Pro/Scale, custom for Enterprise). |
| `X-RateLimit-Remaining` | Requests remaining in the current minute window. |
| `X-RateLimit-Reset` | Seconds remaining until the per-minute window resets. |
| `Retry-After` | Seconds to wait before retrying. Present only on 429 responses. |

## Backoff guidance

- Read the `Retry-After` header on a 429 and wait at least that many seconds.
- Use exponential backoff for transient 5xx, starting at 1 second and doubling. Cap at 60 seconds.
- Use `X-RateLimit-Remaining` to throttle yourself proactively.
- Treat `internal_error` (500) as retryable. Treat `invalid_request` (400), `invalid_query` (400), and `unauthorized` (401) as fatal.
