# Webhooks

The table's third tense. A webhook is a trigger plus a delivery: the clause you would scan, pushed to you when it becomes true, on the channel you choose.

## Four doors, one object

`POST /v2/webhooks` (https://tickerbot.io/docs/endpoints/webhooks/create.md) is the canonical create: pass a discriminated `trigger` object (`scan`, `ticker`, `signal`, or `event`) plus your delivery fields. Four shortcut endpoints build the identical webhook if a simpler body reads better: `POST /v2/scan/subscribe`, `/v2/tickers/{ticker}/subscribe`, `/v2/signals/{signal}/subscribe`, and `/v2/events/subscribe`. Whichever door you use, the webhook is listed and managed through the Webhooks endpoints (https://tickerbot.io/docs/endpoints/webhooks.md): fetch and list, update the trigger or target, audit deliveries, re-enable after auto-suspension, and delete.

## State-change deduplication

One event per not-matching → matching transition. Why you never receive 500 webhook events for one breakout.

For webhooks, the API tracks the last-known match state per (webhook, ticker) and only delivers an event when a ticker transitions from not-matching to matching. If a boolean flickers true → false → true inside a minute, you get one event. If a ticker stays in the match set for three days, you get one event when it first matched and nothing for those three days. To re-fire for that ticker, it has to leave the match set first.

Cadence sets the granularity: match state is captured when the webhook is evaluated, so on `realtime` the flicker window is the ~1-minute refresh, while on `hourly`/`nyse_open` it is the whole gap between evaluations — a ticker that enters and exits entirely between two evaluations never fires at all.

## Polling is different

Polling endpoints (`/scan`, `/tickers`, `/signals`) always return current state with no deduplication. If you're polling and want edge-triggered behavior, diff the result set yourself between calls.

## Delivery channels

One discriminator, set once when you subscribe. The same subscription can deliver to an HTTPS endpoint, a Discord channel, the Tickerbot mobile app, or stay in the dashboard.

- `webhook` — POST a signed `webhook.fired` JSON payload to your `target_url`. The default when you pass a `target_url`.
- `discord` — post a formatted embed to a Discord channel via an incoming-webhook URL. No HMAC — the URL *is* the credential.
- `in_app` — record fires in the dashboard's deliveries timeline only. No outbound request. The default when you pass neither a `target_url` nor a `discord_url`.
- `mobile_push` — push a notification to a phone signed in to the Tickerbot mobile app. Sign into the app with your account and the device connects automatically; the app registers and rotates its `device_id` for you, so there's nothing to call by hand. (The `/v2/devices` endpoints exist for that registration but are driven by the app, not something you integrate against.)
- `slack` — *reserved.* Subscribing with this channel returns `501 not_implemented` today.

If you omit `channel`, it's inferred: `webhook` when `target_url` is set, `discord` when `discord_url` is set, otherwise `in_app`. The channel is fixed once the subscription exists — to switch, delete and re-subscribe.

## Discord

In Discord, open **Server Settings → Integrations → Webhooks → New Webhook**, pick a channel, and **Copy Webhook URL**. Pass it as `discord_url` with `channel: "discord"` on any subscribe endpoint:

```shell
curl -X POST "https://api.tickerbot.io/v2/scan/subscribe" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "q": "above_sma_200 AND market_cap > 1e10",
    "channel": "discord",
    "discord_url": "https://discord.com/api/webhooks/123456789012345678/aBcDeF…",
    "cadence": "realtime"
  }'
```

Each fire posts a single embed: the subscription name, the composed query, and up to the first dozen matching tickers with price and day change, plus a "+N more" line. Discord embed limits apply — long match sets are truncated, not split.

## The Discord URL is a credential

Anyone with the incoming-webhook URL can post to your channel, so we store it as a posting credential and **strip it from every read**. The create response echoes it back once; `GET /v2/webhooks/{id}` and list omit it and set `channel_config_present: true` so tooling can still tell a channel is configured. Delivery rows mask it the same way. To rotate it, delete and re-subscribe with the new URL.

## Retries & failures

A `2xx` (Discord returns `204`) is a success. A `429` is retried, honoring Discord's `Retry-After`. Other `4xx` responses (e.g. a deleted webhook → `404`) are `permanent_failure` — no retry. `5xx` and network errors retry on the standard ladder (30s → 2m → 10m → 1h → 6h), and five consecutive failures auto-disable the subscription, exactly like an HTTPS webhook.

## Validate it

`POST /v2/webhooks/{id}/test` posts a real-shape fire to the configured channel right now — for Discord, a real embed with a `[TEST]` title prefix — and returns the inline outcome. It's one-shot: failures don't retry or count toward auto-disable.


