sifting/io
Developer Tutorials
6 min readSiftingIO Team

Golang WebSocket market data: stream real-time forex and crypto prices

Stream real-time forex and crypto prices in Go over WebSocket: authenticate, subscribe, parse tick frames, and handle pings and reconnects with working code.

Golang WebSocket market data: stream real-time forex and crypto prices

Golang WebSocket market data clients tend to break in the same three places: the connection dies quietly after a minute and a half without traffic, the first frame after a subscribe gets logged as a fresh trade when it is actually a cached snapshot, and a reconnect comes back with zero active subscriptions. None of these show up in a five-minute test. All of them show up overnight in production. This tutorial builds a Go client for SiftingIO's streaming API that handles all three: it connects, authenticates, subscribes to forex and crypto symbols, parses tick frames, and keeps itself alive with pings and exponential-backoff reconnects, in under a hundred lines. If you're still weighing streaming against polling, REST vs WebSocket for real-time market data covers that decision. There's also a Python version of this exercise if Go isn't your stack.

Connect and authenticate#

The stream lives at wss://stream.sifting.io/ws/v1. The simplest authentication is a ?key= query parameter carrying your API key (keys are prefixed sft_). If you'd rather keep the key out of URLs and proxy logs, connect bare and send an auth frame as your first message:

{ "op": "auth", "key": "sft_your_key" }

Either way, the server replies with an ack frame that carries your tier and its limits, including max_conn and max_subs. Read it. It tells you at runtime how many symbols you're allowed to subscribe to, which beats hardcoding a number that changes when the plan does. A bad key gets { "f":"error", "code":"auth_failed" }; connecting without a key and never sending the auth frame gets auth_timeout.

Subscribe and parse WebSocket market data frames#

Subscriptions are grouped by product: fx for forex, cex for crypto aggregated across centralized venues, plus us, com, dex, and tvl for equities, commodities, on-chain swaps, and pool TVL. One connection can hold subscriptions across several products at once:

{ "op": "subscribe", "product": "fx", "symbols": ["EURUSD", "USDJPY"] }

Every server frame discriminates on the f field. Price updates look like this:

{ "f":"tick", "class":"fx", "s":"EURUSD", "p":1.16934, "b":1.16925, "B":510300, "a":1.16943, "A":585025, "t":1778019852426 }

Lowercase p, b, and a are the last price, best bid, and best ask. Their uppercase twins are the matching sizes, and t is a Unix epoch timestamp in milliseconds. The prices are SiftingIO's consensus values, aggregated across multiple independent venues rather than passed through from a single source. Reading a bid and ask correctly is its own topic.

Here's the complete client, using the widely used github.com/gorilla/websocket package:

package main

import (
    "log"
    "os"
    "time"

    "github.com/gorilla/websocket"
)

type frame struct {
    F       string  `json:"f"`
    Class   string  `json:"class"`
    Symbol  string  `json:"s"`
    Price   float64 `json:"p"`
    Bid     float64 `json:"b"`
    BidSize float64 `json:"B"`
    Ask     float64 `json:"a"`
    AskSize float64 `json:"A"`
    T       int64   `json:"t"`
    Code    string  `json:"code"`
}

var subs = map[string][]string{
    "fx":  {"EURUSD", "USDJPY"},
    "cex": {"BTCUSD", "ETHUSD"},
}

func main() {
    key := os.Getenv("SIFTING_KEY")
    backoff := time.Second
    for {
        start := time.Now()
        err := stream(key)
        if time.Since(start) > time.Minute {
            backoff = time.Second // healthy run, reset the backoff
        }
        log.Printf("stream ended: %v, retrying in %s", err, backoff)
        time.Sleep(backoff)
        if backoff < 30*time.Second {
            backoff *= 2
        }
    }
}

func stream(key string) error {
    conn, _, err := websocket.DefaultDialer.Dial("wss://stream.sifting.io/ws/v1?key="+key, nil)
    if err != nil {
        return err
    }
    defer conn.Close()

    // Subscriptions do not survive a reconnect, so they live here:
    // every fresh connection resubscribes before reading.
    for product, symbols := range subs {
        err := conn.WriteJSON(map[string]any{
            "op": "subscribe", "product": product, "symbols": symbols,
        })
        if err != nil {
            return err
        }
    }

    // The server closes connections idle for 90s. Ping well inside that.
    done := make(chan struct{})
    defer close(done)
    go func() {
        t := time.NewTicker(45 * time.Second)
        defer t.Stop()
        for {
            select {
            case <-done:
                return
            case <-t.C:
                conn.WriteJSON(map[string]string{"op": "ping"})
            }
        }
    }()

    for {
        var f frame
        if err := conn.ReadJSON(&f); err != nil {
            return err
        }
        switch f.F {
        case "tick":
            log.Printf("%s %s last=%.5f bid=%.5f ask=%.5f",
                f.Class, f.Symbol, f.Price, f.Bid, f.Ask)
        case "error":
            log.Printf("server error: %s", f.Code)
        }
    }
}

Keep the stream alive in production#

Three decisions in that code do the production work.

First, the ping loop. The server closes any connection that sends nothing for 90 seconds, so the client pings every 45. Skip this and the stream works fine on your desk, where you're constantly reconnecting while testing, then dies in the first quiet stretch after deployment.

Second, reconnection is a plain loop around stream(), with the backoff doubling from 1 second to a 30-second cap and resetting after any run that survived at least a minute. The reset matters. Without it, one flaky hour leaves the client permanently reconnecting at the slowest cadence long after the network recovers.

Third, resubscription comes free because the subscribe calls live inside stream(). Subscriptions don't survive a reconnect; a client that dials again without resubscribing gets a perfectly healthy, perfectly silent connection. On-chain streams add failure modes of their own on top of these; Streaming on-chain DEX swaps over WebSocket without reconnect bugs covers those.

One more behavior worth knowing: on every subscribe the server first sends the last cached value per symbol, then live updates. After a reconnect this is what repaints your state immediately instead of leaving it blank until the next trade.

The official Go SDK#

If you'd rather not maintain frame structs by hand, SiftingIO ships an official Go client: go get github.com/siftingio/sdk-go@latest (Go 1.23+). It's generated from the same OpenAPI and AsyncAPI specs as the Python and JavaScript SDKs, so method names and data shapes match across languages, and it covers both REST and the WebSocket stream. The hand-rolled version above is still worth understanding: when a stream misbehaves at 3 a.m., knowing what the frames look like on the wire is the difference between a fix and a guess.

Common pitfalls#

The case-insensitive JSON trap. Tick frames distinguish bid price from bid size only by letter case, b versus B. Go's encoding/json falls back to case-insensitive matching when a key has no exact struct-tag match, so a struct that only maps b will also receive the B key, and whichever appears later in the frame wins. Your EURUSD bid quietly becomes 510300 instead of 1.16925. Map every case pair explicitly, as the struct above does.

The cached first frame. The snapshot sent on subscribe is the last known value, and in a quiet market its t can be seconds or minutes old. Alerting logic that treats every tick as a fresh print will fire on it, and after a reconnect it can look like the price jumped backward. Compare t against your own clock before acting on a frame.

Subscription limits fail quietly. Exceeding your tier's cap returns { "f":"error", "code":"max_subscriptions" } as a frame on the open connection, and nothing else happens: no close, no HTTP status. If your read loop only handles tick frames, the over-limit symbols simply never arrive. Log error frames, and check max_subs in the auth ack before subscribing.

The free tier includes 1 WebSocket connection and 5 symbol subscriptions, so everything in this post (four symbols across two products) runs without paying, and no credit card is required to get a key. Start building free

Keep reading

Related posts

Golang WebSocket market data: stream real-time forex and crypto prices · SiftingIO