Errors & rate limits
One error envelope across every endpoint. Per-minute rate limits scaled by plan, surfaced via standard headers on every rate-limited response.
Error envelope
The same shape on every endpoint, on every status code from 400 upward.
{
"error": "invalid_query",
"message": "Unknown field 'percent_change'. Did you mean 'day_change_pct'?",
"request_id": "req_9f2b8a1c03d4e5f6a7b8"
}Plan-gate errors — the 429 rate limit, the Free-plan 402 quota, and the webhook/stream tier and count limits — additionally carry code (duplicate of error), plan (your resolved plan key), and an upgrade_url.
Every response — success and error — carries the same opaque id as an X-Tickerbot-Request-Id header; error bodies echo it as request_id.
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. |
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`. |
context_ticker_unavailable | 400 | A `q` used the qualified context-ticker syntax (`spy.sma_50`) with a symbol we recognize but do not serve as a context — today `vix` and `dxy`. The message lists every supported context ticker. A qualifier that is not a context ticker at all is passed through to SQL instead and comes back as `invalid_query`. |
expanded_query_too_large | 400 | Your `q` is within the 4,000-character limit, but after custom-signal references were expanded into their SQL it exceeded the 100,000-character execution bound. Distinct from `bad_request` ("your query is too long") — this means "your query expands too much". Simplify the referenced custom signals or reduce nesting. |
expression_invalid_at_interval | 400 | A custom-signal history read at a fast interval referenced a column that does not exist at that grain (e.g. fundamentals on minute bars). Carries `sqlstate` — the underlying Postgres error code. Request `interval=1d`, or restrict the expression to columns stored at the requested interval. |
history_window_exceeded | 400 | Emitted only by the anonymous sandbox (`/v2/sandbox/*` as-of routes), whose history floor is 365 days. Keyed requests never receive this — as-of depth is unlimited with an API key. The response includes `max_history_days` and the earliest allowed cutoff so your code can clamp. |
interval_unavailable | 400 | The requested column set cannot be served at the requested interval. Two emitters: `full=true` on scan with a non-daily interval (only `1d` stores every column — `1m`/`1h` carry a strict subset), and `join=state` on events referencing a column not stored at that grain (the message lists where each missing column is available). Name the columns you need with `fields`, or request the interval that carries them. |
invalid_query | 400 | The `q` clause failed to parse, references an unknown column, or uses a disallowed SQL feature (semicolons, comments, INSERT/UPDATE keywords). |
kind_not_subscribable | 400 | Retired 2026-07-27 — no endpoint emits this. It briefly applied to `insider`, whose archive gained the ingestion timestamp the fire-on-ingest poller needs on the same day. All four event kinds (`dividend`, `split`, `insider`, `analyst`) are now subscribable. Documented so integrations that branch on it know it can no longer fire. |
not_a_flag_signal | 400 | An event-spans read (`GET /v2/signals/{signal}/{ticker}/events`) named a numeric field. Only boolean flags have on/off occurrence spans — a numeric like `rsi_14` has none. The message points at the endpoints that do serve numerics: `/v2/series` for the value series, `/v2/signals/{signal}?asof=` for threshold matches on a date. |
not_supported_for_custom_signal | 400 | Event spans are precomputed for built-in flags only, so `GET /v2/signals/{signal}/{ticker}/events` (and `kind=signal` event reads) refuse a custom-signal name — customs compile at query time. Use `GET /v2/series`, where customs are ordinary columns, or `/v2/scan?asof=` to find matches. |
not_supported_for_kind | 400 | A grammar param was used on an event kind that cannot serve it unscoped — today only `kind=signal`. Over the ~175M-row firing log, `q` and `join=state` require `signal=<name>`, which binds the query to an index instead of scanning. `group_by` is unavailable there entirely: an aggregate has no `limit` to stop at, so it reads every firing in the window whatever you name — which is also why a mixed `kind` list containing `signal` is refused. Filter mode (`kind`/`ticker`/`tickers`/`universe`/`from`/`to`/`limit`) needs none of this and is always open. Emitted by: Signal firings |
recursion_depth_exceeded | 400 | Retired — never emitted (verified 2026-08-10). A custom-signal chain nesting deeper than 5 levels fails compilation as `compile_failed` with a sub-error of code `forbidden_syntax` ("Custom signal references nest more than 5 levels deep (likely a circular reference)"). Branch on that instead. Documented so integrations that branch on it know it can no longer fire. |
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. |
too_many_custom_signals | 400 | An account-level ceiling on how many custom signals one account may author. Like `too_many_universes`, this is an anti-abuse guard rather than a plan limit — custom signals are unlimited on every plan, including Free, and upgrading does not raise this ceiling. Emitted by: Create a custom signal |
too_many_universes | 400 | An account-level ceiling on how many universes one account may hold (1,000). It is an anti-abuse guard, not a plan limit: the same on every plan including Free, and upgrading does not raise it. Only universes you created count — the system ones (`top_10`, `top_100`) are exempt. Delete some to create more. Emitted by: Create a universe |
unknown_parameter | 400 | The request carried a query or body parameter name the endpoint does not accept — usually a typo (`tickr`) or a param that belongs to a different endpoint. The message lists every name the endpoint accepts, aliases included. Emitted by: Subscribe to events |
unknown_tickers | 400 | A universe POST/PATCH referenced symbols we do not track, or that are no longer active. The message names the offenders (up to ten) and `disallowed` carries the full list. |
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>`. |
monthly_quota_exceeded | 402 | The Free plan's monthly call quota (10,000 calls per calendar month, UTC) is used up. The quota resets on the 1st; `resets_at` carries the exact instant. Paid plans have no monthly cap. Rejected requests (429s and this error) and server-side 5xx errors never consume quota. |
subscription_required | 402 | Retired 2026-07-18 — no endpoint emits this. A valid key is never rejected for lack of a subscription: new signups land on Free, cancelling returns you to Free, and a missing customer record is repaired automatically rather than refused. Documented so older integrations that branch on it know it can no longer fire. |
asof_tier_required | 403 | Retired 2026-07-31 — no endpoint emits this. The `asof` (point-in-time) param is unlimited-depth on every plan, including Free; the former 30-day Free window was removed. (Its predecessor `scan_asof_tier_required` never fired.) Documented so integrations that branch on it know it can no longer fire. |
free_scope | 403 | An anonymous sandbox bars call asked for more than the sandbox serves: it returns the latest trading day only. History, ranges, intraday depth, and pagination need an API key — the Free plan carries all of them, and signing up takes no card. Nothing about the request was malformed; it simply exceeded what an unauthenticated caller gets. Includes `upgrade_url`. |
plan_required | 403 | Retired 2026-07-31 — no endpoint emits this. Its one use was gating `join=state` on `GET /v2/events` (code `state_join_plan_required`) to paid plans; the point-in-time state join is now available on every plan, including Free. Documented so integrations that branch on it know it can no longer fire. |
resource_limit_reached | 403 | Retired — no endpoint emits this (verified 2026-08-10). It was the one 403 covering every cap at once; caps now return their own codes instead: `webhook_limit_reached` (or `webhook_tier_required` on Free) for webhook creates, `too_many_universes` / `too_many_custom_signals` for the account ceilings. Documented so integrations that branch on it know it can no longer fire. |
system_universe_immutable | 403 | A write (`PATCH`/`DELETE`) targeted a system universe (`top_10`, `top_100`). System universes are read-only — copy the constituents into a universe of your own to customize. Emitted by: Delete a universe |
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. The message names the next tier's cap. |
webhook_over_limit | 403 | Re-enabling a webhook is refused because the account holds more than the plan allows — typically after a downgrade left it over cap (on Free, any webhook is over a cap of 0). Distinct from `webhook_limit_reached`, which guards *creating* one. Delete webhooks to get within the limit, or upgrade. Emitted by: Re-enable a webhook |
webhook_tier_required | 403 | You tried to create a webhook on a plan that does not include them (the Free plan today). Webhooks start on the first paid tier — Hobby, at 10 of them — and the body carries an `upgrade_url` straight to billing. |
device_not_found | 404 | A webhook create with `channel=mobile_push` referenced a `device_id` that is not registered to your account. Device registrations are per-account — one account cannot route pushes to another's phone. Re-register via `POST /v2/devices/register` and retry with the returned id. |
not_found | 404 | The requested resource (ticker, webhook, delivery) does not exist or is not accessible to your API key — or the URL path itself is not a route. Unknown-path 404s always carry `docs_url` (the API reference), and when the wrong path is a recognized near-miss of a real route they also carry `suggestion`: the route you probably meant, e.g. `GET /v2/bars` → `/v2/tickers/{ticker}/bars/{interval}`. If your client sees `suggestion`, switch to that route — retrying the same path will 404 forever. |
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. Slugs are account-scoped, so another customer's universe reads as missing, not forbidden. |
unknown_signal | 404 | The signal name in the path is not a column on `ticker`. Check /docs/schema and /docs/flags for valid names. Emitted by: Subscribe to a signal |
already_exists | 409 | A custom-signal `POST` (or `PATCH` rename) used a name you already own. Distinct from `name_collision`, which fires when the name clashes with a built-in column. Pick a different name, or `PATCH` the existing signal. |
idempotency_in_flight | 409 | A request carrying the same `Idempotency-Key` is still being processed. Retry after it completes — the finished response will replay (with an `Idempotency-Replayed: true` header) instead of creating a duplicate. Applies to the create endpoints (webhook creates and subscribes, custom signals, universes). |
name_collision | 409 | A custom signal `POST` (or `PATCH` rename) was rejected because the chosen name is a built-in column. Built-ins win: rename your signal. A clash with another of your own custom signals returns `already_exists` instead. |
rename_blocked | 409 | A custom-signal `PATCH` rename was refused because other custom signals reference the old name — renaming would break their compilation. Carries `references.signals`. Edit those references first, or delete and recreate. Emitted by: Update a custom signal |
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 |
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_referenced | 409 | A universe `DELETE` was refused because webhooks still reference it — deleting it would leave them silently matching nothing. Carries `referencing_webhooks` (id + name). Re-point or delete those webhooks first, or pass `?force=true` to delete anyway; a forced delete leaves them matching nothing (recording `last_error` each eval) until re-pointed or deleted. Mirrors the custom-signal delete contract (`signal_referenced`). Emitted by: Delete a universe |
gone | 410 | The route was removed — `410` (not `404`) so the removal is a stated fact to any remaining caller, and the message says what replaced it. Today only `/v2/scan/explain` (removed 2026-08-06) answers this way. |
idempotency_key_reused | 422 | An `Idempotency-Key` was reused for a *different* request than the one that minted it (different method or path). A key is only valid for replaying the exact request it was first sent with — reusing one across endpoints would hand back the wrong object with a 201. Use a fresh key per distinct request. |
rate_limit_exceeded | 429 | You exceeded your plan's per-minute request limit. The `Retry-After` header gives the seconds until your oldest request ages out of the rolling window (at most 60). The body names your plan's limit and the tier that raises it. |
sandbox_limit | 429 | The anonymous docs sandbox is capped per visitor, per hour and per day. Carries `limits` and `retry_after_seconds` in the body, plus `Retry-After` and `X-Sandbox-Remaining-Hour`/`X-Sandbox-Remaining-Day` headers (the two `X-Sandbox-Remaining-*` headers also ride on every successful anonymous sandbox response, so you can watch the budget before hitting it). Sign up for a free API key for real rate limits. |
too_many_auth_failures | 429 | Failed-auth flood gate: 30 or more failed authentication attempts in a minute from one IP are refused before key lookup, with `Retry-After: 60`. Successful auths never count toward the gate, so shared IPs with real traffic are unaffected. Fix the key you are sending rather than retrying it. |
internal | 500 | Something went wrong on our side. Safe to retry with exponential backoff. Include the failing request and timestamp if you contact support. |
channel_not_implemented | 501 | The delivery channel is a valid name but not built yet — today that is `slack` alone. Nothing else about the request is wrong, so switching to `webhook`, `discord`, or in-app delivery is the whole fix. |
index_building | 503 | A composite index needed for a list query is still building (typically <1 minute on initial deploy). Your query and data are fine; only the index is missing, so retry shortly rather than changing the request. |
kind_unavailable | 503 | An event kind is provisioned but its database support is not in place (operational, transient — today only `kind=signal` can emit it, when its supporting indexes are absent). Retry later or drop the kind from the request; not a plan or validation error. |
unavailable | 503 | Operational and transient: the flag-events store backing signal event spans is unavailable (the database reports its relation missing). Retry later; not a validation or plan error. Emitted by: Signal spans |
Rate limit
Per-account, per-minute window — every key on the account shares it. The cap depends on your plan.
| Plan | Per-minute limit | Sustained equivalent |
|---|---|---|
| Free | 60 / min | ~1 req/sec |
| Hobby | 600 / min | ~10 req/sec |
| Pro | 6,000 / min | ~100 req/sec |
| Scale | 60,000 / min | ~1,000 req/sec |
| Enterprise | custom | — |
The window is a rolling minute, not a clock minute: 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_limit_exceeded with a Retry-After header counting down to the moment your oldest request ages out of the window (at most 60s) — waiting for the top of the minute is not the same thing. The JSON body names your plan’s limit and carries retry_after_seconds and an upgrade_url.
Every rate-limited response — success and 429 alike, quota-blocked 402s included — carries the rate-limit headers. They are absent only where no limiter runs: unauthenticated requests (401s) and Enterprise plans with no finite limit. Use them to back off proactively rather than waiting to be limited. The window is per account — every key on the account, plus the dashboard and the sandbox, share it, so adding keys does not add throughput.
| Header | Description |
|---|---|
X-RateLimit-Limit | Per-minute request limit for the account (60/600/6,000/60,000 for Free/Hobby/Pro/Scale, custom for Enterprise). The window is shared by every key on the account, the dashboard, and the sandbox — adding keys does not add throughput. |
X-RateLimit-Remaining | Requests remaining in the current minute window. |
X-RateLimit-Reset | Unix timestamp (seconds) when the per-minute window resets — same format as `X-Quota-Reset`. |
Retry-After | Seconds to wait before retrying. Present only on 429 responses. |
X-RateLimit-Warning | Appears on successful responses once the window is ≥80% consumed — e.g. `Approaching plan limit (49/60).` Treat it as the back-off signal before the 429. |
No daily cap on any plan. The Free plan has a monthly quota (below); paid plans are bounded only by the per-minute window. See pricing for the full plan comparison.
Monthly quota (Free plan)
Free includes a monthly call quota. Paid plans are unmetered.
The Free plan includes 10,000 calls per calendar month (UTC), resetting on the 1st. Every paid plan has no monthly cap. The rate limit and the quota are independent levers: the per-minute window caps burst speed, the quota caps monthly volume.
When the quota is used up you get 402 monthly_quota_exceeded with a resets_at timestamp and an upgrade_url. Rejected requests (429s and quota 402s) and server-side 5xx errors never consume quota. Quota 402s carry both header sets — X-Quota-* and the rate-limit headers — and an out-of-quota client bursting past its per-minute window still gets a 429 like anyone else.
| Header | Description |
|---|---|
X-Quota-Limit | Monthly call quota for the plan (10,000 on Free; paid plans are unmetered and omit these headers). |
X-Quota-Remaining | Calls remaining in the current calendar month (UTC). |
X-Quota-Reset | Unix timestamp (seconds) of the first instant of next month, when the quota resets. |
Quota headers appear on Free-plan responses to keyed /v2/* calls only — on paid plans there is no quota to report, and dashboard-mirror and sandbox traffic is unmetered so it omits them on every plan.
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(500) andkind_unavailable(503) as retryable. Treatbad_request(400),invalid_query(400), andunauthenticated(401) as fatal; retrying won't help.