Developers API

Build a Real-Time Tadawul Stream with SAHMK WebSocket (JavaScript + Python)

Connect to the SAHMK WebSocket API with API key auth, subscribe and unsubscribe symbols, handle ping and pong, and build production-safe reconnect and error handling for real-time Tadawul price updates.

WebSocketPro+Intermediate13 min read

1. When to use WebSocket vs REST

Use REST for on-demand snapshots and quick lookups. Use WebSocket when you need live price updates pushed as they change with low latency during market hours.

  • REST: simple polling, easier for low-frequency updates.
  • WebSocket: real-time stream, no polling loop, better for live dashboards and alerts.

2. Prerequisites (API key + plan)

  • SAHMK API key: `shmk_live_*` or `shmk_test_*`.
  • Pro, Business, or Enterprise plan for WebSocket access.
  • JavaScript runtime (browser) or Python 3.9+.

WebSocket endpoint:

text
wss://api.sahmk.sa/ws/v1/stocks/?api_key=YOUR_API_KEY

Plan behavior

  • Pro/Business: subscribe with explicit symbols (no `*`)
  • Enterprise: `*` is allowed
  • Use `connected.limits` as runtime truth for your account's current caps (including symbols-per-connection and per-call limits).

3. Connect and inspect `connected` payload

The first message you should handle is `connected`. Read `connected.limits` and use it as runtime truth.

json
{
  "type": "connected",
  "plan": "pro",
  "limits": {
    "max_symbols_per_connection": 60,
    "max_symbols_per_call": 20,
    "stream_modes": ["standard"]
  },
  "message": "Connected to SAHMK real-time stock stream",
  "timestamp": "2026-02-10T10:00:00.000Z"
}

4. Subscribe to specific symbols

Client message formats:

json
{"action":"subscribe","symbols":["2222","1120"]}
{"action":"unsubscribe","symbols":["2222"]}
{"action":"ping"}
{"action":"subscribe","symbols":["*"]}

Use `*` only on Enterprise. On Pro/Business, always send explicit symbol lists.

Server message types you should handle: `connected`, `subscribed`, `quote`, `error`, and `pong`.

5. Handle quote stream and render UI (JavaScript browser)

This browser example includes connect, subscribe, quote parsing, ping every 30s, exponential backoff reconnect with jitter, and graceful unsubscribe on page unload.

websocket_browser.js
const API_KEY = "YOUR_API_KEY";
const URL = `wss://api.sahmk.sa/ws/v1/stocks/?api_key=${API_KEY}`;
const SYMBOLS = ["2222", "1120"];

let ws = null;
let pingTimer = null;
let reconnectAttempt = 0;
let manualClose = false;
let lastErrorSignal = null;

function nextDelayMs(attempt) {
  const base = 1000;
  const cap = 30000;
  const exp = Math.min(cap, base * 2 ** attempt);
  const jitter = Math.floor(Math.random() * 500);
  return exp + jitter;
}

function startPing() {
  clearInterval(pingTimer);
  pingTimer = setInterval(() => {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ action: "ping" }));
    }
  }, 30000);
}

function stopPing() {
  clearInterval(pingTimer);
  pingTimer = null;
}

function parseRetryAfterSeconds(details) {
  if (!details || typeof details !== "object") return null;
  const raw = details.retry_after_seconds;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed <= 0) return null;
  return parsed;
}

function connect() {
  ws = new WebSocket(URL);

  ws.onopen = () => {
    reconnectAttempt = 0;
    lastErrorSignal = null;
    ws.send(JSON.stringify({ action: "subscribe", symbols: SYMBOLS }));
    startPing();
  };

  ws.onmessage = (event) => {
    let msg;
    try {
      msg = JSON.parse(event.data);
    } catch {
      console.error("Invalid JSON payload from stream:", event.data);
      return;
    }

    if (msg.type === "connected") {
      console.log("Connected limits:", msg.limits);
      return;
    }

    if (msg.type === "subscribed") {
      console.log("Subscribed symbols:", msg.symbols);
      return;
    }

    if (msg.type === "quote") {
      const { symbol, data } = msg;
      console.log(`${symbol}: ${data.price} (${data.change_percent}%)`);
      // Example UI hook:
      // document.querySelector(__TOKEN_14__).textContent = data.price;
      return;
    }

    if (msg.type === "error") {
      // Keep latest message-level signal so close handling can choose the right action.
      lastErrorSignal = {
        code: msg.code || null,
        details: msg.details || null,
      };
      console.error("Server error:", msg);
      return;
    }

    if (msg.type === "pong") {
      console.log("pong");
    }
  };

  ws.onerror = (event) => {
    console.error("WebSocket error:", event);
  };

  ws.onclose = (event) => {
    stopPing();
    console.warn(`Closed: code=${event.code} reason=${event.reason}`);

    // Auth path. Do not loop forever without intervention.
    if (event.code === 4401) {
      console.error("Authentication failure (4401). Check API key.");
      return;
    }

    // 4403 means access/entitlement class. Stop and fix account state.
    if (event.code === 4403) {
      console.error("Access denied (4403). Fix account/plan status before retrying.");
      return;
    }

    // 4429 means temporary throttle class. Retry with backoff + jitter.
    if (event.code === 4429) {
      const retryAfterSeconds = parseRetryAfterSeconds(lastErrorSignal?.details);
      const jitterMs = Math.floor(Math.random() * 500);
      const requestedDelayMs = (retryAfterSeconds || 1) * 1000 + jitterMs;
      const delayMs = Math.max(nextDelayMs(reconnectAttempt++), requestedDelayMs);
      console.warn(
        `Throttled (4429). Reconnecting in ${delayMs}ms.`
      );
      if (!manualClose) setTimeout(connect, delayMs);
      return;
    }

    // Deploy/restart can drop active sockets; reconnect and resubscribe on open.
    if (!manualClose) {
      const delay = nextDelayMs(reconnectAttempt++);
      setTimeout(connect, delay);
    }
  };
}

window.addEventListener("beforeunload", () => {
  manualClose = true;
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ action: "unsubscribe", symbols: SYMBOLS }));
  }
  stopPing();
  ws?.close(1000, "Page unload");
});

connect();

6. Keep-alive and reconnect logic (Python async)

This Python example uses `websockets` and includes safe reconnect with jittered backoff, ping interval, quote parsing, and server error handling. In production, assume deploys/restarts can briefly disconnect clients and reconnect + resubscribe.

stream_quotes.py
import asyncio
import contextlib
import json
import random
import websockets

API_KEY = "YOUR_API_KEY"
URL = f"wss://api.sahmk.sa/ws/v1/stocks/?api_key={API_KEY}"
SYMBOLS = ["2222", "1120"]

def next_delay_seconds(attempt: int) -> float:
  base = 1.0
  cap = 30.0
  exp = min(cap, base * (2 ** attempt))
  jitter = random.uniform(0.0, 0.5)
  return exp + jitter

def parse_retry_after_seconds(details):
  if not isinstance(details, dict):
    return None
  raw = details.get("retry_after_seconds")
  try:
    value = float(raw)
  except (TypeError, ValueError):
    return None
  if value <= 0:
    return None
  return value

async def ping_loop(ws):
  while True:
    await asyncio.sleep(30)
    await ws.send(json.dumps({"action": "ping"}))

async def stream_forever():
  attempt = 0
  last_error_signal = None
  while True:
    try:
      async with websockets.connect(URL, ping_interval=None) as ws:
        attempt = 0
        last_error_signal = None
        await ws.send(json.dumps({"action": "subscribe", "symbols": SYMBOLS}))
        pinger = asyncio.create_task(ping_loop(ws))

        try:
          async for raw in ws:
            try:
              msg = json.loads(raw)
            except json.JSONDecodeError:
              print("Invalid JSON payload from stream:", raw)
              continue
            msg_type = msg.get("type")

            if msg_type == "connected":
              print("Connected limits:", msg.get("limits"))
            elif msg_type == "subscribed":
              print("Subscribed:", msg.get("symbols"))
            elif msg_type == "quote":
              symbol = msg.get("symbol")
              data = msg.get("data", {})
              print(f"{symbol}: {data.get('price')} ({data.get('change_percent')}%)")
            elif msg_type == "error":
              last_error_signal = {
                "code": msg.get("code"),
                "details": msg.get("details"),
              }
              print("Server error:", msg)
            elif msg_type == "pong":
              print("pong")
        finally:
          pinger.cancel()
          with contextlib.suppress(asyncio.CancelledError):
            await pinger
    except websockets.exceptions.ConnectionClosed as exc:
      print(f"Connection closed: code={exc.code}, reason={exc.reason}")
      if exc.code == 4401:
        print("Authentication failure (4401). Check API key.")
        return
      if exc.code == 4403:
        print("Access denied (4403). Fix account/plan status before retrying.")
        return
      if exc.code == 4429:
        signal = last_error_signal or {}
        retry_after = parse_retry_after_seconds(signal.get("details"))
        jitter = random.uniform(0.0, 0.5)
        requested_delay = (retry_after if retry_after else 1.0) + jitter
        delay = max(next_delay_seconds(attempt), requested_delay)
        print(f"Throttled (4429). Reconnecting in {delay:.2f}s")
        await asyncio.sleep(delay)
        attempt += 1
        continue
    except Exception as exc:
      print(f"Unexpected error: {exc}")

    delay = next_delay_seconds(attempt)
    print(f"Reconnecting in {delay:.2f}s")
    await asyncio.sleep(delay)
    attempt += 1

if __name__ == "__main__":
  asyncio.run(stream_forever())
bash
pip install websockets

7. Using the trades channel

The trades channel uses the same authentication, subscribe, unsubscribe, ping, reconnect, and resubscribe patterns shown in this tutorial. Change the endpoint and handle trade-specific message types.

text
wss://api.sahmk.sa/ws/v1/market/trades/?api_key=YOUR_API_KEY
  • After an explicit-symbol subscription is acknowledged, the server sends a `trades_snapshot` for each newly subscribed symbol.
  • Live executions arrive as `trade` messages.
  • Keep the 30-second ping and the same production reconnect and error handling used for quotes.
View the trades payload reference →

8. Production checklist

  • Reconnect with exponential backoff + jitter.
  • Resubscribe after every reconnect (subscription state is per connection).
  • Treat close code as the class and `error.code/details` as the action signal.
  • `4401`: stop and fix authentication before reconnecting.
  • `4403`: stop and fix entitlement/account status before reconnecting.
  • `4429`: temporary throttle; retry with backoff + jitter and honor `retry_after_seconds` when provided.
  • Treat invalid JSON / unknown action responses as message-level `error` events, not socket-close failures.
  • Log disconnect codes/reasons for observability and alerting.
  • Do not assume server-side subscriptions persist after reconnect.
  • If using a Python SDK wrapper, only rely on auto reconnect/resubscribe after confirming that behavior in your SDK version docs/tests.

Multi-connection guardrails

  • Open multiple sockets with a `200-400ms` stagger instead of all at once.
  • Queue reconnect attempts globally per API key.
  • Avoid synchronized reconnect storms around market open.

Off-hours connections

You may keep WebSocket connections open outside market hours. SAHMK does not intentionally disconnect clients at market close. Continue sending a ping every 30 seconds, reconnect with capped exponential backoff and jitter when necessary, and restore subscriptions after every reconnect. Market events may be absent while the market is inactive.

9. Plan access and wildcard scope

Use explicit symbol lists on Pro/Business. Wildcard `*` subscriptions are Enterprise-only.

enterprise_subscribe_all.js
// Enterprise-only example:
ws.send(JSON.stringify({
  action: "subscribe",
  symbols: ["*"]
}));

Warning on message volume

Using `*` can produce high message volume. You should process messages asynchronously, avoid heavy per-message computation on the main thread, and apply buffering or throttled rendering in your UI.

10. Common errors and troubleshooting

Gotchas

  • After sending `ping`, you may receive a `quote` before `pong` on busy streams. This is normal.
  • Use values from `connected.limits` as runtime truth.
  • Updates are published when symbols change during active market sessions.
  • Do not manually inject duplicate `Origin` headers in custom clients or proxies.

Error handling checklist

  • Surface server `error` messages in logs and monitoring (keep latest `error.code/details`).
  • Treat close code `4401` as authentication failure and stop blind retries.
  • For close `4403`, branch using the latest `error.code/details`: treat it as entitlement/account access denial and stop retries until account or plan state is fixed.
  • For close `4429`, treat it as temporary throttle: retry with backoff + jitter and honor `retry_after_seconds` when available in latest `error.details`.
  • If Pro client sends `[*]`, handle plan error and fall back to explicit symbol lists.
  • Invalid JSON or unknown action returns `type: "error"` while the socket stays open; handle it as a message-level failure.
  • Server deploy/restart events may drop active connections; reconnect and resubscribe automatically.
  • Use exponential backoff with jitter and a max delay cap.
  • Gracefully unsubscribe and close the connection on page unload or app shutdown.

Troubleshooting FAQ

I connected but see no updates. Why?

Ensure you sent a `subscribe` message and test during market hours. Updates are pushed when prices change.

I get disconnected quickly with code 4401 or 4403.

For `4401`, verify API key format and key validity. For `4403`, fix entitlement/account state before retrying. For `4429`, use delayed reconnect with jitter and honor `retry_after_seconds` from the latest server error details when present.

I am not on Enterprise and `*` subscription fails.

`*` is enterprise-only. On Pro/Business, subscribe with explicit symbols, up to limits returned in `connected.limits`.

11. Next steps

  • Add event rule logic using SAHMK webhooks and alerts rules.
  • Implement REST fallback snapshots when stream reconnects.
  • Persist latest quote state and render it in your dashboard UI.

Resources

Build your live Tadawul stream

Start with symbol subscriptions on Pro or Business, then scale to enterprise patterns when you need broader coverage.

Published by @sahmk_sa · Licensed by Tadawul (Saudi Exchange)