Skip to content

WebSocket

The live feed is a single WebSocket endpoint. All messages in both directions are UTF-8 JSON text frames; the server never uses binary frames or compression. This page covers transport mechanics — for the shape of each message the server emits, see Message Types.


Endpoint

wss://api.mackinac.io/feed

Standard Upgrade: websocket handshake; no subprotocols required. Include a descriptive User-Agent header (e.g. mackinac-python/1.0) to aid incident attribution.


Authentication

Authentication is optional for public data. An anonymous connection works at the free-tier symbol cap (3 concurrent). Authenticate before sending any subscribe to unlock higher tiers.

{ "action": "auth", "key": "mk_live_0123456789abcdef0123456789abcdef" }

Server response on success:

{ "type": "authed", "tier": "api", "symbolLimit": 100 }

On failure:

{ "type": "error", "code": "auth_failed", "message": "Invalid or revoked API key." }

The server may follow an auth_failed with a close frame. Refresh credentials before reconnecting.

JWT per subscribe

Pass a token field on the first subscribe of the session:

{ "action": "subscribe", "exchange": "hl", "symbol": "ETH", "token": "eyJhbGciOi..." }

The server resolves your tier from the JWT on this first subscribe, caches it for the session, and proceeds. Subsequent subscribes in the same session omit the token. This exists so browser clients can authenticate without a separate round-trip; for Python clients use API keys.


Subscribe / Unsubscribe

Subscribe

{ "action": "subscribe", "exchange": "<venue>", "symbol": "<symbol>" }

Both fields are required. exchange is a venue id from the Venue Matrix; symbol follows the per-venue convention.

There is no explicit acknowledgement — the next data frame arriving for that subscription is the confirmation. On failure, an error frame is emitted instead:

{ "type": "error", "code": "symbol_limit_reached", "message": "..." }

Unsubscribe

{ "action": "unsubscribe", "exchange": "<venue>", "symbol": "<symbol>" }

No response. Subsequent frames for that subscription stop arriving immediately.

Subscribe rate limit

The server allows 30 subscribe actions per 10-second sliding window per session. Exceeding this returns:

{ "type": "error", "code": "rate_limited", "message": "Too many subscribe requests. Try again in 7s.", "retryAfter": 7 }

Wait retryAfter seconds. For bulk subscribes (opening 50+ symbols at startup), throttle to ≤ 3/sec.


Cross-venue routing

Some subscriptions fan out internally at the backend:

  • Subscribing to any of uni, univ4, sushi, pancake, ammbook, or ammliquidity causes the backend to subscribe all four Arbitrum AMMs to that symbol. You receive messages only from venues you explicitly subscribed — unless you also subscribed to ammbook.
  • rates:all aggregates rate_market from every Pendle + Spectra market.
  • rates:swaps aggregates print from every Pendle + Spectra pool.
  • rates:<PT-symbol> receives rate_market + rate_depth (Pendle only) for one market.

Snapshot replay

On subscribe, some venues deliver a one-time backfill so clients can render recent context immediately:

VenueWhat’s replayed
hlUp to ~5,000 most-recent prints (in-memory ring buffer)
dydxUp to 5,000 most-recent prints per symbol
uni / univ4 / sushi / pancakeUp to 5,000 most-recent prints per pair
ostiumUp to 500 most-recent prints
pendle / spectraOne cached rate_market snapshot + up to 500 prints
ammliquidityUp to 1,000 most-recent Mint/Burn events in a single ammliquidity_snapshot frame

Snapshots are one-shot: delivered immediately after subscribe, then the stream transitions to live. Re-subscribing to the same symbol on the same session does NOT re-trigger a backfill. Reconnecting and re-subscribing does.

Snapshot ordering: oldest first. After the snapshot, messages arrive in real-time order (not strictly guaranteed across venues with different polling cadences).


Heartbeat & feed health

WebSocket keepalive

The server does not send application-level ping frames. It relies on standard WebSocket Ping/Pong control frames (RFC 6455). If rolling your own client, send a Ping control frame every 30–60 seconds. The connection closes if no data is exchanged in ~5 minutes.

Stale-feed notifications

The server monitors each upstream feed and broadcasts a feed_stale event when the feed goes silent:

VenueStaleness threshold
hl, dydx60 seconds
AMM venues (uni, univ4, univ4chain, sushi, pancake)300 seconds (event-driven; gaps are normal)
All others60 seconds

Stale check runs every 30 seconds. Broadcast to ALL connected clients regardless of subscription.

{ "type": "feed_stale", "exchange": "hl", "staleSec": 67.4 }

When the feed recovers:

{ "type": "feed_live", "exchange": "hl" }

feed_stale is advisory — no client action is required. The server is already attempting to reconnect upstream.

Graceful shutdown

Before a planned restart:

{ "type": "server_closing", "reconnectIn": 5000 }

Wait reconnectIn milliseconds before reconnecting to stagger the reconnect storm.


Reconnect policy

The server does not maintain subscription state across a TCP disconnect. After reconnecting, you must re-subscribe to everything.

Recommended policy:

  1. Exponential backoff — start at 1 s, double on each consecutive failure, cap at 30 s. Reset on first successful message.
  2. Re-auth first — send the auth action before any subscribe.
  3. Re-subscribe all — replay your full subscription list, throttled to ≤ 3/sec.
  4. Dedup snapshots — snapshot replay after reconnect may overlap with prints seen before the disconnect. Dedup using (symbol, blockNumber, txIndex) for on-chain venues; (symbol, time, price, size, side) for HL.
  5. Respect server_closing — if you received this frame before disconnect, wait reconnectIn ms before the first reconnect attempt.

Delivery guarantees

  • No total ordering across symbols or venues. A print for symbol A may arrive before a quote for symbol B even if B’s update happened first upstream.
  • Per-symbol ordering within a venue is best-effort.
  • At-most-once delivery. If a client falls behind consuming the socket, the TCP buffer fills and the server will eventually close the connection. The server does not buffer or replay beyond the snapshot window.
  • For historical data outside the snapshot window, use /v1/history/.

Backpressure

If the client is too slow, the server’s send buffer fills (~1 MB default). Defensive patterns:

  • Decode frames in a dedicated coroutine or thread; never block the WS read loop on user code.
  • Use an in-process queue (asyncio.Queue(maxsize=10_000)) between the WS reader and consumer. On overflow, drop the oldest with a logged warning.
  • For high-volume subscriptions (e.g. hl:ETH during a volatility event) prefer batched processing over per-message latency.

Minimal Python example

import os, asyncio, json, websockets
KEY = os.environ["MACKINAC_API_KEY"]
async def main():
async with websockets.connect("wss://api.mackinac.io/feed") as ws:
await ws.send(json.dumps({"action": "auth", "key": KEY}))
authed = json.loads(await ws.recv())
assert authed["type"] == "authed", authed
await ws.send(json.dumps({"action": "subscribe", "exchange": "hl", "symbol": "ETH"}))
await ws.send(json.dumps({"action": "subscribe", "exchange": "uni", "symbol": "WETH/USDC"}))
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "error":
print("error:", msg["code"], msg["message"])
elif msg["type"] == "feed_stale":
print("stale:", msg["exchange"], msg["staleSec"], "s")
elif msg["type"] == "server_closing":
await asyncio.sleep(msg["reconnectIn"] / 1000)
break
else:
print(msg["type"], msg.get("exchange"), msg.get("symbol"))
asyncio.run(main())