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/feedStandard 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.
API key (recommended for Python clients)
{ "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, orammliquiditycauses the backend to subscribe all four Arbitrum AMMs to that symbol. You receive messages only from venues you explicitly subscribed — unless you also subscribed toammbook. rates:allaggregatesrate_marketfrom every Pendle + Spectra market.rates:swapsaggregatesprintfrom every Pendle + Spectra pool.rates:<PT-symbol>receivesrate_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:
| Venue | What’s replayed |
|---|---|
hl | Up to ~5,000 most-recent prints (in-memory ring buffer) |
dydx | Up to 5,000 most-recent prints per symbol |
uni / univ4 / sushi / pancake | Up to 5,000 most-recent prints per pair |
ostium | Up to 500 most-recent prints |
pendle / spectra | One cached rate_market snapshot + up to 500 prints |
ammliquidity | Up 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:
| Venue | Staleness threshold |
|---|---|
hl, dydx | 60 seconds |
AMM venues (uni, univ4, univ4chain, sushi, pancake) | 300 seconds (event-driven; gaps are normal) |
| All others | 60 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:
- Exponential backoff — start at 1 s, double on each consecutive failure, cap at 30 s. Reset on first successful message.
- Re-auth first — send the
authaction before anysubscribe. - Re-subscribe all — replay your full subscription list, throttled to ≤ 3/sec.
- 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. - Respect
server_closing— if you received this frame before disconnect, waitreconnectInms 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:ETHduring 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())