The analytical case for putting transcripts next to options data is covered elsewhere on this blog. This post is the engineering version: the endpoints, the join, the caching, and the parts that break.
Two APIs, both authenticating with an X-API-Key header, which keeps the client code symmetrical.
The shape of the join
The naive join is on ticker. It does not work, for the reason laid out in the AMD case: a one-day contract and an eighteen-month contract in the same symbol answer different questions, and joining on ticker alone throws them in the same bucket.
The join that works has three keys:
- Ticker, to identify the company
- Days to expiry, to identify the horizon the position is betting on
- Event date, to identify what resolves inside that horizon
Key three is the one the options API cannot supply and the transcripts API can. That asymmetry is the whole reason to run both.
Step one: pull positioning, filtered by tenor
The flow API exposes a contract-level scan with the filters you need applied server side, so you are not paginating through a day's full tape to throw most of it away.
curl -H "X-API-Key: $OPTIONSBELL_KEY" \
"https://optionsbell.com/api/v1/options-flow/unusual?min_premium=1000000&min_voloi=5"
The documented filters on /options-flow/unusual are symbols, type (c/p), min_voloi, min_premium, min_iv, max_dte, date, and since for cursor pagination.
Note what is missing from that list: a minimum DTE. There is max_dte but no min_dte, so the "discard everything expiring this week" filter happens client side:
const FLOW_BASE = 'https://optionsbell.com/api/v1';
const flowHeaders = { 'X-API-Key': process.env.OPTIONSBELL_KEY };
async function positioningWithHorizon({ minDte = 21, minPremium = 1_000_000 } = {}) {
const url = new URL(`${FLOW_BASE}/options-flow/unusual`);
url.searchParams.set('min_premium', String(minPremium));
url.searchParams.set('min_voloi', '5');
const res = await fetch(url, { headers: flowHeaders });
if (!res.ok) throw new Error(`flow ${res.status}`);
const { data } = await res.json();
// Contracts expiring inside the week are dominated by hedging and
// expiry mechanics. They tell you about the session, not the business.
return data.filter(row => row.dte >= minDte);
}
On a quarterly expiry session this filter removes most of the volume, which is the point. A day like 18 September 2026 produced 2,380 qualifying prints, and the overwhelming majority expired that same afternoon.
Step two: resolve each ticker to its most recent call
One request per surviving ticker:
const EC_BASE = 'https://earningscalls.dev/api/v1';
const ecHeaders = { 'X-API-Key': process.env.EARNINGSCALLS_KEY };
async function latestCall(ticker) {
const res = await fetch(`${EC_BASE}/companies/ticker/${ticker}/latest`, { headers: ecHeaders });
if (res.status === 404) return null; // covered ticker, no call on file
if (!res.ok) throw new Error(`transcripts ${res.status}`);
return res.json();
}
/companies/ticker/{ticker}/latest returns a single row rather than the company's full history, which matters when you are fanning out across thirty tickers. Pulling the archive per name and taking the first element works but wastes a lot of payload.
Step three: fetch only the part of the transcript you need
A full transcript is large. If the pipeline feeds a model or a dashboard, three narrower endpoints usually beat pulling the whole thing:
// Condensed version, best default for scanning many names
const summary = await fetch(`${EC_BASE}/transcripts/${id}/summary`, { headers: ecHeaders });
// Structured turns with roles, for anything that cares who spoke
const speakers = await fetch(`${EC_BASE}/speakers/${id}?role=executive`, { headers: ecHeaders });
// Targeted phrase search, scoped to a ticker and a speaker role
const hits = await fetch(
`${EC_BASE}/search/?ticker=NVDA&q=supply%20constraint&speaker_type=executive`,
{ headers: ecHeaders }
);
The search endpoint takes q, type, speaker_type, ticker, sector, industry, date_from, date_to, page and limit. The speaker_type filter is the one that earns its keep here: an analyst asking about supply constraints and an executive conceding them are opposite signals, and a naive keyword search on the full text conflates them.
Step four: the term structure check
The most mechanical thing in this pipeline is worth automating first, because it needs no language processing at all.
The flow API exposes /options-flow/expiry, which returns premium distributed across DTE buckets. Rising implied volatility with tenor localises a market-priced event to a window, as in the Intel chain on 18 September, where IV ran from roughly 55% at four days to 74% at forty-three.
Convert the bucket to a date range, then ask the transcripts API what is scheduled inside it:
async function eventsInWindow(ticker, fromIso, toIso) {
const url = new URL(`${EC_BASE}/earnings/upcoming`);
url.searchParams.set('ticker', ticker);
const res = await fetch(url, { headers: ecHeaders });
const { data = [] } = await res.json();
return data.filter(e => e.event_date >= fromIso && e.event_date <= toIso);
}
Two outcomes, both useful. A scheduled report inside the window explains the volatility hump, and the positioning above it is pre-earnings. Nothing scheduled inside the window is the more interesting case: the market is pricing something the calendar does not know about.
Step five: backtesting the pairing
Live flow answers "what is happening." For "did this ever work," the flow API exposes an end-of-day series per symbol:
curl -H "X-API-Key: $OPTIONSBELL_KEY" \
"https://optionsbell.com/api/v1/options-flow/history/NVDA"
Joined against the call dates from /api/v1/companies/ticker/NVDA on our side, that gives you a per-ticker timeline of unusual positioning with earnings dates marked. The research question becomes tractable: does unusual flow in the N sessions before a call carry information about what management then says, or about how the stock reacts?
Two related endpoints make this easier than building it yourself. /options-flow/streaks returns tickers with several consecutive days of unusual flow, which is the persistence filter that separates a decision from a single desk's afternoon. /options-flow/sentiment returns a 0 to 9 score per ticker derived from bid/ask execution, which is a far better directional proxy than premium totals, because it distinguishes buying from selling rather than just measuring size.
Rate limits and caching
The two sides have very different update characteristics, and the cache policy should reflect that.
Flow data changes continuously while the market is open. The documented Pro allowance is 2,000 requests per day with a 30 per minute rate limit, which is generous for a scanner and restrictive for anything polling per ticker in a loop. Prefer one broad /options-flow/unusual call with server-side filters over thirty single-symbol calls.
Transcript data is immutable once published. A call from 10 August 2026 will read identically in a year. Cache aggressively, keyed on earnings id, with no expiry. The only thing worth re-fetching is the upcoming calendar.
A reasonable split:
| Data | TTL | Reason |
|---|---|---|
/options-flow/unusual |
60s during market hours | continuously updating |
/options-flow/history/{symbol} |
24h | end-of-day series |
/companies/ticker/{t}/latest |
6h | changes only when a new call lands |
/transcripts/{id} and /summary |
permanent | immutable once published |
/earnings/upcoming |
6h | schedules move occasionally |
That pattern keeps a daily scan across a few dozen names comfortably inside both allowances.
The MCP route
If the consumer is an assistant rather than a service, both sides expose MCP servers and the pipeline above collapses into a conversation. Ours is documented at earnings calls MCP, and the options side at OptionsBell's MCP docs.
Connect both and a question like "show me every name with unusual flow past thirty days to expiry, then tell me what management said about guidance on their last call" executes as a sequence of tool calls rather than as code you maintain. For exploratory research that is the better shape. For anything scheduled, running on a cron, or feeding a dashboard, write the pipeline: it is more predictable and far cheaper per run.
What breaks
Four failure modes worth handling before they surprise you in production.
Ticker mismatches. Share classes, recent changes and non-US listings do not always agree between an options venue and a transcript archive. Options data is exchange-driven and US-centric; transcript coverage is company-driven and broader. Expect 404s from the transcripts side for tickers that trade options, and handle them as an ordinary case rather than an error.
No call on file. A company can have heavy options activity and no recent transcript. Newly listed names and companies between reporting periods both produce this. Return null and move on rather than failing the batch.
Expiry-day distortion. Implied volatility on contracts expiring the same day is a mechanical artefact, not a forecast. Values above 100% on one-day contracts are common and mean almost nothing. Filter by DTE before computing any IV statistic.
Quarterly expiry sessions. The third Friday of March, June, September and December produce volumes that are not comparable to ordinary days. If your pipeline computes any day-over-day aggregate, special-case these dates or your sentiment series will show four dramatic swings a year that are pure calendar.
Minimum viable version
Stripped to the smallest thing that produces something useful:
const rows = await positioningWithHorizon({ minDte: 21, minPremium: 1_000_000 });
const tickers = [...new Set(rows.map(r => r.symbol))];
const briefs = await Promise.all(tickers.map(async ticker => {
const call = await latestCall(ticker);
if (!call) return null;
const s = await fetch(`${EC_BASE}/transcripts/${call.id}/summary`, { headers: ecHeaders });
return { ticker, callDate: call.event_date, summary: (await s.json()).summary };
}));
console.table(briefs.filter(Boolean));
Twenty lines, two API keys, one daily run. The output is a short list of companies where somebody has taken a position with a real horizon, next to what management last said about the business.
That list is not a trade signal and should not be treated as one. It is a reading list, ordered by where money has a deadline. On most days it is the most useful twenty lines of code in the stack.
Related reading
- Build a Slack Bot for Real-Time Earnings Call Alerts
- When the Transcript Says Boom and the Tape Buys Protection
- Put Premium Was 2.3x Call Premium. Here Is Why That Number Lies
- MCP vs REST API for Earnings Call Data: Which Should You Use?
- The Complete Guide to Earnings Call Transcript APIs and MCP (2026)