Realtime API
Stream derived option analytics over a WebSocket, at cadences from one minute down to 100 milliseconds: forward, open interest, implied volatility, greeks and dealer exposures. Each channel carries one expiry of one root as a complete Arrow table. Quotes, trades and sizes are not sent.
Personal, non-commercial use only. API access is licensed for your own private use. You may not use it for a business or redistribute the data.
Before you connect
You need three things:
- A signed-in account. Credentials are issued to your session.
- API access. A qualifying plan lets you create your own key in your account settings. Accounts with an approved API data agreement can create one too. Keys are for personal, non-commercial use.
- A plan that includes the cadence you want. See Cadences.
Having an account or a credential does not grant a channel by itself. Each subscription is checked separately against your agreement and plan.
Create a credential
In the app, open Settings, then Data agreements, and create a key under API keys. Copy the secret straight away, because it is shown once. To create one from code instead, send this request with your session token as the bearer:
POST /api/data-agreements/v1/credentials
Authorization: Bearer <session token>
Content-Type: application/json
{ "delivery": "api" }Response shape:
{
"id": "6f1c2a9e-0000-4000-8000-000000000000",
"secret": "cc_opra_<64 hex characters>",
"expires_at": "2026-11-03T15:00:00Z"
}| Rule | Value |
|---|---|
| API key lifetime | 1 year |
| Active credentials per account | 20 |
| Secret visibility | Returned once, and can't be recovered |
To revoke a credential, send DELETE /api/data-agreements/v1/credentials/{id}. A new credential is needed once the old one expires.
| Status | Meaning |
|---|---|
400 | The request body is invalid |
401 | You aren't signed in |
403 | Your account can't have this delivery, usually because the agreement is missing |
409 | The request conflicts with your current state |
503 | The service can't decide right now, so retry later |
Error bodies are {"error": string, "code"?: string}.
Connect
The socket speaks the Centrifugo client protocol. Use its Protobuf transport, because the tables are binary and its JSON transport can't carry them.
The address is wss://stream.coreconvexity.ai/connection/websocket. This example uses the centrifuge JavaScript client. render is your own handler; apache-arrow reads the table.
import { Centrifuge } from 'centrifuge/build/protobuf';
import { tableFromIPC } from 'apache-arrow';
const SOCKET_URL = 'wss://stream.coreconvexity.ai/connection/websocket';
const client = new Centrifuge(SOCKET_URL, {
data: new TextEncoder().encode(JSON.stringify({ credential: secret })),
});
const channel = client.newSubscription('derived:1s:SPY:2026-12-18', { delta: 'fossil' });
channel.on('publication', event => render(tableFromIPC(event.data)));
channel.on('error', event => console.error(event.error.code));
channel.subscribe();
client.connect();Frames
Once your client has applied the delta, every publication is one complete Arrow table in IPC stream format: one record batch, one row per contract, ordered by strike and then right (call first). Read it with any Arrow library. A new subscriber gets the latest table when it subscribes, from recovery.
Subscribe with delta: 'fossil'. It's required: a subscription without it is refused with error 470, and retrying won't help. The first publication is a full table. After that the server sends only the bytes that changed and the client rebuilds the whole table. Rows keep their position, so unchanged contracts cost almost nothing.
The table always has every column. Fossil deltas are computed per channel, one stream of bytes shared by every subscriber, so a column selection can't be applied to it: pick the columns you want when you read the table. REST can narrow the columns with fields, below.
| Column | Type | Notes |
|---|---|---|
expiry | Utf8 | YYYY-MM-DD |
right | Utf8 | C or P |
strike_micros | Int64 | Strike in millionths of a dollar |
oi | Int64 | Open interest |
iv | Float32, nullable | Implied volatility as a fraction |
delta | Float32, nullable | |
gamma | Float32, nullable | Per $1 |
vanna | Float32, nullable | Per vol point |
charm | Float32, nullable | Per calendar day |
gex | Float32, nullable | Dollar gamma exposure per 1% move |
dex | Float32, nullable | Dollar delta exposure |
vex | Float32, nullable | Dollar vanna exposure per vol point |
cex | Float32, nullable | Dollar charm exposure per day |
mid | Float32, nullable | Mid of the two-sided quote, in dollars. Null without a two-sided quote |
premium | Float64, nullable | mid times open interest times 100, rounded to whole dollars. Float64 rather than Float32 because Float32 holds whole numbers exactly only up to 16,777,216, and one index contract's premium can exceed that. Null without a two-sided quote or open interest |
A value that isn't available is a null, never NaN or a placeholder. The schema's metadata holds schema (derived-chain/v2), root, at_ns (the table's time in Unix nanoseconds) and, on the socket, tier. It also holds what belongs to the whole expiry rather than to a row, as decimal text, with the key absent when unknown:
| Key | Meaning |
|---|---|
forward | The expiry's forward price |
spot | Spot implied from the forward (F·e^{-rT}), not a traded equity price |
Quotes, sizes and trades are not in the table.
Our numbers can differ from other tools. See Why our numbers can differ.
Expiries
GET /api/market-data/v1/derived/expiries?root=SPY
Authorization: Bearer cc_opra_<64 hex characters>The response lists the expiries you can build channels for, earliest first:
{ "root": "SPY", "expiries": ["2026-11-03", "2026-11-04"] }Fetch a whole chain
REST requests go to https://coreconvexity.ai.
No subscription is needed for this request. It returns the latest one-minute chain as JSON, or as the same Arrow table the socket publishes:
GET /api/market-data/v1/derived/chain?root=SPY&expiry=*
Authorization: Bearer <API key>expiry is * for every expiry, or one YYYY-MM-DD date.
fields is an optional comma-separated list of the columns you want, for example fields=oi,forward,gamma. It applies to JSON and Arrow alike. expiry, right and the strike always come back. The valid names are oi, forward, spot, iv, delta, gamma, vanna, charm, gex, dex, vex, cex, mid and premium; any other name is a 400 that lists them. Without fields you get everything. forward and spot are metadata in Arrow and top-level members in JSON, and with fields they are included only if you ask for them.
{
"root": "SPY",
"at_s": 1793740500,
"forward": 751.32,
"spot": 750.98,
"contracts": [
{ "expiry": "2026-11-06", "right": "C", "strike": 750, "oi": 12034, "forward": 751.32, "spot": 750.98, "iv": 0.1432, "delta": 0.5512, "gamma": 0.0213, "vanna": 0.0041, "charm": -0.0123, "gex": 1250000, "dex": 4100000, "vex": 52000, "cex": -9800, "mid": 0.15, "premium": 180510 }
]
}at_s is the minute described, in Unix seconds. Prices are in dollars and iv is a fraction. Gamma is per $1, vanna per vol point and charm per calendar day; gex, dex, vex and cex are dollar exposures and mid is in dollars and cents and premium is in whole dollars, as in Frames. For a single expiry forward and spot (implied from the forward) sit beside the contracts. A value that isn't available yet is null.
For large chains, send Accept: application/vnd.apache.arrow.stream to get the table described in Frames, with schema, root and at_ns in the metadata, and forward and spot too for a single expiry. Rows are ordered by expiry, strike and then right.
Channels
derived:{cadence}:{ROOT}:{YYYY-MM-DD}| Part | Example | Notes |
|---|---|---|
cadence | 1s | One of the cadences below |
ROOT | SPXW | The option root in capitals. An underlying with several roots, such as SPX and SPXW, has a channel per root |
| Date | 2026-12-18 | The expiry |
Each channel is one expiry of one root, so subscribe once per expiry you want. Channels carry derived values only.
Cadences
| Cadence | Publication | Access |
|---|---|---|
1m | Changes every minute | Included with the plan |
30s | Changes every 30 seconds | $25 add-on. Included for users who subscribed before the update |
15s | Changes every 15 seconds | $50 add-on |
1s | Changes every second | $100 add-on |
100ms | Changes every 100 milliseconds | LTF |
A plan admits its own cadence and anything slower. Subscribing faster than your plan allows is refused for that channel only.
Staying connected
- Refused access: access is rechecked while you're connected, and decisions last up to 30 seconds. Expect a revoked credential or ended plan to stop an open stream shortly after the change, not instantly.
- Reconnecting: the channel recovers the latest table for you, so there is nothing to catch up on.
- Connection limits: the limit applies across all your live connections, wherever they run.
- Subscription limits: each API key can make 60 subscribe requests a minute, with a burst of 60 so you can open many expiries at once. Past that, a subscribe is refused with error
429and the client retries by itself. - History: there's no history API yet. The socket delivers live data only.