Strategy·Developer / quant··10 min read

Seven things to build on an options flow webhook, and how to wire each one

Concrete integrations for the OptionsBell webhook: a private Slack or Discord channel, Telegram to yourself, a Google Sheet, Notion via n8n, a Postgres journal that spots returning prints, a Vercel receiver and an enrichment step, each with its verification.

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_id plus option_symbol your unique key. Retries become no-ops.
  • Check GET /webhooks/:id/deliveries when 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-secret returns a new one once; the old one stops working immediately.
  • Match notify_interval_min to 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.