sifting/io
Forex & Crypto
6 min readSiftingIO Team

Real-time FX and crypto quotes: REST snapshots and WebSocket streams

How to read real-time FX and crypto quotes from SiftingIO: REST bid/ask snapshots for EURUSD and BTCUSD, WebSocket tick streams, and the pitfalls in between.

Real-time FX and crypto quotes: REST snapshots and WebSocket streams

Real-time FX and crypto quotes power a price ticker on a dashboard, a risk gauge on a treasury monitor, and a fill-quality check inside a strategy loop. All three need the same thing: the current bid, ask, and last trade for a symbol, refreshed often enough to stay useful and not so often that the data feed becomes the bottleneck. For FX pairs like EURUSD and crypto pairs like BTCUSD, two delivery shapes cover the field. A REST snapshot answers a single point-in-time read. A WebSocket subscription pushes every quote change as it happens. This post walks through both, with one credential and one field convention across asset classes.

What a REST snapshot returns#

Quotes and trades live on separate endpoints, because they answer different questions. The quote is what the market is willing to do next; the trade is what it just did. GET /v1/last/quote/{venue}/{symbol} returns the best bid and ask with sizes. GET /v1/last/trade/{venue}/{symbol} returns the most recent trade. The venue segment is one of forex, crypto, stocks, commodities, or dex, and authentication is the X-API-Key header (a ?api_key= query parameter also works, though the header keeps the key out of URL-logging middleware).

Pulling the EURUSD quote:

curl -H "X-API-Key: $SIFTING_KEY" \
  "https://api.sifting.io/v1/last/quote/forex/EURUSD"

The response is compact:

{
  "s": "EURUSD",
  "b": "1.16925",
  "B": "1500000",
  "a": "1.16943",
  "A": "1200000",
  "t": 1778019852426
}

Six fields, all single letters. s is the symbol, b and B are bid price and bid size, a and A are ask price and ask size, and t is an int64 Unix epoch timestamp in milliseconds. The types matter as much as the names: every price and size arrives as a quoted string, and t is the only bare number in the payload. Decimal prices don't round-trip through IEEE 754 floats losslessly, so the API hands over the exact decimal text and leaves parsing to the client. For display and spread math a float() or Number() conversion is fine; for accounting-grade comparisons, feed the string into a decimal type instead.

There's no mid field and no precomputed spread. Both are one line of arithmetic, which keeps the choice of convention (midpoint, bid for marking a long, ask for a short) explicit in the caller:

import os
import requests

KEY = os.environ["SIFTING_KEY"]

def quote(venue, symbol):
    r = requests.get(
        f"https://api.sifting.io/v1/last/quote/{venue}/{symbol}",
        headers={"X-API-Key": KEY},
        timeout=5,
    )
    r.raise_for_status()
    q = r.json()
    bid, ask = float(q["b"]), float(q["a"])
    return {"symbol": q["s"], "bid": bid, "ask": ask,
            "mid": (bid + ask) / 2, "spread": ask - bid, "t_ms": q["t"]}

for venue, sym in [("forex", "EURUSD"), ("crypto", "BTCUSD")]:
    q = quote(venue, sym)
    print(f"{q['symbol']:8s} bid={q['bid']} ask={q['ask']} mid={q['mid']}")

The crypto call uses the same shape with a different venue segment. BTCUSD is the catalog symbol: token plus USD, aggregated across multiple independent centralized venues. Venue-suffixed spellings like BTCUSDT aren't in the catalog, and FX pairs are strictly six uppercase characters with no separator, so EUR/USD is a format error rather than a coverage gap.

One field convention across venues means a dashboard that already renders FX quotes renders crypto quotes without a parser branch: one TypedDict in Python, one interface in TypeScript, one set of dataframe columns.

When to switch to the WebSocket stream#

Polling a snapshot twice a second burns request budget without producing fresh information between polls. FX majors and the busiest crypto pairs update many times per second, so a polling loop either misses ticks or hammers the rate limit. The stream removes both problems by pushing changes over one long-lived connection.

Connect to wss://stream.sifting.io/ws/v1?key=$SIFTING_KEY. The query parameter is the standard way to authenticate; the alternative is connecting bare and sending {"op":"auth","key":"sft_..."} as the first frame. One naming detail catches almost everyone: the WebSocket uses product codes that differ from the REST venue slugs. Forex is fx and centralized crypto is cex, so subscribing with "product":"forex" returns an unknown_product error.

import WebSocket from "ws";

const ws = new WebSocket(
  `wss://stream.sifting.io/ws/v1?key=${process.env.SIFTING_KEY}`
);

let keepalive;

ws.on("open", () => {
  ws.send(JSON.stringify({ op: "subscribe", product: "fx", symbols: ["EURUSD", "USDJPY"] }));
  ws.send(JSON.stringify({ op: "subscribe", product: "cex", symbols: ["BTCUSD", "ETHUSD"] }));
  keepalive = setInterval(() => ws.send(JSON.stringify({ op: "ping" })), 30_000);
});

ws.on("message", (raw) => {
  const msg = JSON.parse(raw);
  if (msg.f !== "tick") return;
  const bid = Number(msg.b);
  const ask = Number(msg.a);
  console.log(msg.s, `${bid}/${ask}`, "last=" + Number(msg.p));
});

ws.on("close", () => clearInterval(keepalive));

Tick frames are marked f: "tick" and carry s, p and P for last trade price and size, b/B and a/A for the top of book, t in epoch milliseconds, and class, which labels the market. The string typing from REST applies here too, hence the Number() calls. On subscribe, the server first emits the last cached value for each symbol, so the client paints immediately before live updates flow.

A workable production pattern combines both transports. Call /v1/last/quote for every subscribed symbol to render the table instantly, open the socket for live updates, and pull the snapshot again whenever the socket drops while a reconnect (exponential backoff from 1 second to a 60-second cap, with jitter) is in flight. Without that snapshot-on-reconnect step, a displayed quote can be many seconds stale and nothing in the message stream will reveal it.

Common pitfalls#

String prices in comparisons. In JavaScript, msg.b > msg.a compares lexicographically and can pass casual testing anyway: "9.5" > "10.2" evaluates to true. Convert at the parse boundary, once, and keep everything downstream numeric. Python fails louder, raising a TypeError the moment a quoted price hits arithmetic, which is the friendlier failure.

The 90-second idle close. The server closes any connection that sends no client frames for 90 seconds, and inbound ticks don't reset that timer. A subscriber receiving thousands of ticks a minute in total silence still gets cut off at the 90-second mark, which looks exactly like a flaky network. Send {"op":"ping"} at least every 60 seconds (the sample uses 30), expect {"f":"pong"} back, and clear the interval on close so reconnects don't stack timers.

Mistaking the last trade for the executable level. p is the most recent print. For a thin crypto pair the last trade can be minutes old while the bid and ask are current and tight. Using p for a tape display is fine; using it as a portfolio mark or a pre-trade sanity check is wrong, and the quote endpoint returns both sides of the book for exactly that reason.

Rate limits and headers worth watching#

Every REST response carries X-RateLimit-Limit (burst capacity) and X-RateLimit-Remaining (tokens left). There is no reset-time header to schedule around, so watch the remaining count rather than guessing at windows. When the limit trips, the response is a 429 with error code rate_limit_exceeded and a Retry-After header in seconds; sleep for that long before retrying. A poller that sees Retry-After regularly is a workload that belongs on the WebSocket, where per-tier connection and subscription allowances replace per-call budgets.

The full field reference, product codes, and error list are in the documentation. Read the docs

Keep reading

Related posts