Server-Sent Events
Server-Sent Events (SSE) deliver the same real-time rates, exchange rates, and tickers as our WebSockets, over a plain HTTP response that stays open. Use it when a WebSocket client is not available or not wanted: from the browser's built-in EventSource, from curl, or from any HTTP library that can read a response as it arrives.
An SSE stream is a regular GET request. The server answers with content-type: text/event-stream and then keeps writing one line per message for as long as the connection stays open. The query parameters, the messages, and the plan requirements are identical to the WebSockets, so you can switch between the two transports without changing what you receive. They are described per topic: Rates, Exchange Rates, and Tickers.
Authentication
Send your API key in the x-access-token header, exactly as for the REST endpoints. This is the preferred method for every client that can set headers: curl, fetch, and server-side HTTP libraries.
curl -N 'https://api.coinranking.com/v2/real-time/sse/rates?list=all' \
-H 'x-access-token: your-api-key' The browser's built-in EventSource cannot set request headers. For that case only, the key is also accepted as a query parameter, just like on the WebSockets:
https://api.coinranking.com/v2/real-time/sse/rates?x-access-token=your-api-key
A key in the query string ends up in browser histories, access logs, and referrer headers, so use the header wherever you can. For a public website, keep the key on your server and proxy the stream through your own origin.
Connection Setup
Streaming is available on all API plans. Your plan's connection limits and minimum update interval still apply.
Open a stream by requesting one of the three paths. Each path mirrors the WebSocket path of the same name:
https://api.coinranking.com/v2/real-time/sse/rates
https://api.coinranking.com/v2/real-time/sse/exchange-rates
https://api.coinranking.com/v2/real-time/sse/tickers
- Rates
/sse/rates- Aggregated price and 24 hour volume per coin. - Exchange Rates
/sse/exchange-rates- Price and 24 hour volume of a coin on a specific exchange. - Tickers
/sse/tickers- Trading pair data per market, including base and quote volumes.
The stream is one-way. Unlike the WebSockets, there are no subscription messages: everything you want to receive is specified in the URL when you open the stream. To change your subscriptions, close the stream and open a new one with the new parameters.
URL Query Parameters
The query parameters are the same as on the WebSockets and are described on each topic page. Each stream needs exactly one subscription parameter; you cannot mix them, and unlike a WebSocket, a stream without a subscription is refused, because there is no way to subscribe after opening it.
| Path | Subscription parameter (choose one) |
|---|---|
/sse/rates | currency-uuids[] or list=all |
/sse/exchange-rates | references[], exchange-uuids[], currency-uuids[], or list=all |
/sse/tickers | market-uuids[], exchange-uuids[], currency-uuids[], or list=all |
All paths accept an optional throttle of 1s, 5s, 10s, 30s, or 60s. When omitted, it defaults to your plan’s minimum update interval. A supported value faster than your plan allows is rejected with THROTTLE_NOT_ALLOWED. A list=all stream is high-volume: a client that stops reading is disconnected, see Reconnecting.
Code examples
The fetch and Python examples read the status and error body of a rejected request before they start streaming. The EventSource example is the quickest way to try the stream from a browser, but it puts the API key in the page and cannot see why a request was rejected; see Using the stream from a website and Error handling in the browser before using it in an integration.
# -N disables output buffering, -g stops curl from interpreting the [] in the URL
curl -N -g 'https://api.coinranking.com/v2/real-time/sse/rates?currency-uuids[]=Qwsogvtv82FCd¤cy-uuids[]=razxDUgYGNAdQ&throttle=10s' \
-H 'x-access-token: your-api-key' // Works in browsers, Node.js 18+, Deno, and Bun
const url = 'https://api.coinranking.com/v2/real-time/sse/rates?currency-uuids[]=Qwsogvtv82FCd¤cy-uuids[]=razxDUgYGNAdQ&throttle=10s';
const response = await fetch(url, {
headers: { 'x-access-token': 'your-api-key' },
});
if (!response.ok) {
// Rejected before the stream started; the body explains why
const { code, message } = await response.json();
throw new Error(`HTTP ${response.status} ${code}: ${message}`);
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break; // The stream ended, e.g. because of a deploy: reconnect with the same URL
buffer += value;
// Every message ends with a blank line (LF today, but the spec allows CRLF)
let boundary = buffer.match(/\r?\n\r?\n/);
while (boundary) {
const frame = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary[0].length);
boundary = buffer.match(/\r?\n\r?\n/);
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim())
.join('\n');
// "retry:" and ":ping" frames carry no data
if (data) console.log(JSON.parse(data));
}
} // For testing only: the API key is visible to anyone who can see the page.
// On a real site, point EventSource at a proxy route on your own server instead.
const source = new EventSource(
'https://api.coinranking.com/v2/real-time/sse/rates?x-access-token=your-api-key¤cy-uuids[]=Qwsogvtv82FCd¤cy-uuids[]=razxDUgYGNAdQ&throttle=10s'
);
// Messages are sent without an event name, so all of them arrive here
source.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log(message);
}; import json
import requests
url = 'https://api.coinranking.com/v2/real-time/sse/rates'
params = {
'currency-uuids[]': ['Qwsogvtv82FCd', 'razxDUgYGNAdQ'],
'throttle': '10s',
}
headers = {'x-access-token': 'your-api-key'}
with requests.get(url, params=params, headers=headers, stream=True) as response:
if response.status_code != 200:
# Rejected before the stream started; the body explains why
error = response.json()
raise RuntimeError(f"HTTP {response.status_code} {error['code']}: {error['message']}")
for line in response.iter_lines(decode_unicode=True):
# Blank lines end a message, ":ping" and "retry:" lines carry no data
if line.startswith('data:'):
message = json.loads(line[len('data:'):].strip())
print(message) Stream Format
A successful request is answered with status 200, content-type: text/event-stream; charset=utf-8, cache-control: no-cache, no-transform, access-control-allow-origin: *, and the usual ratelimit-* headers. The body follows the standard Server-Sent Events format: lines grouped into messages, with a blank line ending each message. Lines end in \n today; a parser should accept \r\n as well, as the SSE specification allows either.
retry: 5000
data: {"type":"confirmation","topic":"rate","throttle":"1s","subscriptions":["Qwsogvtv82FCd"]}
data: {"currencyUuid":"Qwsogvtv82FCd","price":"77348.906368714524","volume":"19604392582.842590","type":"rate"}
:ping
retry: 5000is sent once, at the start of the stream. It tellsEventSourceto wait five seconds before reconnecting when the stream ends.data:lines each carry one JSON message: the same message the WebSocket would send as one frame. Messages have noevent:name, soEventSource.onmessagereceives all of them. Thetypefield tells them apart.:pingis a comment line sent every 25 seconds to keep the connection alive. Ignore it;EventSourcedoes so automatically.
There are no id: lines, so Last-Event-ID replay is not supported: a reconnect resumes with the next update, not with what was missed. The stream carries updates only and does not send a snapshot on connect; see starting values.
Responses
The stream carries two kinds of messages:
Confirmation Messages
The first message on every stream confirms what you are subscribed to and with which throttle. It has the same shape as the WebSocket confirmation:
{
"type": "confirmation",
"topic": "rate",
"throttle": "10s",
"subscriptions": ["Qwsogvtv82FCd", "razxDUgYGNAdQ"]
} The topic is rate, exchange-rate, or ticker, matching the path you opened.
Data Messages
After the confirmation, you receive a data message whenever new data is available for one of your subscriptions. These are the same messages the WebSocket sends, for example a rate from /sse/rates:
{
"type": "rate",
"currencyUuid": "Qwsogvtv82FCd",
"price": "125423.835504191964312353",
"volume": "6270550403.287645846237386287"
} The type is rate, exchange-rate, or ticker, matching the path. The fields of each are described on the topic pages:
- Rates:
currencyUuid,price,volume - Exchange Rates:
exchangeUuid,currencyUuid,exchangeType,price,volume - Tickers:
marketUuid,exchangeUuid,baseUuid,quoteUuid,exchangeType,price,close,baseVolume,quoteVolume,volume,filters
Errors
A request is validated in full before the stream starts. If anything is wrong, the response is a normal HTTP error with a JSON body instead of a stream, and nothing is streamed. The body has the same shape as every other error response of the API: a status (fail for 4xx, error for 5xx), a machine-readable code, and a human-readable message.
HTTP/1.1 400 Bad Request
{
"status": "fail",
"code": "INVALID_THROTTLE",
"message": "Throttle \"2s\" is not supported"
} Unknown UUIDs reject the whole request. Validation is all-or-nothing, the same as on the WebSockets: if any UUID in your subscription does not exist, the request is rejected and the message lists the UUIDs that could not be found. Remove or correct them and open the stream again.
HTTP/1.1 400 Bad Request
{
"status": "fail",
"code": "UNKNOWN_CURRENCY_UUID",
"message": "Could not find coins with the following UUIDs: notarealuuid"
} Match on code rather than on message; the messages may be reworded.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_THROTTLE | The throttle is not one of 1s, 5s, 10s, 30s, or 60s. A supported value faster than your plan allows returns THROTTLE_NOT_ALLOWED instead. |
400 | INVALID_SUBSCRIPTION | No subscription parameter was given, more than one was given, or a references[] entry is not in the exchangeUuid_currencyUuid format. Unlike the WebSockets, a stream without subscriptions is refused, because there is no way to subscribe after opening it. |
400 | TOO_MANY_UUIDS | More than 100 UUIDs in one subscription. |
400 | UNKNOWN_CURRENCY_UUIDUNKNOWN_MARKET_UUIDUNKNOWN_EXCHANGE_UUID | One or more UUIDs do not exist. The whole request is rejected and the message lists the offending UUIDs. |
401 | UNAUTHORIZED | The API key does not exist. |
403 | FORBIDDEN | The API key or subscription does not grant streaming access, the feed is disabled, or streaming limits are not configured for the plan. |
403 | THROTTLE_NOT_ALLOWED | The requested update interval is faster than your plan allows. Omit throttle to use your plan’s minimum, or choose a supported slower interval. |
403 | STREAM_ACCESS_REQUIRED | The stream is missing valid gateway authorization. Use the documented api.coinranking.com URL with your API key. If the error persists, contact support. |
404 | NOT_FOUND | Unknown path. Note that /v2/real-time/rates without /sse/ is the WebSocket path, which also answers 404 to a plain HTTP request. |
429 | STREAM_CONNECTION_LIMIT | Your account has reached its shared WebSocket and SSE connection limit. Close an existing connection before opening another. |
429 | RATE_LIMIT_EXCEEDED | Your rate limit is exhausted. Wait for the window in the ratelimit-reset header before reconnecting. |
500 | INTERNAL_SERVER_ERROR | Something went wrong on our side. The body has status error, and the message carries a reference you can quote when contacting support. |
503 | STREAMING_UNAVAILABLE | Streaming capacity could not be checked. Retry after a delay. This is a temporary service error, not an instruction to upgrade. |
Using the stream from a website
A page that calls the API directly has to put the key in the URL, where every visitor can read it, and it cannot see why a request was refused. Production sites avoid both by proxying the stream through their own origin: a small route on your server adds the key, opens the upstream stream, and pipes it back to the browser unchanged. The browser then uses EventSource against your own domain, so there is no key in the page and no cross-origin request.
// Node.js 18+. GET /stream/rates?currency-uuids[]=... on your server becomes
// a Coinranking stream with the key added here, never in the browser.
import http from 'node:http';
import { Readable } from 'node:stream';
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/stream/rates') return res.writeHead(404).end();
// When the tab goes away, end the upstream stream too. The response's close
// event fires when the browser disconnects.
const controller = new AbortController();
res.once('close', () => controller.abort());
const upstream = await fetch(
`https://api.coinranking.com/v2/real-time/sse/rates${url.search}`,
{ headers: { 'x-access-token': process.env.COINRANKING_API_KEY }, signal: controller.signal }
).catch(() => null);
if (!upstream) {
// Either the tab closed before Coinranking answered, or Coinranking is unreachable
if (!res.destroyed) res.writeHead(502).end();
return;
}
// A refused request (JSON, non-200) passes through as-is, so the browser can read it
res.writeHead(upstream.status, {
'content-type': upstream.headers.get('content-type'),
'cache-control': 'no-cache, no-transform',
});
Readable.fromWeb(upstream.body).on('error', () => res.end()).pipe(res);
}).listen(3000); Three things to keep in mind when you build this:
- Do not buffer. Compression middleware and reverse proxies often hold the response until it ends, which for a stream is never. Disable compression for the route and, behind nginx, set
proxy_buffering off. - Every browser connection is one upstream stream, which counts toward your API usage and rate limit. For a site with many visitors, open one upstream stream per distinct subscription and broadcast its messages to all browsers that want it, instead of one upstream stream per tab.
- Only forward the query parameters you intend to expose. The route above passes the query string through unchanged, which lets any visitor subscribe to anything your key allows. Allow-list the UUIDs or build the upstream URL yourself.
Because the proxy sees the real status and body, it can also turn a refusal into whatever your page finds easiest to handle, for example a single data: frame on a 200. That side-steps the EventSource limitation described in the next section.
Error handling in the browser
The browser's built-in EventSource cannot read the status code or the body of a response that is not a 200 stream. When a request is rejected, it fires a bare error event with readyState === EventSource.CLOSED and does not retry. From that event alone, your page cannot tell a wrong UUID from a wrong API key or an exhausted rate limit. A dropped connection looks different: readyState stays EventSource.CONNECTING and the browser reconnects by itself.
Recommended: a fetch-based client
For real integrations, read the stream with fetch instead. Reading response.body and splitting it on blank lines takes a few lines, as shown in the JavaScript (fetch) tab above. It works in browsers as well as Node.js, Deno, and Bun, it exposes the status, code, and message of every rejected request, and it lets you send the API key in a header instead of the URL. If you prefer a library, @microsoft/fetch-event-source does the same with reconnects handled for you:
import { fetchEventSource } from '@microsoft/fetch-event-source';
await fetchEventSource('https://api.coinranking.com/v2/real-time/sse/rates?currency-uuids[]=Qwsogvtv82FCd', {
headers: { 'x-access-token': 'your-api-key' },
async onopen(response) {
if (response.ok) return;
// Rejected before the stream started; throwing stops the retries
const { code, message } = await response.json();
throw new Error(`HTTP ${response.status} ${code}: ${message}`);
},
onmessage(event) {
console.log(JSON.parse(event.data));
},
}); Diagnosing a rejected EventSource request
If you keep using EventSource, treat an error event with readyState === EventSource.CLOSED right after opening as a rejected request. Then do a single plain fetch of the same URL to read the status, code, and message. If that request unexpectedly succeeds, abort it right away so you do not leave a second stream open.
const url = 'https://api.coinranking.com/v2/real-time/sse/rates?x-access-token=your-api-key¤cy-uuids[]=Qwsogvtv82FCd';
const source = new EventSource(url);
source.onmessage = (event) => {
console.log(JSON.parse(event.data));
};
source.onerror = async () => {
// CONNECTING means the connection dropped; the browser retries by itself
if (source.readyState !== EventSource.CLOSED) return;
// CLOSED means the request was rejected. EventSource hides the reason, so
// fetch the same URL once to read the status and the error body.
const controller = new AbortController();
const response = await fetch(url, { signal: controller.signal });
if (response.ok) {
controller.abort(); // It succeeded this time: do not leave the stream open
return;
}
const { code, message } = await response.json();
console.error(`HTTP ${response.status} ${code}: ${message}`);
}; Reconnecting
There is no maximum stream duration. A stream ends in three cases: the real-time service is deployed or restarted, which ends every open stream cleanly; you close it; or your client stops reading. In the last case the server terminates the stream once two consecutive :ping heartbeats are left unread, roughly a minute, so a consumer that cannot keep up with a list=all stream is cut off rather than buffered without bound. Idle timeouts on the way do not apply as long as the heartbeats flow.
The subscriptions live in the URL, so reconnecting with the same URL restores everything; there is no state to rebuild. EventSource reconnects on its own after the five seconds announced by retry: 5000. Other clients should reconnect after a similar short delay. Opening a stream counts toward your rate limit, so do not retry a rejected request without fixing its cause, and back off when the response is a 429.