Errors & rate limits
One error envelope across every endpoint. Per-minute rate limits scaled by plan, surfaced via standard headers on every response.
Error envelope
The same shape on every endpoint, on every status code from 400 upward.
{
"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. It points us at the exact log entry for your request.
Error codes
Stable, machine-readable codes. The HTTP status is set accordingly.
| 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 a date earlier than the universe coverage window (we backfill from ticker IPO / first-seen date). Every paid plan ships all-time history per ticker, so this fires on no-plan / lapsed states or on requests that genuinely predate coverage. The response includes `max_history_days` and the earliest allowed cutoff so your code can clamp. |
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. Emitted by: Create a universe |
resource_limit_reached | 403 | You've hit the plan cap on a resource (universes or webhooks). Delete an existing one or upgrade. (Every paid plan ships unlimited universes and webhooks — this only fires on internal/Enterprise caps.) Emitted by: Create a universe |
slug_taken | 409 | A POST tried to create a universe with a slug that already exists in your account. Emitted by: Create a universe |
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. |
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. Emitted by: Create a universe |
scan_asof_tier_required | 403 | You passed `?asof=` to `/v2/scan` on an account whose plan does not include historical scan. Every paid plan now ships all-time `?asof=` history; this surfaces only on no-plan / lapsed-trial states. |
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. |
compile_failed | 400 | A SQL expression in a custom-signal body or a scan `q` failed to compile against the live column whitelist. The response includes a structured `errors` array — one entry per problem with `code`, `message`, optional `pos` (character offset), and an optional `suggestion`. |
signal_referenced | 409 | A `DELETE /v2/signals/{name}` was refused because another custom signal references the deleted name. The response carries a `referencing_signals` array so you can audit before forcing. Pass `?force=true` to delete anyway; downstream references will fail compilation until you fix them. Emitted by: Delete a custom signal |
name_collision | 409 | A custom signal `POST` was rejected because the chosen name already exists — either as a built-in column or as another of your own custom signals. Built-ins win: rename your signal. Emitted by: Create a custom signal |
recursion_depth_exceeded | 400 | A custom signal references another custom signal whose own references make the inline depth exceed 5. The compiler enforces a depth cap to keep predicate-compilation deterministic and cheap. Flatten the chain or reduce nesting. |
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 | ~166 req/sec |
| Enterprise | custom | — |
The window is a rolling-minute bucket: brief bursts above the sustained rate are absorbed as long as you stay under your per-minute cap. When you cross the limit you get 429 rate_limited with a Retry-After header pointing at the next minute boundary (always under 60s).
Every successful response (and every 429) includes rate-limit headers. Use them to back off proactively rather than waiting to be limited.
| Header | Description |
|---|---|
X-RateLimit-Limit | Per-minute request limit for the API key (600/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. |
No daily cap. The per-minute window is what catches runaway clients; daily quotas mostly punish honest traffic without solving abuse. See authentication for the full plan comparison.
Backoff guidance
What to do when you get a 429.
- Read the
Retry-Afterheader on the 429 response. Wait at least that many seconds before retrying. - Use exponential backoff for transient
5xxerrors, starting at 1 second and doubling. Cap at 60 seconds. - Use the
X-RateLimit-Remainingheader to throttle yourself proactively rather than racing into the cap. - Treat
internal_error(500) as retryable. Treatinvalid_request(400),invalid_query(400), andunauthorized(401) as fatal; retrying won't help.