# POST /v2/webhooks

**Create a webhook**

Canonical create: a webhook is a trigger plus a delivery.

## Body parameters

| Name | In | Type | Required | Description |
|------|----|----|----------|-------------|
| `trigger` | body | object | yes | What fires the webhook. A discriminated object — `trigger.type` picks the shape, and the fields below belong inside it. Each shape is also available as a flat-params shortcut: `POST /v2/scan/subscribe`, `/v2/tickers/{t}/subscribe`, `/v2/signals/{s}/subscribe`, `/v2/events/subscribe`. |
| `trigger.type` | body | string | yes | Which trigger shape the rest of the object uses. Enum: `scan`, `ticker`, `signal`, `event`. Example: `scan`. |
| `trigger.q` | body | string | no | **scan**: required — the SQL WHERE any ticker must match to fire. **ticker**: required — WHERE fragment evaluated for that ticker (auto-scoped; don't add `ticker = …` yourself; `trigger.condition` accepted as an alias). **event**: optional row-STATE filter on the event's ticker at fire time (`market_cap > 1e10`). Example: `rsi_14 > 70 AND relative_volume > 2`. |
| `trigger.signal` | body | string | no | **signal**: required — a built-in signal name (e.g. `rsi_14`) or one of your custom signals (custom SQL is expanded and frozen at creation). |
| `trigger.condition` | body | string | no | **signal**: required for numeric signals — a single bound like `>70`; sending one with a boolean or custom signal returns 400 (it does not apply). **ticker**: accepted as the original alias of `trigger.q`. |
| `trigger.ticker` | body | string | no | **ticker**: required — the symbol to watch (e.g. `NVDA`). **signal**: optional — restrict the signal to one symbol (omit to watch the whole universe). |
| `trigger.tickers` | body | string | no | **event**: optional symbol list, max 50 (e.g. `AAPL,NVDA`). Mutually exclusive with `trigger.universe`. |
| `trigger.kinds` | body | string[] | no | **event**: required — event kinds to fire on, array or comma list (e.g. `split,analyst`). |
| `trigger.event_q` | body | string | no | **event**: optional event-CONTENT filter in the `/v2/events` grammar over `(ticker, ts, kind, payload)` — e.g. `payload->>'firm' = 'Goldman Sachs'`. Composes with `trigger.q`. |
| `trigger.universe` | body | string | no | **scan / signal / event**: optional universe slug (`top_10`, `top_100`, or one of yours) scoping which tickers can fire. Mutually exclusive with `trigger.tickers` on event. Example: `top_100`. |
| `target_url` | body | string | no | HTTPS delivery URL (the `webhook` channel), max 1024 characters. Omit for in-app delivery, or use `channel` + `discord_url`/`device_id` for other channels. Example: `https://example.com/hook`. |
| `channel` | body | string | no | Delivery channel. See Delivery channels. Enum: `webhook`, `in_app`, `discord`, `mobile_push`. |
| `discord_url` | body | string | no | Discord webhook URL (channel `discord`). |
| `device_id` | body | string | no | Registered device id (channel `mobile_push`, see /v2/devices). |
| `cadence` | body | string | no | Evaluation cadence — a user preference — never gated. Event triggers deliver on ingest — only `realtime` is accepted on them (400 otherwise). Enum: `realtime`, `hourly`, `nyse_open`. Default: `realtime`. |
| `name` | body | string | no | Display name, max 80 characters. Defaults to an auto-generated one from the trigger. Example: `RSI + volume`. |
| `columns` | body | string | no | Extra columns echoed in fired payloads' match rows (`fields` accepted as an alias). Not accepted on `event` triggers (400) — event deliveries carry the event payload, not state rows. Example: `rsi_14,macd_line`. |
| `order` | body | string | no | Column the fired payload's match lists are sorted by before the 100-row cap is applied — so a truncated list is the deterministic top 100, not an arbitrary sample. Same contract as `POST /v2/scan`. Not accepted on `event` triggers (they deliver one event at a time). Default: `market_cap`. Example: `market_cap`. |
| `dir` | body | string | no | Sort direction for `order`. Not accepted on `event` triggers (400). Enum: `asc`, `desc`. Default: `desc`. |

## Status codes

- **201** — The created webhook record (same shape as `GET /v2/webhooks/{id}`) + `test_url`.
- **400** — Missing/invalid `trigger`, bad condition, or invalid delivery fields.
- **403** — `webhook_tier_required` (Free plan) or `webhook_limit_reached` (at your plan's cap).
- **404** — Trigger references an untracked ticker, unknown signal, or missing universe; or `device_not_found` — the `device_id` isn't registered to your account.
- **409** — `idempotency_in_flight` — a concurrent request with the same `Idempotency-Key` is still executing; retry after it settles.
- **422** — `idempotency_key_reused` — the `Idempotency-Key` was already used for a *different* request (another method or path). Use a fresh key per distinct request.
- **501** — `channel_not_implemented` — `channel: "slack"` is declared but not yet deliverable.

## Sample response

```json
{
  "as_of": "2026-07-27T15:40:11.000Z",
  "id": "wh_x7Kd2m-p41q",
  "name": "large-cap gappers",
  "q": "gap_up AND market_cap > 1e9",
  "universe_id": "top_100",
  "cadence": "realtime",
  "target_url": "https://example.com/hook",
  "delivery": "webhook",
  "status": "active",
  "subscription_origin": { "type": "scan", "ref": "q" },
  "test_url": "/v2/webhooks/wh_x7Kd2m-p41q/test",
  "created_at": 1785166811
}
```

## More examples

### Ticker trigger — one symbol, one condition

Request:

```shell
curl -X POST "https://api.tickerbot.io/v2/webhooks" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trigger": { "type": "ticker", "ticker": "NVDA", "condition": "rsi_14 > 70 AND relative_volume > 2" },
    "target_url": "https://example.com/hook"
  }'
```

Response (`201`):

```json
{
  "as_of": "2026-07-27T15:41:00.000Z",
  "id": "wh_smRsF3-z36o",
  "name": "NVDA: rsi_14 > 70 AND relative_volume > 2",
  "q": "ticker = 'NVDA' AND (rsi_14 > 70 AND relative_volume > 2)",
  "status": "active",
  "subscription_origin": { "type": "ticker", "ref": "NVDA", "condition": "rsi_14 > 70 AND relative_volume > 2" },
  "test_url": "/v2/webhooks/wh_smRsF3-z36o/test"
}
```

### Signal trigger — numeric signal with a bound, scoped to a universe

Request:

```shell
curl -X POST "https://api.tickerbot.io/v2/webhooks" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trigger": { "type": "signal", "signal": "rsi_14", "condition": ">70", "universe": "top_100" },
    "channel": "discord",
    "discord_url": "https://discord.com/api/webhooks/123456789012345678/aBcDeF…"
  }'
```

Response (`201`):

```json
{
  "as_of": "2026-07-27T15:42:00.000Z",
  "id": "wh_dC9rTq-k77p",
  "name": "rsi_14 >70 · top_100",
  "q": "rsi_14 > 70",
  "universe_id": "top_100",
  "channel": "discord",
  "status": "active",
  "subscription_origin": { "type": "signal", "ref": "rsi_14", "condition": ">70" },
  "test_url": "/v2/webhooks/wh_dC9rTq-k77p/test"
}
```

### Event trigger — Goldman downgrades on large caps

Request:

```shell
curl -X POST "https://api.tickerbot.io/v2/webhooks" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "trigger": {
      "type": "event",
      "kinds": ["analyst"],
      "q": "market_cap > 1e10",
      "event_q": "payload->>'"'"'firm'"'"' = '"'"'Goldman Sachs'"'"'"
    },
    "target_url": "https://example.com/hook"
  }'
```

Response (`201`):

```json
{
  "as_of": "2026-07-27T15:43:00.000Z",
  "id": "wh_e9Kp3q-r52s",
  "name": "events: analyst",
  "q": "market_cap > 1e10",
  "trigger_kind": "event",
  "event_kinds": ["analyst"],
  "status": "active",
  "subscription_origin": { "type": "event", "ref": "analyst", "condition": "market_cap > 1e10" },
  "test_url": "/v2/webhooks/wh_e9Kp3q-r52s/test"
}
```

## Notes

- Four trigger types. **`scan`** — `{ "type": "scan", "q": "…", "universe"?: "slug" }` fires on any ticker matching a SQL WHERE. **`ticker`** — `{ "type": "ticker", "ticker": "NVDA", "condition": "…" }` fires when one ticker matches, auto-scoped, so do not add `ticker = …` yourself.
- **`signal`** — `{ "type": "signal", "signal": "rsi_14", "condition"?: ">70", "ticker"?: "NVDA", "universe"?: "slug" }` fires on a named signal: booleans need no condition, numerics require a single bound, and custom signals resolve as booleans with their SQL frozen at creation.
- **`event`** — `{ "type": "event", "kinds": ["split","analyst"], "tickers"?: […] | "universe"?: "slug", "q"?: "…", "event_q"?: "…" }` fires when NEW events land. Two composable filters: `q` is a row-STATE filter on the event's ticker (`market_cap > 1e10`), `event_q` an event-CONTENT filter in the [`/v2/events` grammar](/docs/endpoints/events) over `(ticker, ts, kind, payload)`. Delivers `events.fired`; latency is ingest cadence (analyst within the hour, corporate kinds daily). See [Subscribe to events](/docs/endpoints/events/subscribe).
- The per-resource subscribe endpoints (`POST /v2/tickers/{T}/subscribe`, `/v2/signals/{S}/subscribe`, `/v2/scan/subscribe`, `/v2/events/subscribe`) are permanent shorthand for this endpoint — every door builds the identical webhook object, listed, updated, and deleted at `/v2/webhooks`.
- Wire-delivery responses (webhook / Discord / mobile-push) include a `test_url`. Creating does NOT auto-fire — call it to validate your receiver. In-app subscriptions have nothing to validate and carry no `test_url`.
- Prefer the subscribe shorthand when working from a resource: `POST /v2/tickers/{T}/subscribe` etc. — same object, fewer keystrokes.

---

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