A webhook is only as useful as the thing on the other end of it. This post is a list of receivers for the OptionsBell unusual options activity webhook that people actually run, from a five-minute no-code setup to a small database that gets smarter over time. Each one includes the part everybody skips: verifying that the event is genuine before acting on it.
If you have not read what a delivery contains, the explainer covers it in two minutes. Short version: a signed POST with a list of contracts, each carrying symbol, call or put, strike, expiry, days to expiration, Vol/OI, IV and an estimated premium. One contract is delivered once. Events arrive every 5 minutes at most, only inside your active hours, only when something new matches your filters.
Setup, once
Create the webhook with the tickers and filters you want, then fire a test event to prove the receiver works before the market does it for you.
# 1. create
curl -X POST https://optionsbell.com/api/v1/webhooks \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "name": "Core watchlist", "url": "https://YOUR_RECEIVER/hooks/optionsbell",
"tickers": ["NVDA","AMD","AVGO","TSM","META","AMZN"],
"criteria": { "min_premium": 250000, "max_dte": 60 },
"notify_interval_min": 10 }'
# -> 201 with "secret": "whsec_..." (shown once; store it as OB_WEBHOOK_SECRET)
# 2. test the real delivery path
curl -X POST https://optionsbell.com/api/v1/webhooks/WEBHOOK_ID/test -H "X-API-Key: YOUR_KEY"
# -> 200 { "delivered": true, "http_status": 204, "duration_ms": 131, ... }Every receiver below follows the same three rules. Verify the signature over the raw bytes. Answer 2xx within five seconds and do the slow work afterwards. Treat delivery_id as the idempotency key, because a retry that eventually succeeds twice is possible and should be harmless.
1. A private Slack or Discord channel
The most common receiver: a channel only you read, on a workspace or server you own, that turns each contract into a one-line message. Slack and Discord both accept messages through their own incoming webhook URLs, so the receiver is a relay. A Cloudflare Worker does it in one file, free, with the signature check done with Web Crypto:
// worker.js - env: OB_WEBHOOK_SECRET, DISCORD_WEBHOOK_URL
export default {
async fetch(req, env, ctx) {
if (req.method !== "POST") return new Response("nope", { status: 405 });
const raw = await req.text();
if (!(await verify(raw, req.headers.get("X-OptionsBell-Signature") ?? "", env.OB_WEBHOOK_SECRET))) {
return new Response("bad signature", { status: 401 });
}
const ev = JSON.parse(raw);
if (ev.event !== "unusual_activity") return new Response(null, { status: 204 });
const lines = ev.contracts.map((c) =>
`${c.symbol} ${c.symbol_type} ${Number(c.strike_price)} exp ${c.expiration_date} (${c.days_to_expiration}d) ` +
`Vol/OI ${c.volume_oi_ratio}x $${(c.premium_estimate / 1e6).toFixed(2)}M IV ${c.volatility}%`
);
// respond first, let the relay finish in the background
ctx.waitUntil(fetch(env.DISCORD_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "**OptionsBell " + ev.data_date + "**\n" + lines.join("\n") }),
}));
return new Response(null, { status: 204 });
},
};
async function verify(raw, header, secret) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
if (!parts.t || !parts.v1 || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${parts.t}.${raw}`));
const hex = [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (hex.length !== parts.v1.length) return false;
let diff = 0;
for (let i = 0; i < hex.length; i++) diff |= hex.charCodeAt(i) ^ parts.v1.charCodeAt(i);
return diff === 0;
}For Slack, swap the Discord URL for a Slack incoming webhook and send { text } instead of { content }. The ctx.waitUntil call is what lets the Worker answer us immediately and still finish the relay.
2. Telegram, to yourself
Same relay, different destination. Create a bot with BotFather, open a chat with it, and the receiver calls https://api.telegram.org/bot<token>/sendMessage with your own chat id. Telegram is the better choice if you want the message on a phone with a distinct sound and no workspace app open. Set the webhook's cooldown to 30 or 60 minutes for this one; a phone that buzzes every ten minutes during the open is not a monitoring tool, it is a distraction.
3. A Google Sheet that keeps a running log
A spreadsheet is the cheapest database and the easiest place to sort by premium at the end of the week. Google Apps Script can publish a doPost function as a web app URL and append rows. One caveat: Apps Script does not expose request headers to doPost, so you cannot verify the signature there. Treat the long, unguessable web-app URL as the secret, keep the sheet append-only, and do not put anything downstream of it that spends money.
// Apps Script - deploy as Web app, access: Anyone (the URL is your secret)
function doPost(e) {
const ev = JSON.parse(e.postData.contents);
if (ev.event !== "unusual_activity") return ContentService.createTextOutput("ok");
const sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("flow");
ev.contracts.forEach((c) => sh.appendRow([
ev.data_date, ev.delivery_id, c.symbol, c.symbol_type, Number(c.strike_price),
c.expiration_date, c.days_to_expiration, c.volume_oi_ratio, c.volatility, c.premium_estimate,
]));
return ContentService.createTextOutput("ok");
}4. Notion, Airtable or anything else via n8n
If the destination has an n8n, Make or Zapier connector, you do not need code at all. In n8n the flow is three nodes: a Webhook node (enable the raw body option so the signature can be checked), a Code node that recomputes the HMAC from the raw body and the header and stops the run on mismatch, and a Notion or Airtable node that creates one page or record per contract. Split the contracts array with an Item Lists node in between. Make and Zapier work the same way with their catch-hook triggers; where a tool cannot give you the raw body, fall back to the unguessable-URL approach from the spreadsheet recipe and keep the workflow read-only.
5. A Postgres journal that spots returning prints
This is the one that gets more valuable every week. Store every contract you receive, keyed on the option symbol, and the database can tell you something a single event never can: whether this name has been printing repeatedly. A contract that shows up on a Monday, then a different strike on the same name on Wednesday, then a third on Friday is a pattern, and patterns are where the evidence for flow-based signals lives.
create table flow_events (
delivery_id text not null,
option_symbol text not null,
symbol text not null,
side text not null, -- Call | Put
strike numeric not null,
expiration date not null,
dte int not null,
vol_oi numeric not null,
iv numeric,
premium numeric not null,
data_date date not null,
received_at timestamptz not null default now(),
primary key (delivery_id, option_symbol) -- retries become no-ops
);
-- names with unusual prints on 3+ distinct days in the last 10 sessions
select symbol, count(distinct data_date) as days, sum(premium) as total_premium
from flow_events
where data_date >= current_date - interval '14 days'
group by symbol
having count(distinct data_date) >= 3
order by total_premium desc;The receiver is the Vercel handler in the next recipe plus an insert ... on conflict do nothing. Once the table exists, the daily digest we built earlier can read from it instead of calling the API, and can add a returning-prints column for free.
6. A Vercel or Next.js route as the receiver
If your project is already on Next.js, the receiver is a route handler. The important detail is reading the body as text before parsing so the signature is computed over the exact bytes we signed.
// app/api/hooks/optionsbell/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";
export async function POST(req: Request) {
const raw = await req.text();
const header = req.headers.get("x-optionsbell-signature") ?? "";
if (!verify(raw, header, process.env.OB_WEBHOOK_SECRET!)) {
return new Response("bad signature", { status: 401 });
}
const ev = JSON.parse(raw);
after(async () => {
if (ev.event !== "unusual_activity") return;
await storeContracts(ev); // your insert / relay / enrichment
});
return new Response(null, { status: 204 });
}
function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.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(raw).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}7. Enrich before you look
A webhook event is a trigger, and the options flow API is where the trigger gets its context. A receiver that, on each event, pulls the last ten sessions of unusual activity for the symbol and the day's sector picture can rank contracts before you ever see them: a $2M call on a name that has been quiet for a month reads differently from the same print on a name that has been sweeping calls every day. The pipeline post shows the pattern of joining flow with a second data source; a webhook simply replaces the scheduler at the front of it. Keep the enrichment in the background step, after the 2xx has gone out.
Operational checklist
- Respond 2xx within 5 seconds, then process. Slow receivers look like failed ones and trigger retries.
- Verify the signature on every request, over the raw body, in constant time. Reject stale timestamps.
- Make
delivery_idplusoption_symbolyour unique key. Retries become no-ops. - Check
GET /webhooks/:id/deliverieswhen something looks wrong. It shows every attempt with HTTP status, duration and error text. - After 20 consecutive failures the webhook pauses. Fix the receiver, then resume with a PATCH or a successful test call.
- Rotate the secret if it ever lands in a log.
POST /webhooks/:id/rotate-secretreturns a new one once; the old one stops working immediately. - Match
notify_interval_minto the receiver: 10 for databases, 60 or more for anything that pings a human.
One license, one person
All of the above is built for you. The Personal plan is one subscription per person, and that rule applies to webhook deliveries exactly as it applies to API responses. A channel you read, a sheet you sort, a database that feeds your own research: all fine, including for your own business decisions. Forwarding events into a channel for colleagues, a community, a newsletter or a product for other people is not covered; each of those people needs their own subscription. The terms spell it out.
Everything here runs on the same $24.99 plan as the email alerts, the REST API and the MCP server, with no extra fee for push delivery. The full reference, including the payload schema and every management endpoint, is in the API docs. If you build something not on this list, we would like to hear about it.