# 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 }} ---