# POST /v2/signals

**Create a custom signal**

Persists a named **boolean** predicate you can reference anywhere a built-in signal goes. A custom signal is always boolean — the expression must evaluate to true/false (a non-boolean root is rejected at compile).

Once saved, the name resolves in these contexts, expanded to its SQL before the query runs:

- **Scan** — inside `/v2/scan` `q` (live and `asof`), mixed freely with columns: `q = my_squeeze AND market_cap > 1e9`.
- **Signal** — as the signal itself on `GET /v2/signals/{name}` (live and `asof`) and `GET /v2/signals/{name}/{ticker}/history`; it behaves like a built-in boolean signal (matches where the predicate is true, `condition` is ignored).
- **Ticker** — inside a `POST /v2/tickers/{ticker}/subscribe` `condition`.
- **Subscribe** — as the signal on `POST /v2/signals/{name}/subscribe`, or referenced from a scan/ticker subscribe `q`/`condition`.
- **Composition** — inside another custom signal's `expr`; nested references are inlined, and recursion is detected.

It does **not** resolve on `GET /v2/signals/{name}/{ticker}/events` — occurrence spans are precomputed for built-in flags only, so that endpoint returns `400 not_supported_for_custom_signal` and points you at `/history` or `/scan?asof=`.

Resolution is an exact match against your own custom signal names. A name can never collide with a built-in column (create returns 409 `name_collision`), so the two never ambiguate; any identifier that isn't one of your custom signals is left as raw SQL.

**Freeze on subscribe:** the subscribe endpoints expand the custom signal into the stored webhook query at creation time, so a subscription is a snapshot. Editing or deleting the custom signal later does **not** change existing subscriptions — re-subscribe to apply a new definition. Ad-hoc reads (scan/signal GETs) always reflect the current definition.

The expression is compiled at create time against the live column whitelist; references to other custom signals you own are inlined first. Names are scoped per-user: `(user_id, name)` is the key.

**On every plan, including Free.** Custom signals are not a paid feature and are not capped — authoring one and *referencing* one (in a scan `q`, a ticker `condition`, as a signal name, or via history/stats/subscribe) both work on any plan. Defining a signal costs nothing at rest: it compiles into your scan SQL and is paid for by your existing rate limit. An account may hold up to 1,000 custom signals, an anti-abuse ceiling rather than a plan limit.

## Plan access

- **Plan access.** Available on every plan, including Free — authoring and consuming both.
- **Rate limit.** Free 60/min · Hobby 600/min · Pro 6,000/min · Scale 60,000/min.
- **Capacity.** Unlimited on every plan.

## Body parameters

| Name | In | Type | Required | Description |
|------|----|----|----------|-------------|
| `name` | body | string | yes | Slug — `^[a-z][a-z0-9_]{0,63}$`. Must not collide with any built-in column name; `columns` and `scaffold` are reserved. This is the signal's API handle: it's what you reference in `q` and in the CRUD path. |
| `expr` | body | string | yes | Boolean SQL predicate. May reference built-in columns and other custom signals you own. Must evaluate to true/false. Max 4000 chars. |
| `description` | body | string | no | Free-form notes. Max 500 chars. |

## Status codes

- **201** — `{ as_of, signal: { name, kind: "expression", description, expr, created_at, updated_at } }`.
- **400** — `compile_failed` (with `errors` array) when the expression doesn't parse / references unknown columns. `bad_request` for shape failures. `too_many_custom_signals` at the 1,000-per-account ceiling — an anti-abuse guard, not a plan limit.
- **409** — `already_exists` (slug taken on this account) or `name_collision` (slug matches a built-in column).

## Sample response

```json
{
  "as_of": "2026-05-30T18:55:00.000Z",
  "signal": {
    "name": "rsi_oversold_with_volume",
    "kind": "expression",
    "description": "RSI < 30 confirmed by a 2x relative-volume burst.",
    "expr": "rsi_14 < 30 AND volume_ratio_20d > 2",
    "created_at": 1779389700,
    "updated_at": 1779389700
  }
}
```

## Examples

### A boolean: RSI under 30 with a volume burst

Request:

```shell
curl -X POST "https://api.tickerbot.io/v2/signals" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "rsi_oversold_with_volume",
    "description": "RSI < 30 confirmed by a 2x relative-volume burst.",
    "expr": "rsi_14 < 30 AND volume_ratio_20d > 2"
  }'
```

Response (`201`):

```json
{
  "as_of": "2026-05-30T18:55:00.000Z",
  "signal": {
    "name": "rsi_oversold_with_volume",
    "kind": "expression",
    "description": "RSI < 30 confirmed by a 2x relative-volume burst.",
    "expr": "rsi_14 < 30 AND volume_ratio_20d > 2",
    "created_at": 1779389700,
    "updated_at": 1779389700
  }
}
```

## Notes

- Built-in signal infrastructure is unchanged — custom signals run alongside it. `kind` discriminates 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 column raw in `q`/`condition` rather 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/scan` `q`, but not across the `/v2/signals` family (single-ticker customs work everywhere). Keep a custom single-ticker to use it on every endpoint.

---

Interactive sandbox + parameter editor: https://tickerbot.io/docs/endpoints/signals/custom/create
