{
"op": "<string>",
"const": "<string>",
"description": "<string>",
"pair_addresses": {
"item": "<string>"
},
"timeframes": {
"item": "<string>"
}
}{
"const": "<string>",
"pair_address": "<string>",
"token_address": "<string>",
"timeframe": "<string>",
"timestamp": "<string>",
"open": 123,
"high": 123,
"low": 123,
"close": 123,
"volume_sol": 123,
"volume_token": 123,
"trade_count": 123,
"buy_count": 123,
"sell_count": 123,
"unique_traders": 123,
"is_closed": true
}{
"client_id": "<string>",
"data_inception": "<string>",
"const": "<string>"
}{
"status": "<string>",
"op": "<string>",
"pairs": 123,
"timeframes": {
"item": "<string>"
},
"subjects": 123
}{
"const": "<string>",
"message": "<string>",
"error": "<string>",
"cap": 123
}WebSocket: OHLCV
Live candle bars pushed as they’re built — mid-bar updates and final closes for any pool, any timeframe.
(pool, timeframe) you subscribe to. You get mid-bar updates while a bar is open (is_closed: false) and an update when it closes (is_closed: true). A closed bar may be sent again as a correction, so the last closed frame for a bar is the one that counts (see Frame shape).
This is the right channel when you want to render a live chart or trigger logic on bar close. For one-shot history, use /api/v1/candles and /api/v1/candles/latest. For raw per-swap data, use WebSocket: swaps.
Endpoint and auth
wss://ws.dexploit.dev/ws/ohlcv
/ws/ohlcv accepts both, same as /ws/swaps:
Authorization: Bearer ohlcv_live_sk_<your_key>
# or, for browsers:
wss://ws.dexploit.dev/ws/ohlcv?api_key=ohlcv_live_sk_<your_key>
Subscribe
After the connection is up the server sends a hello frame:{ "client_id": "8caba129-bdab-…", "data_inception": "2026-06-03T00:15:00Z", "status": "connected" }
subscribe. Both pair_addresses and timeframes are required — the server will reply with {"error": "invalid subscribe: timeframes empty"} (or similar) if either is missing.
{
"type": "subscribe",
"pair_addresses": ["HdqYz5GVuWgNXbE6fBkgCYXUSPGoyco5yYLBaL5ZzKAR"],
"timeframes": ["1s", "1m"]
}
{ "status": "subscribed", "op": "subscribed", "pairs": 1, "timeframes": ["1s", "1m"], "subjects": 2 }
pairs is the distinct pool count, subjects is the number of live (pool, timeframe) pairs, and op mirrors status.
(pair_address, timeframe) pair — the unit the stream actually fans out on. A subscribe of 3 pools × 2 timeframes is 3 pairs but 6 subjects.Add / remove / unsubscribe (mid-stream)
Once you’re subscribed you can change the watched symbol set without reconnecting by sending more frames on the same socket. This is what lets a chart or watchlist UI swap the active pool, or add/drop timeframes, in place. Every mutating frame carries anop. Each (pair_address, timeframe) pair it expands to is a subject; the ack’s pairs/timeframes/subjects always report the new total live set after the op — not just the delta.
op | Effect | Ack op |
|---|---|---|
add | Union the new (pair, timeframe) subjects into the live set. Bars for the added symbols start arriving immediately. | added |
remove | Drop those subjects. Bars for removed symbols stop. | removed |
unsubscribe | Clear all subjects. pair_addresses / timeframes are optional and ignored. Totals go to 0. | unsubscribed |
subscribe | Replace the entire live set with the new one (reset). Atomic — see note below. | subscribed |
Add symbols
{ "op": "add", "pair_addresses": ["38tqb1K…"], "timeframes": ["1m"] }
{ "status": "added", "op": "added", "pairs": 2, "timeframes": ["1m", "1s"], "subjects": 3 }
Remove symbols
{ "op": "remove", "pair_addresses": ["38tqb1K…"], "timeframes": ["1m"] }
{ "status": "removed", "op": "removed", "pairs": 1, "timeframes": ["1m", "1s"], "subjects": 2 }
Unsubscribe (clear everything)
{ "op": "unsubscribe" }
{ "status": "unsubscribed", "op": "unsubscribed", "pairs": 0, "timeframes": [], "subjects": 0 }
Replace the whole set
A later frame withop: "subscribe" (or with no op — absent defaults to subscribe) replaces the entire live set with the new one:
{ "op": "subscribe", "pair_addresses": ["HdqYz5GVu…"], "timeframes": ["5m"] }
{ "status": "subscribed", "op": "subscribed", "pairs": 1, "timeframes": ["5m"], "subjects": 1 }
/ws/ohlcv, to widen the set you must use op: "add" — sending another subscribe (or an op-absent frame) discards what you had. The frame’s op field is what the server reads; the legacy type field is ignored.Frame shape
{
"type": "ohlcv",
"pair_address": "HdqYz5GVuWgNXbE6fBkgCYXUSPGoyco5yYLBaL5ZzKAR",
"token_address": "BsZoKtYtP3V2xJnqonEaYXXaQdLv8kPM45aRbt23oPEJ",
"timeframe": "1m",
"timestamp": "2026-05-20T12:20:00+00:00",
"open": 7.553982e-7,
"high": 7.613724e-7,
"low": 7.553982e-7,
"close": 7.613724e-7,
"volume_sol": 2058053069,
"volume_token": 2713479778451,
"trade_count": 5,
"buy_count": 5,
"sell_count": 0,
"unique_traders": 5,
"is_closed": false
}
| Field | Notes |
|---|---|
pair_address | The on-chain pool/LP account. Note the naming: OHLCV frames key the pool as pair_address, but swaps WebSocket frames key the same on-chain pool as pool_address. Same account, different field name across the two streams. |
timestamp | Bar open time, ISO 8601. The bar covers [timestamp, timestamp + timeframe). |
is_closed | false for interim mid-bar pushes; true once the bar has closed. A closed bar may be sent again with is_closed: true and corrected values when trades land after it first closed, usually within about 2–3 seconds and occasionally after the next bar’s close. The 2–3 s is typical for 1s and 1m bars; a trade that is delivered late can correct a longer bar later (seen at 32 s on 5m and 15m bars). The stream sends corrections for a bar for at most 10 minutes after it closes, and none after that. Keep the last is_closed: true frame per (pair_address, timeframe, timestamp): that is the bar /api/v1/candles serves, unless the window was repaired later (repairs are not sent on the stream; see below). |
volume_sol | Lamports, despite the name. Divide by 1e9 for SOL. |
volume_token | Token base units. Divide by 10 ** quote_decimals for human-readable. |
unique_traders | Distinct wallets that traded in this bar so far. Grows monotonically across mid-bar frames. |
Limits and errors
Two independent limits apply:| Limit | Bounds | Env | Default |
|---|---|---|---|
pair_addresses per frame | The pool count of a single subscribe / add / subscribe-replace frame. | MAX_PAIRS_PER_SUBSCRIBE | 1000 |
| Live subjects per connection | The cumulative live (pool × timeframe) set across all your adds. | MAX_SUBJECTS_PER_CONNECTION | 5000 |
- Bad frame / unknown op →
{ "error": "invalid subscribe: <detail>" }and the socket stays open. An unknown op comes back as{ "error": "invalid subscribe: unknown op: <op>" }. - Per-frame pool cap →
{ "error": "invalid subscribe: too many pair_addresses; max 1000" }. - Per-connection subject cap → an
addorsubscribe-replace that would push the live set past the cap is rejected atomically — no partial apply, and your existing subjects keep delivering:
{ "error": "subject cap reached for this connection; cap 5000", "cap": 5000 }
Minimal TypeScript client
import WebSocket from 'ws'; // browser: use the global WebSocket
const URL = 'wss://ws.dexploit.dev/ws/ohlcv';
const API = 'ohlcv_live_sk_<your_key>';
const PAIR = '<pool_address>';
const TFS = ['1m'];
let backoff = 1000;
function connect() {
const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${API}` } });
ws.on('open', () => {
backoff = 1000;
ws.send(JSON.stringify({
type: 'subscribe',
pair_addresses: [PAIR],
timeframes: TFS,
}));
});
ws.on('message', (raw) => {
const f = JSON.parse(raw.toString());
if (f.type !== 'ohlcv') return; // skip hello + subscribe ack + errors
const tag = f.is_closed ? 'CLOSED' : 'open ';
const volSol = f.volume_sol / 1e9;
console.log(
`${tag} ${f.timeframe}@${f.timestamp} o=${f.open.toExponential(3)} c=${f.close.toExponential(3)} ` +
`vol=${volSol.toFixed(3)} SOL (${f.trade_count} trades)`,
);
});
ws.on('close', () => {
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, 60_000);
});
ws.on('error', () => { /* close fires next */ });
}
connect();
(pair_address, timeframe, timestamp) and overwrite the entry on every frame for that key — the latest frame is always the freshest snapshot of that bar. is_closed: true means the bar has closed, but a corrected close for the same key can follow — usually within a few seconds, and for up to 10 minutes — so keep overwriting on later closed frames too. If you trigger logic on close, allow for that correction.
Repairs made after that are not sent on the stream. When missed trades are backfilled (for example after an ingestion outage), the corrected bars are written to storage and served by /api/v1/candles, but no frame is sent for them. If you need exact history, re-read the affected window from REST: after a reconnect (see Reconnect & backpressure), or when a window you hold is known to have been repaired.
Changing the watched set at runtime
A watchlist or chart UI that switches the active pool sendsadd / remove on the same open socket — no reconnect. Helpers, and an ack handler that reads back the new live totals:
// `ws` is the open socket from above.
// Start watching another pool (keeps the existing ones):
const addPair = (pair: string, tfs = ['1m']) =>
ws.send(JSON.stringify({ op: 'add', pair_addresses: [pair], timeframes: tfs }));
// Stop watching a pool:
const removePair = (pair: string, tfs = ['1m']) =>
ws.send(JSON.stringify({ op: 'remove', pair_addresses: [pair], timeframes: tfs }));
// Drop everything (e.g. on view teardown):
const clearAll = () => ws.send(JSON.stringify({ op: 'unsubscribe' }));
// In your message handler, branch on the ack/error frames:
ws.on('message', (raw) => {
const f = JSON.parse(raw.toString());
switch (f.op ?? f.status) { // 'subscribed' | 'added' | 'removed' | 'unsubscribed'
case 'added':
case 'removed':
case 'unsubscribed':
case 'subscribed':
// f.pairs / f.subjects are the NEW live totals after the op.
console.log(`live set: ${f.pairs} pools, ${f.subjects} subjects`);
return;
}
if (f.error) { // non-fatal mid-stream — socket stays open
console.warn('op rejected:', f.error, f.cap ? `(cap ${f.cap})` : '');
return;
}
// …otherwise it's an `ohlcv` bar frame — handle as above.
});
op: 'subscribe' (or an op-absent frame) only when you intend to reset the whole set — it discards the current subscription. To widen, always use op: 'add'.
For reconnect strategy, gap-filling against /api/v1/candles, and keepalive guidance, see Reconnect & backpressure.
Programmatic spec
The full message contract for this channel — and the other two WebSocket streams — is published as an AsyncAPI 3.0 document you can feed into client generators, validators, or an LLM.{
"op": "<string>",
"const": "<string>",
"description": "<string>",
"pair_addresses": {
"item": "<string>"
},
"timeframes": {
"item": "<string>"
}
}{
"const": "<string>",
"pair_address": "<string>",
"token_address": "<string>",
"timeframe": "<string>",
"timestamp": "<string>",
"open": 123,
"high": 123,
"low": 123,
"close": 123,
"volume_sol": 123,
"volume_token": 123,
"trade_count": 123,
"buy_count": 123,
"sell_count": 123,
"unique_traders": 123,
"is_closed": true
}{
"client_id": "<string>",
"data_inception": "<string>",
"const": "<string>"
}{
"status": "<string>",
"op": "<string>",
"pairs": 123,
"timeframes": {
"item": "<string>"
},
"subjects": 123
}{
"const": "<string>",
"message": "<string>",
"error": "<string>",
"cap": 123
}?api_key=ohlcv_live_sk_… appended to the connection URL. Browser-friendly; the only option in environments where you can't set headers.
Op-tagged frame that mutates the live (pool x timeframe) set. The FIRST frame subscribes; later frames on the same socket can add/remove/unsubscribe or subscribe (reset) without reconnecting. op absent = subscribe = REPLACE.
One bar update for a subscribed (pool, timeframe). NOTE: volume_sol is in lamports despite the name (divide by 1e9 for SOL); volume_token is in base units (divide by 10**quote_decimals).
Sent once on connect with a UUID client_id and the index inception timestamp.
Sent after each successful op. status and op echo the op (subscribed/added/removed/unsubscribed). pairs/subjects are the new LIVE TOTALS after the op (not a delta). A subject = one (pool, timeframe) pair.
Validation failure, auth issue, or server-side error. Connection usually stays open after a recoverable error.

