SQL
One SQL WHERE clause over signals. The same clause is q on scan, events and news, and the condition on a webhook.
One grammar, three projections
Same WHERE clause; the identifiers come from the surface you're querying.
- The ticker row — Scan and webhook
q. Identifiers are signals of any type — booleans you name directly, numerics, strings, and dates you compare — plus the qualified context tickers below. - The event row — All events and the webhook
event_qcontent filter. Exactly four identifiers:ticker,ts,kind, andpayload, with jsonb operators for payload fields (payload->>'firm' = 'Goldman Sachs'). Withjoin=stateticker-state signals become legal too, evaluated as of each event's timestamp. - The article row — News. Article columns (
time_published,source, sentiment fields) plus thetkalias for per-ticker rollups.
Sending the query
POST a JSON body. No encoding needed.
Send the query in a JSON body via POST /v2/scan — the same pattern holds on /v2/events and /v2/news. GET with q as a URL parameter also works, but then the query must be URL-encoded: spaces become %20, > becomes %3E, single quotes become %27. Most HTTP clients encode automatically.
# JSON body: the query travels as written, no URL encoding
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"q": "above_sma_200 AND market_cap > 1e10"}'Examples
Bullish breakouts on volume
Stocks at or above their 20-day high on at least 2× their normal volume.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "at_20d_high AND high_volume_alert"
}'Small-cap gappers, avoiding earnings noise
Small caps gapping up, with no earnings announcement this week.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "gap_up AND small_cap AND NOT earnings_this_week"
}'Oversold tech with healthy fundamentals
Oversold technology names that are still profitable.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "rsi_oversold AND sector = 'Technology' AND pe_ratio BETWEEN 5 AND 30"
}'New 52-week highs with momentum
Stocks making fresh 52-week highs with strong upward momentum.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "at_52w_high AND momentum_strong_up"
}'Intraday range breaks
Names that broke above the prior 15-minute high on heavy volume.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "range_break_15m_up AND high_volume_alert"
}'Mega-cap consolidation
Mega-cap stocks coiling for a move: tight Bollinger bands, low volatility.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "market_cap > 200000000000 AND atr_pct < 1.5"
}'Sector rotation candidates
Healthcare and energy names crossing above their 50-day average.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "sector IN ('Healthcare', 'Energy') AND above_sma_50 AND NOT in_downtrend"
}'Macro-conditioned setup (qualified context tickers)
RSI-oversold names, but only when the S&P 500 is holding above its 50-day average.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "rsi_oversold AND spy.price > spy.sma_50"
}'Sector-relative breakout
Tech breakouts that are also outpacing the tech sector ETF on the same day.
curl -X POST "https://api.tickerbot.io/v2/scan" \
-H "Authorization: Bearer YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"q": "at_20d_high AND sector = 'Technology' AND change_1d_pct > xlk.change_1d_pct"
}'Context tickers (qualified syntax)
Reference the market itself inside a query about individual stocks.
A scan or webhook q normally filters each ticker on its own signals. Sometimes the condition you care about lives on a different symbol: “oversold names, but only while the S&P 500 holds its 50-day.” Writing spy.price > spy.sma_50 pulls those signals from SPY into the same query — no second call, no client-side join, and every clause evaluates against the same instant (including under asof). The qualifier is any symbol the catalog returns, spelled as it appears there — an ETF, an equity, a rate, a crypto or FX pair:
| example | what it is |
|---|---|
spy | An ETF — S&P 500 proxy. Any ETF works the same way (qqq, iwm, xlk, ...). |
aapl | Any equity in the catalog — a bellwether, a peer, a competitor. |
r:sofr | A rate row (R: prefix) — SOFR, fed funds, Treasury yields and spreads. |
x:btcusd | A crypto or FX row (X: prefix). |
- A qualifier that is not in the catalog is a
400 unknown_context_tickernaming it — never a raw SQL error, never a silent zero-row result. - Symbols with a dot or hyphen in them (
BRK.B) cannot be qualifiers: the dot is the separator. Everything else in the catalog can, including prefixed ids (r:sofr,x:btcusd). vixanddxyare index series no vendor we hold carries; they answer400 context_ticker_unavailable.- There is no cap on refs per query beyond the 4,000-character limit on
q; each ref is one indexed lookup evaluated once per query, whatever the symbol. - Refs inside a custom signal's
exprare not rewritten. Keep custom signals single-ticker and put the context ref inq.
Where SQL doesn’t apply
A few reads ask a narrower question than a WHERE clause.
- Signal matches take
condition, notq: the question is one comparator on one named signal, so it's a single bound like?condition=>70(operators> >= = != < <=). Booleans need no condition at all. The sameconditionshape appears on ticker- and signal-trigger webhooks. - Series and bars take structured params (
columns,interval,from/to) — you're selecting a grid, not filtering rows. - Tickers reads are keyed by symbol; there's nothing to filter.
What we don’t accept
Valid SQL features we explicitly reject. Avoids surprises when an otherwise-correct WHERE clause returns a 400.
The one surface with a stricter subset is custom-signal expr, which compiles against the list below.
- No subqueries or joins. A query filters one row: the wide ticker row on /v2/scan, the (ticker, ts, kind, payload) event row on /v2/events, the article row on /v2/news. The one sanctioned join is `join=state` on /v2/events — a parameter, not SQL you write.
- No statement composition. `UNION` / `EXCEPT` / `INTERSECT`, `WITH`, `SELECT`, any DDL/DML keyword, semicolons, and SQL comments (`--`, `/* */`) are rejected.
- Functions are whitelisted: aggregates (`count`, `avg`, `sum`, `min`, `max`, `string_agg`, ...) in `select`/`having`, plus scalar helpers (`coalesce`, `round`, `abs`, `lower`, `upper`, `length`, `date_trunc`, `extract`, `now`, ...). Anything else — including window functions (`OVER (...)`, `row_number`, `rank`, `lag`) — is a 400 naming the function.
- Everything else you'd expect works: `LIKE`/`ILIKE`, `IN`, `BETWEEN`, `CASE`, computed expressions (`price / sma_50 > 1.05`), and casts to whitelisted types (`::numeric`, `::date`, `::timestamptz`, ...).
- No double-quoted identifiers. Write columns bare (`price`, not `"price"`); single quotes are for string literals.
- Custom-signal `expr` is stricter still: comparisons, `AND`/`OR`/`NOT`, `IN`, `BETWEEN`, `IS [NOT] NULL`, arithmetic, and only `abs`/`coalesce`/`round`/`least`/`greatest`. No `LIKE`/`ILIKE`, no `CASE`, no `::` casts. An expression that works in a scan `q` can still be rejected with `compile_failed`.