---
title: "Create a webhook"
method: POST
path: "/webhooks"
tags: ["Webhooks"]
---

# Create a webhook

`POST /webhooks`

## Request

- Base URL: `https://optionsbell.com/api/v1`
- URL: `https://optionsbell.com/api/v1/webhooks`
- Auth: API key in header `X-API-Key`

Register a ticker list plus optional filters once; from then on
OptionsBell POSTs a signed `unusual_activity` event to `url`
whenever **new** unusual contracts matching the filters appear for
those tickers.

**When it fires.** Evaluation runs every 5 minutes during the
webhook's `active_hours` (default 09:00-16:00 America/New_York) and
compares the current unusual-activity scan against the contracts you
have already been sent, so each contract is delivered once. At most
one delivery per `notify_interval_min` (default 10); contracts that
surface during the cooldown are included in the next delivery. The
first run after creation only records a baseline and does not
deliver - otherwise you would receive everything that is already
unusual the moment you register. The same base floor as the app
alerts applies (Vol/OI >= 1.5, OI >= 100, premium >= $25,000) plus
your optional `criteria`. Each event carries at most 10 contracts per
ticker (highest Vol/OI first); contracts beyond that cap are marked as
seen and not delivered later. Deliveries are best-effort push,
typically 1-6 minutes after a print appears in our data.

**Response.** 201 with the webhook and its signing `secret`
(`whsec_...`), returned **exactly once** - store it; there is no way
to read it again, only to rotate it. Webhooks and app-created alerts
share the limit of **10 per subscription** (409 `LIMIT_REACHED`).

**URL rules.** `url` must be an absolute `https` URL (max 2048
characters, no embedded credentials) on a public host: `localhost`,
`*.local`, `*.internal` and hosts that resolve to private, loopback,
link-local or carrier-grade NAT ranges are rejected with 400
`INVALID_URL`. Redirects are not followed at delivery time.

**License.** Deliveries go to the subscriber's own endpoint; the
Personal plan's one-person rule applies - do not forward events to
others (https://optionsbell.com/terms#api-license).

## Request body

- WebhookCreate
  - `url` string, uri, required: Absolute https URL on a public host, max 2048 characters, no credentials. Validated (including a DNS lookup) at registration.
  - `tickers` string[], required: 1-150 tickers, case-insensitive, each matching `^[A-Z][A-Z0-9.\-]{0,9}$` after upper-casing. Duplicates are dropped.
  - `name` string: Display name; defaults to `Webhook <n>`. Echoed in every event as `webhook.name`.
  - `criteria` WebhookCriteria: Optional contract filters on top of the always-on base floor (Vol/OI >= 1.5, OI >= 100, premium >= $25,000). `null` (or 0) means no extra constraint. In responses `type` is `c`, `p` or `all`; in requests `call`/`calls` and `put`/`puts` are accepted as well.
    - `type` 'c' | 'p' | 'all': Calls only, puts only, or both.
    - `min_voloi` number, nullable: Vol/OI ratio floor.
    - `min_premium` number, nullable: Estimated premium floor in USD (last_price * volume * 100).
    - `min_iv` number, nullable: Implied volatility floor in percent (60 = 60%).
    - `max_dte` integer, nullable: Max days to expiration (floats are floored).
    - `min_volume` number, nullable: Contract volume floor.
    - `min_oi` number, nullable: Open interest floor.
  - `notify_interval_min` 10 | 30 | 60 | 240 | 480 | 1440
  - `active_hours` WebhookActiveHours: Daily window in which the webhook is evaluated, `HH:MM` 24h in America/New_York. `end` is exclusive; a window may wrap past midnight (e.g. 20:00 -> 04:00). `start` equal to `end` never fires.
    - `start` string, required
    - `end` string, required
  - `is_active` boolean

## Response `201`

Webhook created. `secret` is included in this response only.

- WebhookWithSecret: A registered webhook. Never carries the signing secret.
  - `id` string, required
  - `name` string, required
  - `url` string, uri, required
  - `tickers` string[], required: Upper-cased, de-duplicated. Max 150.
  - `criteria` WebhookCriteria, required: Optional contract filters on top of the always-on base floor (Vol/OI >= 1.5, OI >= 100, premium >= $25,000). `null` (or 0) means no extra constraint. In responses `type` is `c`, `p` or `all`; in requests `call`/`calls` and `put`/`puts` are accepted as well.
    - `type` 'c' | 'p' | 'all': Calls only, puts only, or both.
    - `min_voloi` number, nullable: Vol/OI ratio floor.
    - `min_premium` number, nullable: Estimated premium floor in USD (last_price * volume * 100).
    - `min_iv` number, nullable: Implied volatility floor in percent (60 = 60%).
    - `max_dte` integer, nullable: Max days to expiration (floats are floored).
    - `min_volume` number, nullable: Contract volume floor.
    - `min_oi` number, nullable: Open interest floor.
  - `notify_interval_min` 10 | 30 | 60 | 240 | 480 | 1440, required: Minimum minutes between two deliveries.
  - `active_hours` object, required: Daily window in which the webhook is evaluated, `HH:MM` 24h in America/New_York. `end` is exclusive; a window may wrap past midnight (e.g. 20:00 -> 04:00). `start` equal to `end` never fires.
    - `start` string, required
    - `end` string, required
    - `timezone` 'America/New_York'
  - `is_active` boolean, required: false: not evaluated at all (set via PATCH).
  - `paused_at` string, date-time, nullable, required: Set when the webhook was paused after 20 consecutive failed deliveries; null otherwise. Paused webhooks are still evaluated but not delivered to.
  - `consecutive_failures` integer, required: Failed `unusual_activity` deliveries in a row; reset to 0 by any successful delivery, a successful test, or a PATCH with `url` / `is_active: true`.
  - `last_run_at` string, date-time, nullable, required: Last evaluation (UTC), whether or not anything was delivered.
  - `last_delivered_at` string, date-time, nullable, required: Last successful `unusual_activity` delivery (UTC). The cooldown (`notify_interval_min`) counts from here.
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required
  - `secret` string, required: HMAC-SHA256 signing secret (`whsec_` + 48 hex chars). Shown once - store it.

## Other responses

- `400`: Invalid input: `INVALID_PARAM` (body not a JSON object, bad `name`, `criteria`, `notify_interval_min`, `active_hours` or `is_active`, or `url` missing), `INVALID_URL` (not https, not public, does not resolve), `INVALID_SYMBOLS` (`tickers` missing, empty, contains an invalid ticker, or more than 150).
- `401`: Missing or invalid API key: `MISSING_API_KEY`, `INVALID_API_KEY_FORMAT`, `INVALID_API_KEY`. No rate-limit headers.
- `403`: Key is valid but has no usable subscription: `NO_ACTIVE_SUBSCRIPTION` (no Personal plan on the key) or `SUBSCRIPTION_INACTIVE` (past due, paused, expired, or canceled past its period end). No rate-limit headers.
- `409`: `LIMIT_REACHED`: the subscription already holds 10 alerts and webhooks in total. Delete one first.
- `429`: Rate limit exceeded: `RATE_LIMIT_MINUTE` (60/min) or `RATE_LIMIT_DAY` (4,000/day). Carries both rate-limit header sets and `Retry-After` (seconds until the exhausted window opens again). Rejected requests count against the minute window, so back off instead of retrying immediately.
- `500`: Unexpected server error: `INTERNAL_ERROR`. The body and the X-Request-Id header carry the request id.
- `503`: Authentication backend unreachable: `AUTH_UNAVAILABLE`. Retry later. No rate-limit headers.

---

[API](https://skmtc.dev/optionsbell/apis/optionsbell-options-flow-api.md) · [All operations](https://skmtc.dev/optionsbell/apis/optionsbell-options-flow-api/llms.txt) · [OpenAPI document](https://skmtc.dev/optionsbell/apis/optionsbell-options-flow-api/revisions/42db9bcaa61f?raw)
