# POST /v2/signals

**Create a custom signal**

Persists a named boolean (or numeric) expression in the existing predicate grammar. Once saved, the name is referenceable from any SQL context — `/v2/scan` `q`, `/v2/signals/{name}`, subscribe-path `condition`, the expression body of another custom signal — and the predicate compiler inlines its body before SQL emit.

The expression is compiled at create time against the live signal column whitelist; references to other custom signals you own are inlined first (recursion is detected). Names are scoped per-user: `(user_id, name)` is the key. Names cannot collide with built-in column names — those return 409 `name_collision`.

Write access requires Scale or above. Hobby/Pro can read `/v2/signals` catalog but writes return `403 custom_signals_tier_required`.

## Plan access

- **Plan access.** Scale and above. Hobby/Pro can read the catalog but writes return 403 custom_signals_tier_required.
- **Rate limit.** Hobby 600/min · Pro 2,000/min · Scale 10,000/min.
- **Capacity.** Unlimited custom signals per account on Scale and Enterprise.

## 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. |
| `expr` | body | string | yes | SQL expression in the predicate grammar. May reference built-in columns and other custom signals you own. 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.
- **403** — `custom_signals_tier_required` — Hobby/Pro can read but not write custom signals.
- **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.
- Once saved, the new name works in any SQL context (scan `q`, the `expr` of another custom signal, the body of a webhook subscribe, etc.). The compiler inlines the AST at consumer-compile time; there's no separate eval path.

---

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