WebSocketPro+Intermediate12 min read

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

Learn how to 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.

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 plan or Enterprise plan for WebSocket access.
  • JavaScript runtime (browser) or Python 3.9+.

WebSocket endpoint:

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

Plan behavior

  • Pro: max 60 symbols per connection, max 20 symbols per call, no *
  • Enterprise: * is allowed
  • Enterprise limits: Custom by contract; current default can be 200 symbols/connection unless your enterprise setup specifies otherwise.

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
  },
  "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":["*"]}

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://app.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;

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 connect() {
  ws = new WebSocket(URL);

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

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);

    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(`#price-${symbol}`).textContent = data.price;
      return;
    }

    if (msg.type === "error") {
      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/entitlement path. Do not loop forever without intervention.
    if (event.code === 4001) {
      console.error("Authentication or entitlement failure. Check API key/plan.");
      return;
    }

    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.

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

API_KEY = "YOUR_API_KEY"
URL = f"wss://app.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

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

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

        try:
          async for raw in ws:
            msg = json.loads(raw)
            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":
              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 == 4001:
        print("Authentication or entitlement failure. Check API key/plan.")
        return
    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. Enterprise subscribe-all (`*`) usage

Subscribing to all symbols is 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.

8. 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 arrive when symbols change during market hours (Sun-Thu, 10:00-15:30 KSA).
  • Do not manually inject duplicate `Origin` headers in custom clients or proxies.

Error handling checklist

  • Surface server `error` messages in logs and monitoring.
  • Treat close code `4001` as auth or entitlement failure and stop blind retries.
  • If Pro client sends `[*]`, handle plan error and fall back to explicit symbol lists.
  • 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 4001.

Check API key format, key validity, and whether your account and plan entitlement allow WebSocket streaming.

Pro plan failed when subscribing to `*`.

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

9. Next steps

  • Add alerting logic using SAHMK webhooks and price alerts.
  • 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, then scale to enterprise patterns when you need broader coverage.

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