# Introduction
Real-time WebSocket feed for crypto exchange listing announcements. Connect over WSS, receive structured JSON the moment a listing, delisting, or risk event is detected.
## Supported exchanges
| Exchange | Status |
| -------- | ------ |
| Binance | Live |
| Upbit | Live |
| Bithumb | Live |
## Announcement types
| Type | Description | Exchanges | Impact |
| ------------------------------------------------- | -------------------------------------------------------------------- | ----------------------- | :----: |
| `spot_listing` | New spot market listing | Binance, Upbit, Bithumb | + |
| `spot_delisting` | Spot market delisting | Binance, Upbit, Bithumb | − |
| `futures_listing` | New futures / perpetual listing | Binance | + |
| `futures_delisting` | Futures / perpetual delisting | Binance | − |
| `hodler_airdrop` | Binance HODLer Airdrop | Binance | + |
| `monitoring_tag_extend` | Token added to Binance's Monitoring Tag | Binance | − |
| `monitoring_tag_remove` | Token removed from Binance's Monitoring Tag | Binance | + |
| `caution_released` | Caution designation lifted | Bithumb, Upbit | + |
| `not_listing` | Other announcement (maintenance, token swap, etc.) | Binance, Upbit | n/a |
See [Message Reference](message-reference.md) for full payload schemas.
## Features
- Microsecond timestamps at every stage (detect, dispatch).
- Pre-parsed `ticker` and `listingType` on every message; original `title` kept for cross-checking.
- Per-exchange filtering via `?cex=`.
- WebSocket PING every 15 s; libraries handle PONG automatically.
## Next steps
| Page | Purpose |
| ---- | ------- |
| [Quick Start](quick-start.md) | Connect in under 5 minutes |
| [Authentication](authentication.md) | API key format and usage |
| [WebSocket API](websocket-api.md) | Endpoints, lifecycle, query parameters |
| [Message Reference](message-reference.md) | JSON schemas for every message type |
| [Exchange Filtering](exchange-filtering.md)| `?cex=` and event-type filtering |
| [Rate Limits](rate-limits.md) | Connection, message, and per-key caps |
| [Error Handling](error-handling.md) | Close codes and reconnection strategy |
| [Code Examples](code-examples.md) | Python, Node.js, Go, Rust |
---
# Quick Start
Connect, authenticate, and receive announcements in five minutes.
## 1. Get an API key
Request a key on Telegram. Format: `dsk_` + 64 hex characters.
```
dsk_a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890ab
```
The key is shown **once** at creation. Store it securely.
## 2. Connect
| Endpoint | Region | Coverage |
| ------------------------------ | ------------- | ------------------------- |
| `wss://cryptolisting.ws` | AWS Tokyo | Binance + Upbit + Bithumb |
| `wss://kr.cryptolisting.ws` | AWS Seoul | Upbit only |
Pick one endpoint per bot — the closest to your trading region. Same key works on both.
Pass the key as the `X-API-Key` header:
{{#tabs }}
{{#tab name="Python" }}
```bash
pip install websocket-client
```
```python
import json, websocket
URL = "wss://cryptolisting.ws"
HEADER = ["X-API-Key: dsk_your_key_here"]
def on_message(ws, message):
data = json.loads(message)
if data["type"] == "announcement":
print(f"{data['listingType']} | {data['ticker']} | {data['publisher']}")
print(f" {data['title']}")
websocket.WebSocketApp(URL, header=HEADER, on_message=on_message).run_forever()
```
{{#endtab }}
{{#tab name="Node.js" }}
```bash
npm install ws
```
```javascript
const WebSocket = require("ws");
const ws = new WebSocket("wss://cryptolisting.ws", {
headers: { "X-API-Key": "dsk_your_key_here" },
});
ws.on("message", (raw) => {
const data = JSON.parse(raw);
if (data.type === "announcement") {
console.log(`${data.listingType} | ${data.ticker} | ${data.publisher}`);
console.log(` ${data.title}`);
}
});
```
{{#endtab }}
{{#endtabs }}
## 3. Receive messages
On connect, the server sends a `welcome` message with your tier and limits. Then a stream of `announcement` and `heartbeat` (every 30 s) messages.
```json
{
"type": "announcement",
"title": "Binance Will List TOKEN (TOKEN)",
"ticker": "TOKEN",
"publisher": "binance",
"listingType": "spot_listing",
"detectedTimestampUs": 1710345000005000,
"dispatchTimestampUs": 1710345000006000,
"abnormalDetectionLatency": false
}
```
On the `free` tier, `ticker` is `""` and `title` carries an upgrade notice for every event except `not_listing`. Other fields are unchanged. See [Tier behavior](message-reference.md#tier-behavior).
## 4. Filter by exchange (optional)
Add the `cex` query parameter to scope the stream:
```
wss://cryptolisting.ws?cex=binance
wss://cryptolisting.ws?cex=binance,upbit
```
Omit it to receive every supported exchange.
## Next
- [Message Reference](message-reference.md) — every field, every event type
- [Error Handling](error-handling.md) — close codes and reconnection
- [Code Examples](code-examples.md) — production-ready clients
---
CryptoListing.ws is a technical data feed, not financial advice. See [Legal](/legal/).
---
# Authentication
Every connection requires an API key in the `X-API-Key` header.
## Key format
```
dsk_<64 hex characters>
```
- Prefix: `dsk_`
- Body: 64 hex characters (32 random bytes)
## Passing the key
Set `X-API-Key` on the WebSocket upgrade request:
```
X-API-Key: dsk_your_key_here
```
Query-parameter authentication (`?api_key=…`) is **not supported** and the handshake returns `401`. This keeps keys out of URLs, browser history, and proxy logs.
## Key properties
Set by the administrator at creation:
| Property | Description |
| --------------------- | -------------------------------------------------------------------------- |
| **Tier** | `free`, `basic`, `premium`, or `enterprise` — see [Tier behavior](message-reference.md#tier-behavior) |
| **Allowed CEX** | Exchanges this key may subscribe to (`*` = all) |
| **Max distinct IPs** | Concurrent IPs allowed for this key |
| **Expiration** | Optional — key is rejected after this date |
Per-IP and absolute connection caps are fixed (5 / 20). See [Rate Limits](rate-limits.md).
## Lifecycle
| State | Trigger | Effect |
| ------------ | ------------------------------------ | -------------------------------------------------------------------------- |
| **Active** | Created | Connections accepted |
| **Expired** | Expiration date reached | Handshake refused; active sessions closed with `1000 key_expired` |
| **Revoked** | Administrator revokes | Handshake refused; active sessions closed with `1000 key_invalidated` |
## Security
- All connections use TLS (WSS).
- Treat the key like a password. Lost or leaked? Contact the administrator to rotate it.
---
# WebSocket API
Two endpoints share the same API-key namespace.
## Endpoints
| URL | Region | Coverage | Use it for |
| ------------------------------ | -------------------------------------- | ------------------------- | ----------------------------------------- |
| `wss://cryptolisting.ws` | AWS Tokyo (ap-northeast-1a, apne1-az4) | Binance + Upbit + Bithumb | Bots in Tokyo / global |
| `wss://kr.cryptolisting.ws` | AWS Seoul (ap-northeast-2c, apne2-az3) | Upbit only | Korea-based bots trading Upbit |
Pick one per bot — the closest to your trading region. The Seoul endpoint removes the Seoul → Tokyo network hop on Upbit detection. Bithumb is dispatched only from Tokyo. Same key authenticates on both, but [rate limits and connection caps are tracked independently](rate-limits.md).
Frames are **binary**; payloads are UTF-8 JSON.
## Query parameters
| Parameter | Required | Description | Example |
| --------- | -------- | ---------------------------------------- | ---------------- |
| `cex` | No | Comma-separated list of exchanges | `binance,upbit` |
See [Exchange Filtering](exchange-filtering.md).
## Lifecycle
```
Client Server
| |
|--- WSS handshake + API key -----→ |
|←---- 101 Switching Protocols -----|
| |
|←---- welcome --------------------| (immediate)
|←---- PING (control frame) -------| (every 15 s)
|--- PONG ------------------------→ | (handled by your WS lib)
|←---- heartbeat (JSON) -----------| (every 30 s)
|←---- announcement ---------------| (when detected)
|--- {"type":"test"} -------------→ | (optional)
|←---- test_announcement ---------| (only to you)
|←---- close (1000, key_expired) -| (if key expires)
```
### Handshake
If the key fails validation, the server rejects the upgrade with an HTTP error:
| Code | Cause |
| ----- | --------------------------------------------------------------------------- |
| `426` | Malformed upgrade request (missing/invalid `Sec-WebSocket-Key` or `Upgrade`)|
| `401` | Missing `X-API-Key` header |
| `403` | Invalid, revoked, or expired key |
| `429` | Rate, per-IP, distinct-IP, or absolute-cap limit hit (or cooldown active) |
See [Error Handling](error-handling.md) for retry strategy.
### Welcome
Sent once after a successful handshake. Confirms tier and limits. See [`welcome`](message-reference.md#welcome).
### Keep-alive
The server sends a WebSocket PING control frame every **15 s** (with 0–5 s jitter on the first). Your library responds with PONG automatically — Python `websockets`, Node.js `ws`, Go `gorilla/websocket`, Rust `tokio-tungstenite` all handle it without configuration.
If the server does not receive a PONG within **30 s** of the last ping, it closes the connection.
Don't add an "no-message-for-N-seconds → reconnect" watchdog at the application layer. Listings are sparse; you'll reconnect in a loop during quiet periods. Use your library's close/error callbacks instead.
### Heartbeat
A JSON `heartbeat` message every **30 s**, every tier. Lets your client confirm the connection is alive without waiting for a rare announcement. See [`heartbeat`](message-reference.md#heartbeat).
### Announcements
When an event is detected, every subscriber whose filter matches receives an `announcement`. See [`announcement`](message-reference.md#announcement).
### Disconnection
The server may close the connection with a close frame. The reason field tells you why — see [Error Handling](error-handling.md).
## Client-to-server messages
The only supported client message is the test request:
```json
{"type":"test"}
```
Returns a fake [`test_announcement`](message-reference.md#test-announcement). Limits:
| Limit | Value | Effect on excess |
| ------------------- | ------------- | ----------------------------------------- |
| Message rate | 3 / minute | Connection closed (`rate_limit_exceeded`) |
| Frame size | 1 KB | Connection closed (`frame_too_large`) |
| Test rate | 1 / minute | `test_rate_limited` error response |
---
# Message Reference
All messages are **binary WebSocket frames** containing UTF-8 JSON. Every message has a `type` field.
**Forward compatibility.** New fields may be added at any time without notice. Your parser must ignore unknown fields. Don't use strict schema validation.
| Type | Sent | Direction |
| ------------------- | ------------------------------- | ---------------- |
| `welcome` | Once after handshake | Server → client |
| `heartbeat` | Every 30 s | Server → client |
| `announcement` | When an event is detected | Server → client |
| `test_announcement` | After `{"type":"test"}` | Server → client |
| `error` | On test rate-limit | Server → client |
| `test` | Request a test announcement | Client → server |
---
## Welcome
```json
{
"type": "welcome",
"tier": "premium",
"maxDistinctIps": 2,
"maxConnectionsPerIp": 5,
"absoluteMaxConnections": 20,
"allowedCex": "*",
"expiresInSecs": 2592000
}
```
| Field | Type | Description |
| ------------------------ | --------------- | -------------------------------------------------------------------------------------- |
| `type` | string | Always `"welcome"` |
| `tier` | string | `free`, `basic`, `premium`, or `enterprise` |
| `maxDistinctIps` | integer | Max distinct IPs that can hold connections with this key |
| `maxConnectionsPerIp` | integer | Per-IP cap — fixed at 5 |
| `absoluteMaxConnections` | integer | Hard cap across all IPs — fixed at 20 |
| `allowedCex` | string | Effective filter after key restriction × `?cex=`. `"*"` = all |
| `expiresInSecs` | integer or null | Seconds until the key expires; `null` = no expiration |
---
## Announcement
Sent when the detection engine captures a new event.
```json
{
"type": "announcement",
"title": "Binance Will List TOKEN (TOKEN)",
"ticker": "TOKEN",
"publisher": "binance",
"listingType": "spot_listing",
"detectedTimestampUs": 1710345000005000,
"dispatchTimestampUs": 1710345000006000,
"abnormalDetectionLatency": false
}
```
| Field | Type | Description |
| -------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `type` | string | Always `"announcement"` |
| `title` | string | Original exchange title. Replaced by an upgrade notice on `free` for every type except `not_listing`. |
| `ticker` | string | Asset symbol(s). Comma-separated on multi-ticker events. `""` on `free` for every type except `not_listing`. |
| `publisher` | string | Lowercase exchange name (`binance`, `upbit`, `bithumb`) |
| `listingType` | string | One of [Listing types](#listing-types) |
| `detectedTimestampUs` | integer | Detection time, µs since Unix epoch |
| `dispatchTimestampUs` | integer | Dispatch time, µs since Unix epoch |
| `abnormalDetectionLatency` | boolean | `true` if publish→detect was abnormally high; payload still valid |
**`ticker` may contain several symbols.** Always split on `,`:
```python
tickers = data["ticker"].split(",") # ["ABC", "DEF", "GHI"]
```
**Run your own parser as a fallback.** `ticker` is pre-extracted from `title` for speed. Cross-check it against `title` in production code so a single parser bug doesn't take you down. `title` is always the original, unmodified exchange title.
### Publishers
| Value | Exchange |
| --------- | ----------------------------------------- |
| `binance` | [Binance](https://www.binance.com) |
| `upbit` | [Upbit](https://upbit.com) |
| `bithumb` | [Bithumb](https://www.bithumb.com) |
### Listing types
| Value | Meaning | Exchanges |
| ------------------------- | ------------------------------------------------------------- | --------- |
| `spot_listing` | New spot market listing | Binance, Upbit, Bithumb |
| `futures_listing` | New futures/perpetual listing | Binance |
| `spot_delisting` | Spot market delisting | Binance, Upbit, Bithumb |
| `futures_delisting` | Futures/perpetual delisting | Binance |
| `hodler_airdrop` | Binance HODLer Airdrop | Binance |
| `monitoring_tag_extend` | Token added to Binance's Monitoring Tag | Binance |
| `monitoring_tag_remove` | Token removed from Binance's Monitoring Tag | Binance |
| `caution_released` | Caution designation lifted (`유의 종목 지정 해제`) | Bithumb, Upbit |
| `not_listing` | Other announcement (maintenance, token swap, etc.) | Binance, Upbit |
### Monitoring Tag — edge cases
- **Mixed Seed Tag bundles.** A `monitoring_tag_*` event lists Monitoring Tag tickers only. Tickers in the same announcement that belong to the Seed Tag program are excluded. Seed-Tag-only announcements arrive as `not_listing`.
- **Mixed extend + remove.** When a single Binance announcement both adds and removes tickers, the dispatch emits two separate events with the same `title` and disjoint `ticker` lists.
### Caution lifecycle (Bithumb / Upbit)
Korean exchanges run a multi-stage risk track on tokens already trading (pre-warning, formal designation, extension, release, delisting). We narrow the broadcast to the two stages that drive trading decisions:
- `caution_released` — the caution designation is lifted (`유의 종목 지정 해제`). Often a positive technical signal.
- `spot_delisting` — final removal (`거래지원 종료`).
The intermediate stages (`유의촉구` / `거래유의·투자유의종목 지정` / `지정 연장`) and risk-context deposit halts are detected but intentionally not forwarded — they generate noise without clear actionable signal on the trading side.
**Composite announcements.** A single title that pairs a release with a delisting (e.g. `신세틱스(SNX) 거래유의종목 지정 해제 및 (BCD, WTC) 거래지원 종료`) is split into one event per (`listingType`, ticker-group). Multi-ticker, single-stage titles (e.g. `(BCD, WTC) 거래지원 종료`) are joined comma-separated like listings.
#### Example: caution_released
```json
{
"type": "announcement",
"title": "신세틱스(SNX) 거래유의종목 지정 해제",
"ticker": "SNX",
"publisher": "bithumb",
"listingType": "caution_released",
"detectedTimestampUs": 1745971200834000,
"dispatchTimestampUs": 1745971200842117,
"abnormalDetectionLatency": false
}
```
#### Example: spot_delisting
```json
{
"type": "announcement",
"title": "고트세우스 막시무스(GOAT) 거래지원 종료",
"ticker": "GOAT",
"publisher": "bithumb",
"listingType": "spot_delisting",
"detectedTimestampUs": 1745971200834000,
"dispatchTimestampUs": 1745971200842117,
"abnormalDetectionLatency": false
}
```
### Tier behavior
Tier is returned in `welcome.tier`. Pricing details on the [pricing page](https://cryptolisting.ws/pricing/).
| Tier | All types except `not_listing` | `not_listing` | Delivery delay |
| ------------ | ---------------------------------------------------------------------- | ------------- | -------------- |
| `free` | `ticker = ""`, `title` = upgrade notice. Other fields unchanged. | Full content | None |
| `basic` | Full content | Full content | +20 ms |
| `premium` | Full content | Full content | None |
| `enterprise` | Full content | Full content | None |
The free tier is **default-deny**: any new event type is automatically redacted on free without a schema migration. Free clients must accept `ticker = ""` and the upgrade-notice `title` gracefully.
Test announcements always carry the full payload regardless of tier.
### Measuring latency
All timestamps are **microseconds since Unix epoch**.
```
Dispatch delay = dispatchTimestampUs − detectedTimestampUs
Network delay = your_receive_us − dispatchTimestampUs
Total = your_receive_us − detectedTimestampUs
```
```python
import time
def on_announcement(msg):
now_us = int(time.time() * 1_000_000)
dispatch_ms = (msg["dispatchTimestampUs"] - msg["detectedTimestampUs"]) / 1000
network_ms = (now_us - msg["dispatchTimestampUs"]) / 1000
total_ms = (now_us - msg["detectedTimestampUs"]) / 1000
print(f"dispatch={dispatch_ms:.2f}ms network={network_ms:.2f}ms total={total_ms:.2f}ms")
```
---
## Test Announcement
Send `{"type":"test"}`. The server replies with a fake announcement, identical in shape to a real one but with `type = "test_announcement"` and `ticker = "DUMMYTOKEN"`.
```json
{
"type": "test_announcement",
"title": "Binance Will List DUMMYTOKEN (DUMMYTOKEN)",
"ticker": "DUMMYTOKEN",
"publisher": "binance",
"listingType": "spot_listing",
"detectedTimestampUs": 1743850001999800,
"dispatchTimestampUs": 1743850002000000,
"abnormalDetectionLatency": false
}
```
- Sent only to the requester, not broadcast.
- Always full payload, including on `free`.
- Rate limit: **1 per minute per API key**, shared across connections.
On excess, the server returns an error instead:
```json
{"type": "error", "code": "test_rate_limited", "retryAfterSecs": 42}
```
| Field | Type | Description |
| ---------------- | -------- | --------------------------------- |
| `type` | string | Always `"error"` |
| `code` | string | Currently only `test_rate_limited`|
| `retryAfterSecs` | integer | Seconds until next request allowed|
---
## Heartbeat
Sent every 30 s to every subscriber, every tier.
```json
{
"type": "heartbeat",
"timestampNs": 1710345030123456789,
"timeUtc": "2026-04-17T08:30:30.123456Z"
}
```
| Field | Type | Description |
| ------------- | ------- | -------------------------------------------------------- |
| `type` | string | Always `"heartbeat"` |
| `timestampNs` | integer | Server emission time, ns since Unix epoch |
| `timeUtc` | string | ISO 8601 UTC, microsecond precision |
---
## Keep-alive (PING/PONG)
Separate from the application-level heartbeat, the server sends a WebSocket PING control frame (opcode `0x9`, empty payload) every **15 s** (0–5 s jitter on the first). Your library responds with PONG automatically. If no PONG arrives within **30 s** of the last ping, the server closes the TCP connection.
Don't reconnect on "no announcement for N seconds". Listings are sparse — you'll loop. Watch the 30 s [`heartbeat`](#heartbeat) instead, or rely on your WebSocket library's close/error callbacks.
---
# Exchange Filtering
Two filter layers narrow the stream you receive. The effective scope is their **intersection**.
## 1. Key restriction
Set by the administrator at key creation. Your effective allow-list appears in the [`welcome`](message-reference.md#welcome) message:
```json
{ "type": "welcome", "allowedCex": "binance,upbit", ... }
```
`"*"` means all exchanges.
## 2. `?cex=` query parameter
Pass on the WebSocket URL:
| URL | Receives |
| ---------------------------------------------- | ------------------ |
| `wss://cryptolisting.ws` | All exchanges |
| `wss://cryptolisting.ws?cex=binance` | Binance only |
| `wss://cryptolisting.ws?cex=binance,upbit` | Binance + Upbit |
## Effective filter
| Key allows | You request | You receive |
| ---------------- | ------------- | -------------------- |
| `*` | `binance` | Binance |
| `*` | none | All |
| `binance,upbit` | `binance` | Binance |
| `binance,upbit` | `upbit` | Upbit |
| `binance,upbit` | none | Binance + Upbit |
| `binance` | `upbit` | nothing (no overlap) |
If the intersection is empty, the connection still succeeds and stays alive (PING and `heartbeat` keep flowing) but you receive no `announcement` messages. Verify your `allowedCex` against your `?cex=` value.
## Filtering by event type
`?cex=` narrows by exchange. To narrow by event class, branch on `listingType` in your handler:
```python
def on_message(ws, raw):
msg = json.loads(raw)
if msg["type"] != "announcement":
return
lt = msg["listingType"]
if lt == "spot_listing":
snipe(msg["publisher"], msg["ticker"])
elif lt in ("spot_delisting", "futures_delisting"):
unwind(msg["publisher"], msg["ticker"])
elif lt in ("monitoring_tag_extend", "monitoring_tag_remove", "caution_released"):
log_risk_signal(msg["publisher"], msg["ticker"], lt)
```
The schema is identical across exchanges, so a single `match` / `switch` / `if-elif` chain covers every type. See [Listing types](message-reference.md#listing-types) for the full list.
---
# Rate Limits & Security
**Limits are tracked per endpoint.** A key with a 5-connection-per-IP cap can hold 5 connections on `wss://cryptolisting.ws` *and* 5 on `wss://kr.cryptolisting.ws` simultaneously (10 total). Cooldowns also apply per endpoint.
## Connection limits
### Per IP (any key)
| Limit | Value |
| ------------------------------ | ------------- |
| Concurrent connections | 20 |
| New connections | 10 / minute |
### Per API key
| Limit | Value |
| ------------------------------ | -------------- |
| Connections per IP | 5 |
| Distinct IPs | configurable |
| Absolute connections (all IPs) | 20 |
| Connection cooldown | 5 s per IP |
`Distinct IPs` is set by the administrator at key creation.
## 429 error codes (handshake)
The server returns HTTP 429 with a JSON body whose `error` field tells you which limit you tripped:
| Error | Cause | Action |
| ---------------------------------- | ------------------------------------------------------- | ------------------------------------- |
| `connection_rate_limit_exceeded` | Your IP opened > 10 connections in 60 s (any key) | Back off; retry after the window |
| `per_ip_concurrent_limit_reached` | Your IP holds 20 concurrent connections (any key) | Close unused connections |
| `per_ip_connection_limit_reached` | This IP holds 5 connections for this specific key | Close one before opening another |
| `max_distinct_ips_reached` | Key already in use from its max distinct IPs | Request a higher cap |
| `absolute_connection_cap_reached` | Key hit the 20-total ceiling | Cap is fixed — use a second key |
| `connection_cooldown` | Same key + IP reconnecting within 5 s | Wait `retry_after_s` (5) |
## Client message limits
| Limit | Value | On excess |
| -------------- | ---------- | ----------------------------------------- |
| Message rate | 3 / minute | Connection closed (`rate_limit_exceeded`) |
| Frame size | 1 KB | Connection closed (`frame_too_large`) |
| Test rate | 1 / minute | Error response (`test_rate_limited`) |
The test rate is shared across all connections sharing a key. Excess returns an error message, not a disconnect — see [Test Announcement](message-reference.md#test-announcement).
## Security
- All connections use TLS (WSS).
- Keys can be revoked instantly. Active sessions close with `1000 key_invalidated`.
- Keys with an expiration date are rejected after expiry. Active sessions close with `1000 key_expired`.
---
# Error Handling & Reconnection
## Handshake errors
| HTTP code | Cause | Action |
| --------- | ------------------------------------------------------------ | ----------------------------------- |
| `426` | Malformed upgrade (missing/invalid `Sec-WebSocket-Key`) | Fix the WebSocket client |
| `401` | Missing `X-API-Key` header | Add the header |
| `403` | Invalid, revoked, or expired key | Request a new key |
| `429` | Rate or connection limit hit | Back off, see [Rate Limits](rate-limits.md) |
## Close codes
| Code | Reason | Meaning | Reconnect? |
| ------ | --------------------- | ----------------------------------------------- | --------------------------- |
| `1000` | `key_expired` | Key expiration date passed | No — get a new key |
| `1000` | `key_invalidated` | Administrator revoked the key | No — get a new key |
| `1008` | `too_slow` | Client lagged > 10 messages behind | Yes — process faster |
| `1008` | `rate_limit_exceeded` | Sent > 3 messages / minute to the server | Yes — stop sending |
| `1009` | `frame_too_large` | Sent a frame larger than 1 KB | Yes — fix the client |
## Reconnection strategy
**Exponential backoff**, capped at 5 minutes:
```
attempt 1 → 1 s
attempt 2 → 2 s
attempt 3 → 4 s
attempt 4 → 8 s
… cap at 300 s
```
**Reset the counter** after a successful connection.
**Decision logic**:
| Trigger | Action |
| ------------------------------------------- | ------------------------------------------------- |
| Close `key_expired` / `key_invalidated` | Stop |
| Close `rate_limit_exceeded` | Wait 60 s, stop sending client messages, reconnect|
| Close `too_slow` | Reconnect immediately, process faster |
| HTTP `429` | Back off (you hit a connection limit) |
| Any other disconnect | Reconnect with exponential backoff |
## Detecting dead connections
The server sends a WebSocket PING every 15 s and closes the connection if no PONG arrives within 30 s. Your library handles PONG automatically and surfaces the disconnect as a close event.
```python
import asyncio, json, websockets
async def stream(ws):
try:
async for raw in ws:
data = json.loads(raw)
if data["type"] == "announcement":
handle(data)
except websockets.ConnectionClosed as e:
print(f"closed code={e.code} reason={e.reason!r}")
```
Don't wrap `recv()` in a timeout shorter than ~60 s as a liveness check. Listings are sparse — you'll time out and reconnect during quiet periods. Trust the library's PING/PONG, or watch for the 30 s `heartbeat`.
---
# Code Examples
Production-ready clients with reconnection, latency tracking, and a test-announcement smoke check on connect.
Keep-alive (PING/PONG) is handled by each WebSocket library — no application-level watchdog needed.
{{#tabs }}
{{#tab name="Python" }}
```bash
pip install websockets
```
```python
import asyncio
import json
import time
import websockets
API_KEY = "dsk_your_key_here"
WS_URL = "wss://cryptolisting.ws"
# Filter exchanges (optional):
# WS_URL = "wss://cryptolisting.ws?cex=binance,upbit"
MAX_RETRIES = 20
def on_announcement(msg: dict):
now_us = int(time.time() * 1_000_000)
network_ms = (now_us - msg["dispatchTimestampUs"]) / 1000
print(f"[{msg['listingType']}] {msg['ticker']} on {msg['publisher']}")
print(f" {msg['title']}")
print(f" network={network_ms:.2f}ms")
async def run():
headers = {"X-API-Key": API_KEY}
for attempt in range(MAX_RETRIES):
try:
async with websockets.connect(WS_URL, extra_headers=headers) as ws:
print("connected")
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "welcome":
print(f"welcome: tier={msg['tier']} cex={msg['allowedCex']}")
await asyncio.sleep(15)
await ws.send(json.dumps({"type": "test"}))
elif msg["type"] in ("announcement", "test_announcement"):
if msg["type"] == "test_announcement":
print("[TEST] ", end="")
on_announcement(msg)
except websockets.ConnectionClosed as e:
reason = e.rcvd.reason if e.rcvd else ""
if reason in ("key_expired", "key_invalidated"):
print(f"key invalid: {reason}"); return
print(f"closed: {reason}")
except Exception as e:
print(f"error: {e}")
backoff = min(2 ** attempt, 300)
print(f"reconnecting in {backoff}s")
await asyncio.sleep(backoff)
if __name__ == "__main__":
asyncio.run(run())
```
{{#endtab }}
{{#tab name="Node.js" }}
```bash
npm install ws
```
```javascript
const WebSocket = require("ws");
const API_KEY = "dsk_your_key_here";
const WS_URL = "wss://cryptolisting.ws";
// Filter exchanges (optional):
// const WS_URL = "wss://cryptolisting.ws?cex=binance,upbit";
const MAX_RETRIES = 20;
function onAnnouncement(msg) {
const nowUs = Date.now() * 1000;
const networkMs = (nowUs - msg.dispatchTimestampUs) / 1000;
console.log(`[${msg.listingType}] ${msg.ticker} on ${msg.publisher}`);
console.log(` ${msg.title}`);
console.log(` network=${networkMs.toFixed(2)}ms`);
}
function connect(attempt = 0) {
if (attempt >= MAX_RETRIES) return console.log("max retries");
const ws = new WebSocket(WS_URL, { headers: { "X-API-Key": API_KEY } });
ws.on("open", () => console.log("connected"));
ws.on("message", (data) => {
const msg = JSON.parse(data);
if (msg.type === "welcome") {
console.log(`welcome: tier=${msg.tier} cex=${msg.allowedCex}`);
setTimeout(() => ws.send(JSON.stringify({ type: "test" })), 15_000);
} else if (msg.type === "announcement" || msg.type === "test_announcement") {
if (msg.type === "test_announcement") process.stdout.write("[TEST] ");
onAnnouncement(msg);
}
});
ws.on("close", (code, reason) => {
const r = reason.toString();
if (r === "key_expired" || r === "key_invalidated") {
return console.log(`key invalid: ${r}`);
}
const backoff = Math.min(2 ** attempt, 300);
console.log(`closed (${code}); reconnecting in ${backoff}s`);
setTimeout(() => connect(attempt + 1), backoff * 1000);
});
ws.on("error", (err) => console.error("error:", err.message));
}
connect();
```
{{#endtab }}
{{#tab name="Go" }}
```bash
go get github.com/gorilla/websocket
```
```go
package main
import (
"encoding/json"
"log"
"math"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
apiKey = "dsk_your_key_here"
wsURL = "wss://cryptolisting.ws"
maxRetries = 20
)
type Msg struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
Ticker string `json:"ticker,omitempty"`
Publisher string `json:"publisher,omitempty"`
ListingType string `json:"listingType,omitempty"`
DetectedTimestampUs uint64 `json:"detectedTimestampUs,omitempty"`
DispatchTimestampUs uint64 `json:"dispatchTimestampUs,omitempty"`
AbnormalDetectionLatency bool `json:"abnormalDetectionLatency,omitempty"`
Tier string `json:"tier,omitempty"`
AllowedCex string `json:"allowedCex,omitempty"`
}
func onAnnouncement(m Msg) {
nowUs := uint64(time.Now().UnixMicro())
networkMs := float64(nowUs-m.DispatchTimestampUs) / 1000
log.Printf("[%s] %s on %s | %s | network=%.2fms",
m.ListingType, m.Ticker, m.Publisher, m.Title, networkMs)
}
func main() {
headers := http.Header{"X-API-Key": {apiKey}}
for attempt := 0; attempt < maxRetries; attempt++ {
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
if resp != nil && resp.StatusCode == 403 {
log.Println("key invalid"); return
}
log.Printf("dial: %v", err)
backoff := time.Duration(math.Min(math.Pow(2, float64(attempt)), 300)) * time.Second
log.Printf("reconnect in %s", backoff)
time.Sleep(backoff)
continue
}
log.Println("connected")
attempt = 0
stop := false
for {
_, raw, err := conn.ReadMessage()
if err != nil {
if ce, ok := err.(*websocket.CloseError); ok {
if ce.Text == "key_expired" || ce.Text == "key_invalidated" {
log.Printf("key invalid: %s", ce.Text); stop = true
} else {
log.Printf("closed: %s", ce.Text)
}
} else {
log.Printf("read: %v", err)
}
break
}
var m Msg
json.Unmarshal(raw, &m)
switch m.Type {
case "welcome":
log.Printf("welcome: tier=%s cex=%s", m.Tier, m.AllowedCex)
time.Sleep(15 * time.Second)
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"test"}`))
case "announcement", "test_announcement":
onAnnouncement(m)
}
}
conn.Close()
if stop { return }
backoff := time.Duration(math.Min(math.Pow(2, float64(attempt)), 300)) * time.Second
log.Printf("reconnect in %s", backoff)
time.Sleep(backoff)
}
}
```
{{#endtab }}
{{#tab name="Rust" }}
`Cargo.toml`:
```toml
[dependencies]
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
http = "1"
```
```rust
use futures_util::StreamExt;
use serde::Deserialize;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::protocol::CloseFrame;
use tokio_tungstenite::tungstenite::Message as Ws;
const API_KEY: &str = "dsk_your_key_here";
const WS_URL: &str = "wss://cryptolisting.ws";
const MAX_RETRIES: u32 = 20;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Msg {
#[serde(rename = "type")] msg_type: String,
#[serde(default)] title: String,
#[serde(default)] ticker: String,
#[serde(default)] publisher: String,
#[serde(default)] listing_type: String,
#[serde(default)] dispatch_timestamp_us: u64,
#[serde(default)] tier: String,
#[serde(default)] allowed_cex: String,
}
fn on_announcement(m: &Msg) {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_micros() as u64;
let network_ms = (now - m.dispatch_timestamp_us) as f64 / 1000.0;
println!("[{}] {} on {} | {} | network={:.2}ms",
m.listing_type, m.ticker, m.publisher, m.title, network_ms);
}
#[tokio::main]
async fn main() {
for attempt in 0..MAX_RETRIES {
let req = http::Request::builder()
.uri(WS_URL)
.header("X-API-Key", API_KEY)
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key",
tokio_tungstenite::tungstenite::handshake::client::generate_key())
.body(()).unwrap();
let ws = match connect_async(req).await {
Ok((ws, _)) => ws,
Err(e) => {
eprintln!("dial: {e}");
let backoff = Duration::from_secs((2u64.pow(attempt)).min(300));
tokio::time::sleep(backoff).await;
continue;
}
};
println!("connected");
let (_, mut rx) = ws.split();
let mut stop = false;
while let Some(frame) = rx.next().await {
match frame {
Err(e) => { eprintln!("read: {e}"); break; }
Ok(Ws::Close(Some(CloseFrame { reason, .. }))) => {
if reason == "key_expired" || reason == "key_invalidated" {
eprintln!("key invalid: {reason}"); stop = true;
} else { eprintln!("closed: {reason}"); }
break;
}
Ok(f) => {
if let Ok(text) = f.to_text() {
if let Ok(m) = serde_json::from_str::(text) {
match m.msg_type.as_str() {
"welcome" => println!("welcome: tier={} cex={}", m.tier, m.allowed_cex),
"announcement" | "test_announcement" => on_announcement(&m),
_ => {}
}
}
}
}
}
}
if stop { return; }
let backoff = Duration::from_secs((2u64.pow(attempt)).min(300));
tokio::time::sleep(backoff).await;
}
}
```
{{#endtab }}
{{#endtabs }}
---