← tickerbot.io
View as markdown
Concepts

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 column rsi_99. Did you mean rsi_14 or rsi_7? Catalog: /v2/signals",
  "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.

CodeStatusWhen you see it
bad_request400
The request was malformed: missing required parameter, invalid format, value out of range, or unsupported combination.
compile_failed400
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_ambiguous400
The qualifier case-folds to two different catalog symbols (the preferred-share spellings — `ADSW` and `ADSw` are different instruments). Type the exact symbol.
context_ticker_unavailable400
A `q` used the qualified context-ticker syntax (`spy.sma_50`) with an index series we recognize but do not carry — `vix` and `dxy`. Any symbol in the catalog can be a context ticker; a symbol that is not in it is `unknown_context_ticker` below.
expanded_query_too_large400
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_interval400
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.
interval_unavailable400
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_query400
The `q` clause failed to parse, references an unknown column, or uses a disallowed SQL feature (semicolons, comments, INSERT/UPDATE keywords). The message is written for the reader: an unknown column names the nearest live columns and points at the catalog; a bare numeric or text column where a condition is needed, or a true/false column compared to a number, says what to write instead.
kind_not_subscribable400
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_signal400
An event-spans read named a numeric signal. Only booleans 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_signal400
Event spans and firings are precomputed for built-in booleans only, so `kind=signal` event reads (and the deprecated spans read) 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_kind400
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_exceeded400
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_interval400
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_signals400
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.
too_many_universes400
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_context_ticker400
A `q` (scan or webhook) referenced `<ticker>.<column>` with a symbol that is not in the catalog. Checked before the query runs, so a typo names itself instead of returning zero rows. Spell the symbol as `/v2/tickers` returns it; dotted symbols such as `BRK.B` cannot be qualifiers because the dot is the separator.
unknown_parameter400
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.
unknown_tickers400
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.
unauthenticated401
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_exceeded402
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_required402
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_required403
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.
plan_required403
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_reached403
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_immutable403
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_reached403
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_limit403
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.
webhook_tier_required403
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_found404
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_found404
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/bars/{ticker}/{interval}`. If your client sees `suggestion`, switch to that route — retrying the same path will 404 forever.
universe_not_found404
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_signal404
The signal name in the path is not a signal. Check the signal catalog for valid names.
already_exists409
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_flight409
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_collision409
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_blocked409
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.
signal_referenced409
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.
slug_taken409
A POST tried to create a universe with a slug that already exists in your account.
Emitted by: Create a universe
universe_referenced409
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
gone410
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_reused422
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_exceeded429
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_limit429
The no-key sandbox (`/v2/sandbox/*`) is the same API with the same responses; without a key it is budgeted per IP at 30 requests/hour and 60/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). Signed in on the docs, or with an API key on `/v2`, your plan's per-minute limit applies instead.
too_many_auth_failures429
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.
internal500
Something went wrong on our side. Safe to retry with exponential backoff. Include the failing request and timestamp if you contact support.
channel_not_implemented501
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.
upstream_unavailable502
Operational and transient: an image endpoint (`GET /v2/tickers/{ticker}/icon`, `/logo`) could not fetch the image from its source. Retry shortly; the row and every other endpoint are unaffected.
index_building503
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_unavailable503
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.
unavailable503
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.

PlanPer-minute limitSustained equivalent
Free60 / min~1 req/sec
Hobby600 / min~10 req/sec
Pro6,000 / min~100 req/sec
Scale60,000 / min~1,000 req/sec
Enterprisecustom—

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.

HeaderDescription
X-RateLimit-LimitPer-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-RemainingRequests remaining in the current minute window.
X-RateLimit-ResetUnix timestamp (seconds) when the per-minute window resets — same format as `X-Quota-Reset`.
Retry-AfterSeconds to wait before retrying. Present only on 429 responses.
X-RateLimit-WarningAppears 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.

HeaderDescription
X-Quota-LimitMonthly call quota for the plan (10,000 on Free; paid plans are unmetered and omit these headers).
X-Quota-RemainingCalls remaining in the current calendar month (UTC).
X-Quota-ResetUnix 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-After header on the 429 response. Wait at least that many seconds before retrying.
  • Use exponential backoff for transient 5xx errors, starting at 1 second and doubling. Cap at 60 seconds.
  • Use the X-RateLimit-Remaining header to throttle yourself proactively rather than racing into the cap.
  • Treat internal (500) and kind_unavailable (503) as retryable. Treat bad_request (400), invalid_query (400), and unauthenticated (401) as fatal; retrying won't help.