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.

JSON over HTTPSX-API-Key authv1 stableUsing Claude? Connect via MCP →

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.

HeaderMeaning
X-RateLimit-LimitRequests allowed per minute (60).
X-RateLimit-RemainingRequests left in the current minute window.
X-RateLimit-ResetUnix timestamp (seconds) when the minute window resets. Always the minute window.
X-RateLimit-Daily-LimitRequests allowed per day (4000).
X-RateLimit-Daily-RemainingRequests left in the current day window.
Retry-AfterOn 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

GET/api/v1/options-flow/unusual

Unusual 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
GET/api/v1/options-flow/:symbol

Per-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
GET/api/v1/options-flow/top

Top 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
GET/api/v1/options-flow/history/:symbol

Per-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
GET/api/v1/options-flow/streaks

Multi-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
GET/api/v1/options-flow/expiry

Expiry 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
GET/api/v1/options-flow/stats

Dataset 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.

GET/api/v1/options-flow/sentiment

Per-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&sector=Information+Technology&limit=50
GET/api/v1/options-flow/sentiment/:symbol

Per-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
GET/api/v1/options-flow/iv-rank

IV 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
GET/api/v1/options-flow/iv-rank/:symbol

Per-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
GET/api/v1/options-flow/oi-changes

Day-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
GET/api/v1/options-flow/oi-changes/:symbol

Per-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
GET/api/v1/options-flow/sectors

Sector-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
GET/api/v1/options-flow/market

Market-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
GET/api/v1/webhooks

List 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.

POST/api/v1/webhooks

Create 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).

GET/api/v1/webhooks/:id

One webhook with its pause state, consecutive_failures, last_run_at and last_delivered_at.

PATCH/api/v1/webhooks/:id

Partial 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.

DELETE/api/v1/webhooks/:id

Delete the webhook and its delivery log. Returns { id, deleted: true }.

POST/api/v1/webhooks/:id/test

Send 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.

GET/api/v1/webhooks/:id/deliveries

Delivery 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
POST/api/v1/webhooks/:id/rotate-secret

Generate a new signing secret and return it once. The old secret stops being used immediately.

GET/api/v1/health

Readiness probe. Public. Runs a database query and returns { status, version, timestamp, db: { reachable, latencyMs } }; 503 DB_UNHEALTHY when the database is unreachable.

GET/api/v1/ping

Liveness 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

ParamTypeBehavior
(none)defaultLatest scan day available (on /:symbol: the latest day with data for that symbol).
dateYYYY-MM-DDA specific scan day. Must be a real calendar day, otherwise INVALID_DATE.
date_fromYYYY-MM-DDInclusive lower bound. Combine with date_to for a range; date_from after date_to is INVALID_DATE_RANGE.
date_toYYYY-MM-DDInclusive upper bound.
sinceISO 8601Contracts 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

ParamTypeBehavior
symbolscomma listRestrict 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

ParamTypeBehavior
typec | p | allCalls only, puts only, or both (default). Case-insensitive; call/calls and put/puts are accepted too. Anything else is INVALID_PARAM.
min_voloinumberVol/OI ratio floor. Default 1.5 unless raw=1.
min_premiumnumber ($)Estimated premium floor = last_price * volume * 100. Default $25k unless raw=1.
min_ivnumber (%)Implied volatility floor in percent (60 = 60%).
max_dteintegerMax days to expiration (floats are floored).
min_volumenumberContract volume floor.
min_oinumberOpen interest floor. Default 100 unless raw=1.
raw1 | trueSkips the always-on base floor (Vol/OI>=1.5, OI>=100, premium>=$25k). Use only if you want the unfiltered tape.
limitintegerMax rows per page. /unusual: 1-1000, default 300. /:symbol: 1-500, default 100. Floats are floored, out-of-range values clamped.
offsetinteger >= 0Rows 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, then data_date, symbol, option_symbol ascending. The tie-breaker makeslimit + offset paging 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 } (/:symbol adds symbol first). count is the rows in this response, filtered_total the rows matching the filters across all pages (0 when the page is past the end), sort is always score_desc,symbol,option_symbol. Empty results keep the same shape with count: 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_at is only refreshed during the regular session; a row's first_seen_at / last_seen_at are the scan timestamps since filters 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=14

Derived endpoints: paging and completeness

  • /top, /streaks, /sentiment and /oi-changes page the same way as /unusual: limit + offset, and the envelope carries count, limit, offset, filtered_total, has_more. Each has a deterministic tie-breaker (symbol, and option_symbol for /top), so pages of a finished day never overlap or skip.
  • A result is complete when has_more is false, equivalently when offset + count reaches filtered_total. On every endpoint, count below the requested limit also means nothing was cut; there is no other cap. /sectors has at most 14 rows and its default limit returns all of them.
  • /top is not a fixed global top-N: symbols, type and min_premium filter before ranking and rank is the position in the full filtered set. A ticker with more than 100 qualifying prints is enumerated with offset.
  • Absence semantics. /streaks (min_streak=1, side=all) and /sentiment (no side / 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-changes additionally omits symbols with no earlier unusual-flow day in the previous 30 days or with prev_oi below min_prev_oi; a zero change is returned as a row with oi_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 / industry labels 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 from data_date to expiration_date computed by the API and authoritative for max_dte and 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 as bullish_confirmations are string arrays.
  • Dates and times. Trading days (date, data_date, prev_date, history_start) are YYYY-MM-DD. first_seen_at / last_seen_at and the since echo are ISO-8601 timestamps in UTC.
  • Units. Implied volatility (volatility, *_iv, min_iv) is in percent (25.43 = 25.43%). Premiums are USD, estimated as last_price * volume * 100. IV rank, IV percentile, min_rank/max_rank and oi_change_pct/min_change_pct are 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.

StatusCodeMeaning
400INVALID_DATEdate / date_from / date_to is not YYYY-MM-DD or not a real calendar day.
400INVALID_DATE_RANGEdate_from is after date_to.
400INVALID_SINCEsince is not a parseable ISO-8601 timestamp.
400INVALID_PARAMA numeric or integer parameter is not a number, or an enum parameter (type, side) has an unknown value.
400INVALID_SYMBOLSsymbols was sent but contains no valid ticker.
400INVALID_SYMBOLThe path symbol on a /:symbol route is not a valid ticker.
400INVALID_URLWebhooks: url is not an absolute https URL on a public host, or the host does not resolve.
401MISSING_API_KEYNo X-API-Key header.
401INVALID_API_KEY_FORMATKey does not start with sms_ or is too short.
401INVALID_API_KEYKey not recognized.
403NO_ACTIVE_SUBSCRIPTIONKey valid but no Personal subscription attached.
403SUBSCRIPTION_INACTIVESubscription past due, paused, expired, or canceled past its period end.
404NOT_FOUNDWebhooks: no webhook with this id on your subscription.
409LIMIT_REACHEDPOST /webhooks: the subscription already holds 10 alerts and webhooks in total.
429RATE_LIMIT_MINUTEPer-minute cap (60) hit.
429RATE_LIMIT_DAYDaily cap (4,000) hit.
500INTERNAL_ERRORUnexpected server error; body and X-Request-Id carry the request id.
503AUTH_UNAVAILABLEAuth backend temporarily unreachable - retry later.
503DB_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_symbol anyway 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 /unusual with since.

Management endpoints

MethodPathBehavior
GET/webhooksList all webhooks: { count, data: Webhook[] }. Never includes the secret.
POST/webhooksCreate. 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/:idOne webhook with its pause state, consecutive_failures, last_run_at and last_delivered_at.
PATCH/webhooks/:idPartial 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/:idDeletes the webhook and its delivery log: { id, deleted: true }.
POST/webhooks/:id/testSends 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/deliveriesDelivery 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-secretNew 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 and delivery_id stay 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_at is set and nothing more is sent (it is still evaluated, so contracts keep being marked as seen). Resume it with a PATCH carrying a url or is_active: true, or with a successful /test. Any successful delivery resets consecutive_failures to 0; failed test events do not count.
  • GET /webhooks/:id/deliveries shows 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; name 1-80 characters; url up 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/stats to 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 →