Strasmore API · options
A clean NBBO per contract, every N seconds.
One HTTP call returns one row per option contract per interval bucket, from 09:30 to 16:00 ET: bid, ask, both sizes, the mid, and the timestamp of the quote that was standing. No SQL, no window functions, no forward-fill of your own.
The raw quote tape (cache_options_quotes, roughly 7 trillion rows) carries
no underlying, expiry, type or strike column — all four are encoded
inside the OCC ticker — and no column telling you which quote was last in a given
second. So the straightforward version of this question is contract discovery, plus
per-interval sampling, plus quote cleaning, before you see a single usable row. These
two endpoints do all three server-side.
The first look needs no key and no signup. The same two endpoints with a key read at your own plan's window — including the full tape back to 2022 on Pro.
Quickstart — no key
Two calls: find out what was quoted, then ask for the snapshots. The dates below are filled in for you: 2026-10-30 is the most recent session the no-key window reaches.
1. What contracts were quoted?
curl -s "https://ai.strasmore.com/v1/demo/options/contracts?underlying=SPY&date=2026-10-30&expiry_from=2026-11-20&expiry_to=2026-11-20&type=call"
Each row is a contract with its expiry, type, strike, how many quotes it printed in the session, and the first and last of them — enough to pick the ones worth sampling.
2. One clean quote per minute, for one contract:
curl -s "https://ai.strasmore.com/v1/demo/options/quote-snapshots?contract=O:SPY261120C00500000&date=2026-10-30&interval=60"
A row looks like this:
{
"contract": "O:SPY261120C00500000",
"bucket_end": "2026-10-30T09:31:00-0400",
"bid": 276.97,
"ask": 280.78,
"bid_size": 1,
"ask_size": 1,
"mid": 278.875,
"quote_time": "2026-10-30T09:30:50-0400"
}
Add &format=csv for the same columns as CSV, with the column list as
the header row.
Ask your assistant
Paste this into ChatGPT, Claude or any assistant that can make HTTP requests. It names the endpoint and its parameters, so the assistant does not have to guess:
Strasmore has a free, no-key endpoint that returns a clean options NBBO
per contract per interval:
GET https://ai.strasmore.com/v1/demo/options/quote-snapshots
?underlying=SPY&date=YYYY-MM-DD&interval=60
(or &contract=O:SPY261016C00500000 for a single contract)
Optional: expiry_from, expiry_to, type=call|put, strike_min, strike_max,
format=csv, cursor. Each row is contract, bucket_end (ET), bid, ask,
bid_size, ask_size, mid, quote_time.
List what was quoted on a date first:
GET https://ai.strasmore.com/v1/demo/options/contracts?underlying=SPY&date=YYYY-MM-DD
Without a key: send one contract, or an expiry range of at most
31 days, and pick a date inside the last 5 days.
Use it to answer my question, and show me the numbers you got.GET /options/contracts
Every contract with at least one quote in the regular session on date.
- No key:
https://ai.strasmore.com/v1/demo/options/contracts - With a key:
https://api.strasmore.com/v1/options/contracts(Authorization: Bearer <key>)
Returns contract, expiry, type,
strike, quotes, first_quote,
last_quote.
GET /options/quote-snapshots
One row per contract per interval bucket from 09:30 to 16:00 ET. Each row carries the last valid quote at or before the end of its bucket.
- No key:
https://ai.strasmore.com/v1/demo/options/quote-snapshots - With a key:
https://api.strasmore.com/v1/options/quote-snapshots
A bucket with no update is not left out. An NBBO is a step function —
the quote stands until something replaces it — so a quiet minute repeats the quote that
was still standing, and quote_time tells you when it was set. Buckets
before a contract's first quote of the session carry no row at all.
Parameters
| Parameter | Meaning |
|---|---|
underlying | US equity or ETF symbol, e.g. SPY. Required unless contract is given. |
date | Trading date, YYYY-MM-DD. Required. |
interval | Bucket length in seconds, 1–3600. Default 60. Snapshots only. |
contract | One OCC option ticker as the tape stores it, e.g. O:SPY261120C00500000 — O: + underlying + YYMMDD + C/P + strike×1000 padded to 8 digits. Replaces every other filter. |
expiry_from, expiry_to | Expiry range, inclusive, YYYY-MM-DD. |
type | call or put. |
strike_min, strike_max | Strike range, inclusive. |
cursor | The next_cursor of the previous page. |
format | csv for CSV; JSON otherwise. |
Columns
| Column | Meaning |
|---|---|
contract | The OCC option ticker. |
bucket_end | End of the interval bucket, ISO 8601 in ET with the UTC offset. The last bucket ends at 16:00:00 even when the interval does not divide the session evenly. |
bid, ask | The standing quote at bucket_end. |
bid_size, ask_size | Sizes on that quote. |
mid | (bid + ask) / 2, to 4 decimals. |
quote_time | When that quote was set — nanosecond SIP timestamp, ET. Earlier than bucket_end whenever the quote was carried forward. |
What counts as a quote
A quote is used only when both sides quoted (bid_size > 0 and ask_size > 0), neither price negative, and ask_price >= bid_price. Everything else — a one-sided quote, a
crossed quote, a negative price — is skipped, and the previous valid quote stands. The
rule is stated back to you in every response under request.valid_quote, so
a result can always be reproduced.
Paging
Rows come back ordered by contract, then by bucket. When more exist than the row cap
returns, truncated is true and next_cursor is
the position to continue from — pass it as &cursor= and you get the
rows after it, with nothing repeated and nothing skipped.
A truncated page also carries narrow: the filters that would make the
answer fit in one call. Paging a whole chain one page at a time works, but asking for
one expiry or one strike band is usually what you actually wanted.
Python
import csv, io, urllib.parse, urllib.request
BASE = "https://ai.strasmore.com/v1/demo/options" # no key needed
def get(path, **params):
url = f"{BASE}/{path}?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=60) as r:
return r.read(), r.headers.get("content-type", "")
# 1. what was quoted
import json
body, _ = get("contracts", underlying="SPY", date="2026-10-30",
expiry_from="2026-11-20", expiry_to="2026-11-20", type="call")
contracts = json.loads(body)["rows"]
print(len(contracts), "contracts;", contracts[0]["contract"])
# 2. one clean quote a minute, as CSV, following the cursor to the end
rows, cursor = [], None
while True:
params = dict(contract="O:SPY261120C00500000", date="2026-10-30", interval=60,
format="csv")
if cursor:
params["cursor"] = cursor
body, ctype = get("quote-snapshots", **params)
page = list(csv.DictReader(io.StringIO(body.decode())))
rows += page
# The JSON form carries next_cursor; ask for it when you need to page CSV.
meta = json.loads(get("quote-snapshots", **{**params, "format": "json"})[0])
cursor = meta.get("next_cursor")
if not cursor:
break
print(len(rows), "snapshots;", rows[0])Limits & cost
Without a key:
- 500 rows per call, then a cursor.
- 20 seconds per call, and 60 calls a minute per IP.
- Either one
contract, or an expiry range of at most 31 days. - The last 5 days of the quote tape. A date before that is answered with a 402 naming the plan that reads it — never a silent empty result.
With a key, the row cap is 20,000 and the window is your plan's. The full options NBBO tape from 2022 is one of the datasets Pro includes.
One honest note on breadth. The cost of these calls is the tape scan, and it scales with how many contracts you ask for, not how many rows come back. A single contract answers in a fraction of a second and one expiry in a few seconds; a month of expiries on the most heavily quoted underlyings is tens of seconds and will hit the no-key timeout. When that happens the answer names the narrowing that works rather than just reporting a timeout.
The SQL underneath
Every response carries the exact statement it came from, in sql. It runs
unchanged on POST https://api.strasmore.com/v1/query and on the no-key SQL
endpoint, so you can take it, change it, and keep going. Nothing here is a second path
to the warehouse: these endpoints build that statement and hand it to the same gate,
the same executor and the same limits every other query goes through.