API · v1
OptionsBell REST API
Programmatic access to unusual options activity, derived metrics, per-symbol flow, and aggregated sentiment across 7,000+ US-listed stocks. JSON over HTTPS, key-authenticated, rate-limited per key.
Authentication
All requests authenticate via the X-API-Key header (keys start with sms_). Generate a key in your account settings - an active Personal subscription is required. A canceled subscription keeps working until the end of its paid period. Data endpoints are GET; the webhook management endpoints use POST, PATCH and DELETE with JSON bodies. CORS is open, so browser clients work too.
curl https://optionsbell.com/api/v1/options-flow/unusual \
-H "X-API-Key: sms_xxxxxxxxxxxxxxxx"Rate limits & usage cap
One plan, one set of limits: 60 requests per minute and 4,000 requests per day per API key, enforced durably across deployments. Every authenticated response (200, 400, 429) carries both header sets so you can pace your client. Responses rejected before authentication completes (401, 403, 503) carry none.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | Requests allowed per minute (60). |
| X-RateLimit-Remaining | Requests left in the current minute window. |
| X-RateLimit-Reset | Unix timestamp (seconds) when the minute window resets. Always the minute window. |
| X-RateLimit-Daily-Limit | Requests allowed per day (4000). |
| X-RateLimit-Daily-Remaining | Requests left in the current day window. |
| Retry-After | On 429 only: seconds to wait before retrying (minute window reset, or 00:00 UTC for the daily cap). Rejected requests still count against the minute window, so back off instead of retrying in a loop. |
Exceeding a window returns 429 with code RATE_LIMIT_MINUTE or RATE_LIMIT_DAY. Every response also carries an X-Request-Id header - quote it when reporting a problem.
One subscription, one person. Your key is bound to you by name. Use the output for your own professional or business decisions as you like, but do not share your API key, connector URL, data or output with anyone — not even colleagues in the same company; every person needs their own subscription. Output must not be redistributed or embedded in products, bots, dashboards, reports or channels for others, and stored responses must be deleted within 30 days. Details in the Terms.
Endpoints
/api/v1/options-flow/unusualUnusual options activity scan across the covered universe of 7,000+ US-listed stocks - the same dataset that powers the email alerts. Defaults to the latest scan day; filter by symbols, side, Vol/OI, premium, IV, days-to-expiry, volume and open interest. Rows are ranked by score (Vol/OI weighted by premium) with deterministic limit/offset paging.
/api/v1/options-flow/unusual?symbols=AAPL,NVDA&type=c&min_voloi=5&min_premium=250000&limit=300/api/v1/options-flow/:symbolPer-symbol unusual options activity - every contract on a single ticker that passed the unusual filter, ranked by score. Same filters and paging as /unusual (limit default 100, max 500); defaults to the latest day that has data for the symbol.
/api/v1/options-flow/:symbol/AAPL?limit=100/api/v1/options-flow/topTop prints of a trading day, ranked by estimated premium (rank 1 = largest) - the biggest bets view that also powers the alert emails. Defaults to the latest day; scope by date, side, or your own tickers (filters apply before ranking, rank counts across pages). min_premium defaults to $25,000; limit 1-100, default 20, plus offset with filtered_total / has_more.
/api/v1/options-flow/top?type=p&min_premium=1000000&limit=20/api/v1/options-flow/history/:symbolPer-symbol end-of-day history of unusual flow (aggregated from contracts that passed the unusual filter) - daily call/put contract counts, volume, open interest, premium, C/P ratios, average IV, net delta, and the consecutive-day streak. Newest first; date_from/date_to bound the window; limit 1-200, default 90. An unknown symbol returns 200 with count 0.
/api/v1/options-flow/history/:symbol/TSLA?date_from=2026-04-01&limit=90/api/v1/options-flow/streaksMulti-day unusual-flow streaks - symbols showing unusual options activity for min_streak (1-365, default 3) or more consecutive trading days, with the dominant side by C/P volume ratio (side=call|put|all). One row per symbol with unusual flow that day (streak_days >= 1). Ordered by streak length, total premium, symbol. limit 1-200, default 50, plus offset with filtered_total / has_more.
/api/v1/options-flow/streaks?min_streak=5&side=call&limit=50/api/v1/options-flow/expiryExpiry concentration - where the day's unusual premium sits along the DTE axis, in fixed buckets (0-7, 8-30, 31-90, 90+) with contract counts, volume and call/put premium splits. Params: date, symbols. Contracts without a DTE are excluded.
/api/v1/options-flow/expiry?symbols=NVDA,TSLA/api/v1/options-flow/statsDataset coverage stats - available date ranges, contract counts, symbol counts, sector coverage and the intraday rescan cadence. Lets clients and agents discover what is queryable before they query it.
/api/v1/options-flow/sentimentPer-symbol sentiment of the day's unusual flow - call/put volumes, premium, open interest, net delta, C/P ratios, average IV per side, plus bullish/bearish classification with strength_score 0-9 and the screener confirmations behind it. Filters: date or date_from/date_to, symbols, sector (exact label), side=bullish|bearish, signal_only, min_strength (unsignalled rows count as 0), min_premium; limit 1-500, default 100, plus offset with filtered_total / has_more. Ordered by total premium, date, symbol.
/api/v1/options-flow/sentiment?signal_only=1&min_strength=6§or=Information+Technology&limit=50/api/v1/options-flow/sentiment/:symbolPer-symbol unusual-flow sentiment time series, newest first. Defaults to the last 30 days; bound with date_from/date_to; limit 1-250.
/api/v1/options-flow/sentiment/:symbol/NVDA?limit=60/api/v1/options-flow/iv-rankIV rank + IV percentile snapshot per symbol as of the latest day, computed against the symbol's history of daily average IV. IV values are in percent; rank = (current - min) / (max - min) and percentile = share of sessions with lower IV, both 0 to 1 (rank is null when max equals min). Symbols need >= 20 sessions to qualify. side=call|put|both (default both = the higher of the two ranks) picks the rank used for min_rank/max_rank and ordering; lookback_days (1-3650) restricts the history, default is all of it. limit 1-500, default 100.
/api/v1/options-flow/iv-rank?side=put&min_rank=0.8&limit=50/api/v1/options-flow/iv-rank/:symbolPer-symbol IV rank time series, newest first. Each row carries that day's call/put IV with its rank and percentile against the full available history (or the last lookback_days). limit 1-500, default 60.
/api/v1/options-flow/iv-rank/:symbol/AMD?lookback_days=120/api/v1/options-flow/oi-changesDay-over-day open-interest change per symbol (total, calls, puts) versus the symbol's most recent earlier day with unusual flow in the last 30 days (prev_date), ordered by absolute percentage change, then symbol. Symbols without a prior observation or with prev_oi below min_prev_oi (default 1,000) are absent; a zero change is a row with oi_change 0. min_change_pct is a fraction (0.5 = 50%); side=gainers|losers|all. limit 1-500, default 100, plus offset with filtered_total / has_more.
/api/v1/options-flow/oi-changes?side=gainers&min_change_pct=0.5&limit=50/api/v1/options-flow/oi-changes/:symbolPer-symbol OI change time series with day-over-day deltas at the total / call / put level, newest first. The oldest row in the window has null prev_* and *_change fields. date_from/date_to; limit 1-500, default 60.
/api/v1/options-flow/oi-changes/:symbol/NVDA?limit=60/api/v1/options-flow/sectorsSector-level rollup of the day's unusual flow - symbol count, total volume, call/put/total premium, average net delta, and bullish/bearish symbol counts per sector label, ordered by total premium. Only labels with activity are returned: the 11 GICS sectors plus 'Diversified' and 'Unknown' from the classification source and 'Unclassified' for unmapped symbols, at most 14 rows. Params: date; limit 1-50, default 50 (always every group).
/api/v1/options-flow/sectors?limit=11/api/v1/options-flow/marketMarket-wide breadth of unusual flow + regime. One row per trading day, newest first, with the breadth score (0-100: share of classified symbols reading bullish; null only when none were classified), aggregate call/put ratio, regime label (bullish/bearish/mixed), and bullish/bearish symbol counts. Params: date or date_from/date_to; limit 1-365, default 30.
/api/v1/options-flow/market?limit=30/api/v1/webhooksList your webhooks - ticker lists + filters that push new unusual contracts to your https endpoint as signed JSON events instead of polling. Never includes the signing secret. See the Webhooks section for the delivery contract.
/api/v1/webhooksCreate a webhook: { url, tickers[] (max 150), name?, criteria?, notify_interval_min? (10|30|60|240|480|1440), active_hours?: { start, end } in ET, is_active? }. Answers 201 with the webhook and its secret (shown once). Webhooks and app alerts share the limit of 10 per subscription (409 LIMIT_REACHED).
/api/v1/webhooks/:idOne webhook with its pause state, consecutive_failures, last_run_at and last_delivered_at.
/api/v1/webhooks/:idPartial update of any create field. tickers replaces the list, criteria keys are merged. Sending url or is_active: true resumes a paused webhook and resets the failure counter.
/api/v1/webhooks/:idDelete the webhook and its delivery log. Returns { id, deleted: true }.
/api/v1/webhooks/:id/testSend one signed test event with a sample contract through the real delivery path. 200 when your endpoint answered 2xx within 5 s, 502 otherwise; both carry { delivered, delivery_id, attempts, http_status, duration_ms, error }. A successful test resumes a paused webhook.
/api/v1/webhooks/:id/deliveriesDelivery log, newest first: id, event, status, attempts, http_status, duration_ms, error, contracts (count), created_at. limit 1-200, default 50.
/api/v1/webhooks/:id/deliveries?limit=50/api/v1/webhooks/:id/rotate-secretGenerate a new signing secret and return it once. The old secret stops being used immediately.
/api/v1/healthReadiness probe. Public. Runs a database query and returns { status, version, timestamp, db: { reachable, latencyMs } }; 503 DB_UNHEALTHY when the database is unreachable.
/api/v1/pingLiveness probe. Public, no database. Returns { status: "ok", timestamp }.
Query parameters
Both contract-level endpoints (/unusual and /:symbol) accept the same filters; only the paging limits differ. Default scope is the latest scan day - everything else is opt-in. Parameters are validated up front: a malformed value answers 400 before any query runs (see Error responses).
Time scope
| Param | Type | Behavior |
|---|---|---|
| (none) | default | Latest scan day available (on /:symbol: the latest day with data for that symbol). |
| date | YYYY-MM-DD | A specific scan day. Must be a real calendar day, otherwise INVALID_DATE. |
| date_from | YYYY-MM-DD | Inclusive lower bound. Combine with date_to for a range; date_from after date_to is INVALID_DATE_RANGE. |
| date_to | YYYY-MM-DD | Inclusive upper bound. |
| since | ISO 8601 | Contracts whose last_seen_at >= this timestamp (unparseable: INVALID_SINCE). A polling window, not a monotonic cursor: last_seen_at is refreshed on every 5-minute scan during the regular session while the contract stays in the feed, so poll with a few minutes of overlap and de-duplicate on (data_date, symbol, option_symbol). On /unusual, since combines with the day scope (latest day unless date/date_from/date_to is set); on /:symbol, since alone spans all days. |
Symbol scope
| Param | Type | Behavior |
|---|---|---|
| symbols | comma list | Restrict to one or more tickers, e.g. ?symbols=AAPL,NVDA,TSLA. Case-insensitive; each token must match ^[A-Z][A-Z0-9.-]{0,9}$ after upper-casing. Invalid tokens next to valid ones are dropped; a list without a single valid token is INVALID_SYMBOLS. Ignored on /:symbol routes, where the path symbol follows the same rule (INVALID_SYMBOL). |
Contract filters
| Param | Type | Behavior |
|---|---|---|
| type | c | p | all | Calls only, puts only, or both (default). Case-insensitive; call/calls and put/puts are accepted too. Anything else is INVALID_PARAM. |
| min_voloi | number | Vol/OI ratio floor. Default 1.5 unless raw=1. |
| min_premium | number ($) | Estimated premium floor = last_price * volume * 100. Default $25k unless raw=1. |
| min_iv | number (%) | Implied volatility floor in percent (60 = 60%). |
| max_dte | integer | Max days to expiration (floats are floored). |
| min_volume | number | Contract volume floor. |
| min_oi | number | Open interest floor. Default 100 unless raw=1. |
| raw | 1 | true | Skips the always-on base floor (Vol/OI>=1.5, OI>=100, premium>=$25k). Use only if you want the unfiltered tape. |
| limit | integer | Max rows per page. /unusual: 1-1000, default 300. /:symbol: 1-500, default 100. Floats are floored, out-of-range values clamped. |
| offset | integer >= 0 | Rows to skip (default 0). Page through a full day with limit + offset; the envelope reports filtered_total and has_more. |
Numeric thresholds must be numbers (otherwise INVALID_PARAM); negative values are clamped to 0. The same rules apply to the numeric, integer and enum parameters of every other endpoint.
Ordering, paging and row identity
- Rows are ordered by
volume_oi_ratio * ln(premium_estimate + 1)descending, thendata_date, symbol, option_symbolascending. The tie-breaker makeslimit+offsetpaging deterministic. - A row is uniquely identified by
(data_date, symbol, option_symbol). - The envelope is
{ date, date_from, date_to, since, count, limit, offset, filtered_total, has_more, sort, data }(/:symboladdssymbolfirst).countis the rows in this response,filtered_totalthe rows matching the filters across all pages (0 when the page is past the end),sortis alwaysscore_desc,symbol,option_symbol. Empty results keep the same shape withcount: 0. - The current trading day is upserted every 5 minutes during the regular session: volume, last price, Vol/OI and therefore the score change, new contracts are added, none are removed. Past days are immutable, so a date-fixed query on a finished day is repeatable. For a consistent intraday enumeration, page quickly and de-duplicate on the row identity.
last_seen_atis only refreshed during the regular session; a row'sfirst_seen_at/last_seen_atare the scan timestampssincefilters on, null when no intraday appearance was recorded.
Examples
# Latest scan, default filters
GET /api/v1/options-flow/unusual
# Calls only on NVDA + TSLA with Vol/OI >= 5 and $250k+ premium
GET /api/v1/options-flow/unusual?symbols=NVDA,TSLA&type=c&min_voloi=5&min_premium=250000
# Last 7 days of AAPL flow, ranked by score
GET /api/v1/options-flow/AAPL?date_from=2026-06-01&date_to=2026-06-07
# Anything seen in the last 10 minutes (polling; de-duplicate on the row identity)
GET /api/v1/options-flow/unusual?since=2026-06-08T14:30:00Z
# Page through a full day
GET /api/v1/options-flow/unusual?date=2026-06-05&limit=1000&offset=1000
# Short-dated put hedging, IV > 60%, DTE <= 14
GET /api/v1/options-flow/unusual?type=p&min_iv=60&max_dte=14Derived endpoints: paging and completeness
/top,/streaks,/sentimentand/oi-changespage the same way as/unusual:limit+offset, and the envelope carriescount, limit, offset, filtered_total, has_more. Each has a deterministic tie-breaker (symbol, andoption_symbolfor/top), so pages of a finished day never overlap or skip.- A result is complete when
has_moreis false, equivalently whenoffset + countreachesfiltered_total. On every endpoint,countbelow the requestedlimitalso means nothing was cut; there is no other cap./sectorshas at most 14 rows and its default limit returns all of them. /topis not a fixed global top-N:symbols,typeandmin_premiumfilter before ranking andrankis the position in the full filtered set. A ticker with more than 100 qualifying prints is enumerated withoffset.- Absence semantics.
/streaks(min_streak=1,side=all) and/sentiment(noside/signal_only) have one row per symbol with unusual flow that day, so a ticker missing from the complete result had no unusual contract on that day./oi-changesadditionally omits symbols with no earlier unusual-flow day in the previous 30 days or withprev_oibelowmin_prev_oi; a zero change is returned as a row withoi_change: 0. - Finished days are repeatable: contract rows are immutable, the per-symbol daily aggregates are recomputed only for the two most recent days on each scan, and signals are computed once per day. The
sector/industrylabels are joined from a mapping table at query time and can change if a symbol is reclassified.
Types & units
Values are serialized straight from Postgres, so the JSON type follows the column type. The OpenAPI spec marks every field.
- Numeric strings. Prices, volumes, open interest, premiums, ratios, IV and other BIGINT / NUMERIC columns arrive as strings (
"1234","25.43") to preserve precision - parse them on your side. - Numbers. Integer counts (
days_to_expiration, calendar days fromdata_datetoexpiration_datecomputed by the API and authoritative formax_dteand the expiry buckets,rank,consecutive_days,strength_score,breadth_score, contract counts) and float columns (oi_change_pct,call_put_ratio,*_iv_percentile) are JSON numbers; flags are booleans; text arrays such asbullish_confirmationsare string arrays. - Dates and times. Trading days (
date,data_date,prev_date,history_start) areYYYY-MM-DD.first_seen_at/last_seen_atand thesinceecho are ISO-8601 timestamps in UTC. - Units. Implied volatility (
volatility,*_iv,min_iv) is in percent (25.43 = 25.43%). Premiums are USD, estimated aslast_price * volume * 100. IV rank, IV percentile,min_rank/max_rankandoi_change_pct/min_change_pctare fractions 0..1.
Error responses
All errors return JSON in the form { "error": "<message>", "code": "<CODE>" }; a 500 adds requestId (same value as the X-Request-Id header). 400, 404, 409 and 429 responses carry the rate-limit headers; 401, 403 and 503 do not. POST /webhooks/:id/test answers 502 with the regular test-result body (not an error object) when your endpoint did not accept the event.
| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_DATE | date / date_from / date_to is not YYYY-MM-DD or not a real calendar day. |
| 400 | INVALID_DATE_RANGE | date_from is after date_to. |
| 400 | INVALID_SINCE | since is not a parseable ISO-8601 timestamp. |
| 400 | INVALID_PARAM | A numeric or integer parameter is not a number, or an enum parameter (type, side) has an unknown value. |
| 400 | INVALID_SYMBOLS | symbols was sent but contains no valid ticker. |
| 400 | INVALID_SYMBOL | The path symbol on a /:symbol route is not a valid ticker. |
| 400 | INVALID_URL | Webhooks: url is not an absolute https URL on a public host, or the host does not resolve. |
| 401 | MISSING_API_KEY | No X-API-Key header. |
| 401 | INVALID_API_KEY_FORMAT | Key does not start with sms_ or is too short. |
| 401 | INVALID_API_KEY | Key not recognized. |
| 403 | NO_ACTIVE_SUBSCRIPTION | Key valid but no Personal subscription attached. |
| 403 | SUBSCRIPTION_INACTIVE | Subscription past due, paused, expired, or canceled past its period end. |
| 404 | NOT_FOUND | Webhooks: no webhook with this id on your subscription. |
| 409 | LIMIT_REACHED | POST /webhooks: the subscription already holds 10 alerts and webhooks in total. |
| 429 | RATE_LIMIT_MINUTE | Per-minute cap (60) hit. |
| 429 | RATE_LIMIT_DAY | Daily cap (4,000) hit. |
| 500 | INTERNAL_ERROR | Unexpected server error; body and X-Request-Id carry the request id. |
| 503 | AUTH_UNAVAILABLE | Auth backend temporarily unreachable - retry later. |
| 503 | DB_UNHEALTHY | /health only: database unreachable. |
Webhooks
Instead of polling /unusual, register a webhook: a ticker list plus optional filters, stored once. OptionsBell then POSTs a signed JSON event to your https endpoint whenever new unusual contracts matching the filters appear for those tickers. Webhooks are managed only through this API (no UI), require the same key and Personal subscription, and the management calls count against the rate limits like any other request - outbound deliveries do not.
When a webhook fires
- Evaluation runs every 5 minutes during the webhook's
active_hours(default 09:00-16:00 America/New_York, end exclusive, may wrap past midnight). Outside the window nothing is evaluated or delivered. - Each run compares the current unusual-activity scan for your tickers against the contracts you have already been sent. Only contracts you have not received yet are delivered, so each contract is delivered once - a contract that stays unusual for days does not repeat. De-duplicate on
option_symbolanyway if you retry on your side. - At most one delivery per
notify_interval_min(default 10; choices 10, 30, 60, 240, 480, 1440), counted from the last successful delivery. Contracts that surface during the cooldown are batched into the next delivery, not dropped. - 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. Expect the first event at the earliest ~10 minutes after creation, and only if something new shows up. Adding tickers later does not reset the baseline: contracts already unusual on a newly added ticker arrive with the next evaluation.
- The same base floor as the app alerts applies (Vol/OI >= 1.5, OI >= 100, premium >= $25k) plus your optional
criteria(type,min_voloi,min_premium,min_iv,max_dte,min_volume,min_oi- same semantics as the query parameters above). An event carries at most 10 contracts per ticker (highest Vol/OI first); anything beyond that cap is marked as seen and not delivered later. - Deliveries are best-effort push, typically 1-6 minutes after a print appears in our data. For an audit trail or a backfill after downtime, query
/unusualwithsince.
Management endpoints
| Method | Path | Behavior |
|---|---|---|
| GET | /webhooks | List all webhooks: { count, data: Webhook[] }. Never includes the secret. |
| POST | /webhooks | Create. Body: { url, tickers[], name?, criteria?, notify_interval_min?, active_hours?: { start, end }, is_active? }. Answers 201 with the webhook plus secret - shown exactly once. 409 LIMIT_REACHED when the subscription already holds 10 alerts and webhooks in total. |
| GET | /webhooks/:id | One webhook with its pause state, consecutive_failures, last_run_at and last_delivered_at. |
| PATCH | /webhooks/:id | Partial update, any subset of the create fields. tickers replaces the list, criteria keys are merged (null drops a constraint). Sending url or is_active: true resumes a paused webhook and resets the failure counter; is_active: false stops evaluation. |
| DELETE | /webhooks/:id | Deletes the webhook and its delivery log: { id, deleted: true }. |
| POST | /webhooks/:id/test | Sends one signed test event with a sample contract using the real delivery path. 200 { delivered: true, ... } or 502 { delivered: false, ... } with delivery_id, attempts, http_status, duration_ms, error. A successful test resumes a paused webhook; a failed one does not count towards pausing. |
| GET | /webhooks/:id/deliveries | Delivery log, newest first, ?limit=50 (1-200): { webhook_id, count, data: [{ id, event, status, attempts, http_status, duration_ms, error, contracts, created_at }] }. contracts is the number of contracts in the payload. |
| POST | /webhooks/:id/rotate-secret | New signing secret, returned once with the webhook. The old secret stops being used immediately. |
Validation errors are 400 with INVALID_PARAM (malformed body, name, criteria, notify_interval_min, active_hours, is_active), INVALID_URL (not https, not a public host, does not resolve) or INVALID_SYMBOLS (tickers missing, invalid, or more than 150); an unknown id is 404 NOT_FOUND. The url must be an absolute https URL on a public host - localhost, *.local, *.internal and hosts resolving to private, loopback or link-local ranges are rejected at registration. Redirects are not followed.
Create a webhook
curl -X POST https://optionsbell.com/api/v1/webhooks \
-H "X-API-Key: sms_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "NVDA/TSLA calls",
"url": "https://example.com/hooks/optionsbell",
"tickers": ["NVDA", "TSLA"],
"criteria": { "type": "c", "min_premium": 250000, "max_dte": 30 },
"notify_interval_min": 10,
"active_hours": { "start": "09:30", "end": "16:00" }
}'
# 201
{
"id": "cmg1x2y3z0000abcd12345678",
"name": "NVDA/TSLA calls",
"url": "https://example.com/hooks/optionsbell",
"tickers": ["NVDA", "TSLA"],
"criteria": { "type": "c", "min_voloi": null, "min_premium": 250000, "min_iv": null, "max_dte": 30, "min_volume": null, "min_oi": null },
"notify_interval_min": 10,
"active_hours": { "start": "09:30", "end": "16:00", "timezone": "America/New_York" },
"is_active": true,
"paused_at": null,
"consecutive_failures": 0,
"last_run_at": null,
"last_delivered_at": null,
"created_at": "2026-09-23T13:02:11.480Z",
"updated_at": "2026-09-23T13:02:11.480Z",
"secret": "whsec_0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a6978"
}
# Verify your endpoint end-to-end
curl -X POST https://optionsbell.com/api/v1/webhooks/cmg1x2y3z0000abcd12345678/test \
-H "X-API-Key: sms_xxxxxxxxxxxxxxxx"Event payload
Every delivery is a POST with Content-Type: application/json, User-Agent: OptionsBell-Webhooks/1.0, X-OptionsBell-Event (unusual_activity or test), X-OptionsBell-Delivery (the delivery id, unique per delivery and identical across its retries - use it for idempotency) and X-OptionsBell-Signature. Unlike the data endpoints, the numeric fields in the payload are JSON numbers; strike_price and expiration_date are the stored strings.
POST https://example.com/hooks/optionsbell
Content-Type: application/json
User-Agent: OptionsBell-Webhooks/1.0
X-OptionsBell-Event: unusual_activity
X-OptionsBell-Delivery: whd_3f9c1a2b4d5e6f708192a3b4
X-OptionsBell-Signature: t=1758640502,v1=5257a869e7ecebcaa3d8c7cd39ab5f2b4d9e0c1a2b3c4d5e6f708192a3b4c5d6
{
"event": "unusual_activity",
"delivery_id": "whd_3f9c1a2b4d5e6f708192a3b4",
"sent_at": "2026-09-23T14:35:02.117Z",
"data_date": "2026-09-23",
"webhook": { "id": "cmg1x2y3z0000abcd12345678", "name": "NVDA/TSLA calls" },
"count": 2,
"contracts": [
{
"symbol": "NVDA",
"option_symbol": "NVDA|20261016|215.00C",
"symbol_type": "Call",
"strike_price": "215.0000",
"expiration_date": "10/16/26",
"days_to_expiration": 23,
"volume_oi_ratio": 85.3,
"volatility": 54,
"premium_estimate": 13700000
},
{
"symbol": "TSLA",
"option_symbol": "TSLA|20261009|460.00C",
"symbol_type": "Call",
"strike_price": "460.0000",
"expiration_date": "10/09/26",
"days_to_expiration": 16,
"volume_oi_ratio": 12.4,
"volatility": 61,
"premium_estimate": 2450000
}
]
}Verifying the signature
X-OptionsBell-Signature is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 over the string <t>.<raw body> keyed with your webhook secret. Compute it over the raw request bytes exactly as received (do not parse and re-serialize), compare in constant time, and reject requests whose t is too far from your clock. The 5-minute tolerance below is a client-side recommendation against replay - OptionsBell signs a fresh t on every attempt but does not enforce a tolerance itself.
// Node.js (Express: use express.raw({ type: "application/json" }) so req.body is a Buffer)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOptionsBell(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
app.post("/hooks/optionsbell", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyOptionsBell(req.body, req.get("X-OptionsBell-Signature") ?? "", process.env.OB_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(204).end(); // answer 2xx within 5 s, then do the work
});# Python
import hmac, hashlib, time
def verify_optionsbell(raw_body: bytes, signature_header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
# Flask: verify_optionsbell(request.get_data(), request.headers.get("X-OptionsBell-Signature", ""), SECRET)Retries and pausing
- Any 2xx within 5 seconds counts as delivered. Respond first, process after.
- Up to 3 attempts per delivery: immediately, after 2 s, after 6 s. 5xx, 408, 429, timeouts and network errors are retried; any other 4xx ends the delivery. Redirects are not followed. Each attempt is signed with a fresh
t; the body anddelivery_idstay the same. - A failed delivery leaves its contracts marked as not-yet-delivered, so the next evaluation (5 minutes later) sends them again - the cooldown only counts from successful deliveries.
- After 20 consecutive failed deliveries the webhook is paused:
paused_atis set and nothing more is sent (it is still evaluated, so contracts keep being marked as seen). Resume it with aPATCHcarrying aurloris_active: true, or with a successful/test. Any successful delivery resetsconsecutive_failuresto 0; failed test events do not count. GET /webhooks/:id/deliveriesshows every delivery with its last HTTP status, attempts, duration and error text - check it before re-enabling.
Limits and licensing
- Webhooks and app-created alerts share a limit of 10 per subscription; up to 150 tickers per webhook;
name1-80 characters;urlup to 2048 characters. - Webhooks stop firing when the subscription ends (a canceled subscription keeps working until the end of its paid period); the configuration is kept.
- Deliveries go to your own endpoint and the one-person rule of the Personal plan applies to them exactly as to API responses: consume the events yourself, do not forward them to colleagues, channels or products for others.
Coverage and freshness
- 7,000+ US-listed common stocks and ADRs with active options.
- The current trading day is refreshed every 5 minutes during the regular session; rows are added or updated, never removed. Past days are immutable.
- Historical days are queryable everywhere via
date/date_from/date_to, and as an end-of-day series via/options-flow/history/:symbol. Call/options-flow/statsto see the available date range. - All timestamps are UTC; trading days are the US session date.
Get a key
Generate your API key in seconds
Subscribe, then create a key from your settings. Keys are scoped per-account and revocable any time.
Go to settings →