Create a custom signal
https://api.tickerbot.io/ v2/ signalsA named boolean predicate you can reference anywhere a built-in signal goes.
Body parameters
stringrequiredSlug — ^[a-z][a-z0-9_]{0,63}$. Must not collide with any built-in signal name, and 15 names are reserved outright: columns, plus the /v2/series OHLCV aliases open/high/low/close/volume/vwap/trades and o/h/l/c/v/vw/n (those resolve to bars before custom lookup). This is the signal's API handle: it's what you reference in q and in the CRUD path.
stringrequiredBoolean SQL predicate. May reference built-in signals and other custom signals you own. Must evaluate to true/false. Max 4000 chars. **Stricter grammar than scan q:** comparisons, AND/OR/NOT, IN, BETWEEN, IS [NOT] NULL, arithmetic, and the functions abs/coalesce/round/least/greatest only — no LIKE/ILIKE, no CASE, no :: casts, no other functions. An expression that scans fine can still be rejected here with compile_failed.
What you send is what you read back: responses echo your expression as stored, not its expansion. A signal referencing another custom of yours returns the reference as you typed it — the inlined SQL exists only internally, and is what a subscribe endpoint freezes into a webhook. The one rewrite: a column named under its pre-2026-09-07 spelling (bollinger_pct_b) is stored under its current name (bollinger_b) and reported in _meta.deprecated_columns.
stringFree-form notes. Max 500 chars. Absent or empty comes back as "" rather than null.
Headers
stringOptional unique string (≤255 chars). A retry carrying the same key within 24h replays the original response (Idempotency-Replayed: true header) instead of creating a duplicate; a concurrent duplicate gets 409 idempotency_in_flight; reusing a key for a *different* request (another method or path) gets 422 idempotency_key_reused. 5xx responses are not stored — those retries re-execute.
Returns
as_ofstringServer time this response was assembled (ISO 8601).
signalobjectThe stored signal: `name`, `kind` (`custom`), `description`, `expr` (your predicate as stored), `created_at`, `updated_at`.
_metaobjectOnly when `expr` named a column under its pre-2026-09-07 spelling: `deprecated_columns` lists each one (`requested`, `use`, `note`). The stored `expr` carries the current name.
Status codes
201{ as_of, signal: { name, kind: "custom", description, expr, created_at, updated_at } }.400compile_failed (with errors array) when the expression doesn't parse / references unknown signals. bad_request for shape failures. too_many_custom_signals at the 1,000-per-account ceiling — a flat anti-abuse guard, identical for every account; its body carries current and limit so a client can show "N of M used". The ceiling is checked BEFORE the request is validated, so at the limit you get this rather than a shape error.409already_exists (slug taken on this account), name_collision (slug matches a built-in signal), or idempotency_in_flight (a concurrent request with the same Idempotency-Key is still executing).422idempotency_key_reused — the Idempotency-Key was already used for a *different* request (another method or path). Use a fresh key per distinct request.Notes
- Reference it in an expression position — scan
qand its aggregateselect/group_by/having, the signal read endpoints, seriescolumns, webhook predicates, and events queries underjoin=state. Not as acolumns/ordervalue: those take physical columns only. - The expression must evaluate to true/false; a non-boolean root is rejected at compile. Names are scoped per-user (
(user_id, name)is the key) and can never collide with a built-in signal, so a name is either yours or raw SQL — never ambiguous. - Freeze on subscribe: subscribe endpoints expand the signal into the stored webhook query at creation, so a subscription is a snapshot. Editing or deleting the signal later does not change existing subscriptions — re-subscribe to apply. Ad-hoc reads always reflect the current definition.
- Available on every account and never gated: they cost nothing at rest and are covered by your existing rate limit. An account may hold up to 1,000 — a flat anti-abuse ceiling, identical for every account.
- Where the name resolves, expanded to its SQL before the query runs: inside
/v2/scanq(live and as-of), mixed freely with built-in signals (q = my_squeeze AND market_cap > 1e9); as the signal itself onGET /v2/signals/{name}(live and as-of), behaving like a built-in boolean; as acolumnsvalue onGET /v2/series?ticker={t}&columns={name}; inside aPOST /v2/tickers/{ticker}/subscribecondition; as the signal onPOST /v2/signals/{name}/subscribe; and inside another custom signal'sexpr(nested references are inlined, recursion is detected). - It does NOT resolve on the deprecated spans read (
GET /v2/signals/{name}/{ticker}/events, sunset 2026-10-31), nor onkind=signalevents: firings are precomputed for built-in booleans only, so both return400 not_supported_for_custom_signaland point at/v2/seriesor/scan?asof=. - Built-in signal infrastructure is unchanged — custom signals run alongside it.
kinddiscriminates the two on the catalog endpoint. - Custom signals are boolean by construction (the expression must evaluate to true/false). To vary a numeric threshold per query, keep the numeric signal raw in
q/conditionrather than wrapping it in a custom signal. - Ad-hoc reads (scan/signal GETs) expand the current definition on every request; subscribe endpoints freeze the expansion at creation time (see the freeze note in the description).
- Cross-ticker references (e.g.
spy.close) inside a custom are supported in/v2/scanq, but not across the/v2/signalsfamily (single-ticker customs work everywhere). Keep a custom single-ticker to use it on every endpoint.