A live bid and ask price API looks trivial to consume until the first bug report arrives. A dashboard renders NaN. A spread alert fires on every tick. A Python script dies with TypeError: unsupported operand type(s) for +: 'float' and 'str'. The cause is almost always the same: the client assumed prices arrive as JSON numbers, and in the SiftingIO API they don't. Prices and sizes are quoted strings by design.
This post builds a correct quote client for EURUSD and BTCUSD twice: once against the REST snapshot endpoint, once against the WebSocket stream. Every field name and type below is the one the API actually returns.
Fetching the live bid and ask over REST#
The snapshot endpoint is GET /v1/last/quote/{venue}/{symbol}, where the venue is one of stocks, crypto, forex, commodities, or dex. Authentication is the X-API-Key header. A ?api_key= query parameter is the documented alternative, but the header keeps the key out of URL-logging middleware.
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. Two type details matter more than anything else on this page. Every price and size is a string, so "b":"1.16925" needs an explicit float() or Number() before any math. And t is the only unquoted number in the payload.
Why strings? Decimal prices don't round-trip through IEEE 754 floats losslessly, so the API hands you the exact decimal text and leaves the parsing decision to you. For display and spread math a float is fine; for accounting-style comparisons, feed the string into a decimal type instead.
There's no mid field and no precomputed spread, so compute both yourself:
import os
import requests
KEY = os.environ["SIFTING_KEY"]
BASE = "https://api.sifting.io/v1/last"
def quote(venue, symbol):
r = requests.get(
f"{BASE}/quote/{venue}/{symbol}",
headers={"X-API-Key": KEY},
timeout=5,
)
r.raise_for_status()
q = r.json()
bid = float(q["b"])
ask = float(q["a"])
return {
"symbol": q["s"],
"bid": bid,
"ask": ask,
"mid": (bid + ask) / 2,
"spread": ask - bid,
"t_ms": q["t"],
}
print(quote("forex", "EURUSD"))
print(quote("crypto", "BTCUSD"))
Symbol format is strict. FX pairs are six uppercase characters with no separator: EURUSD works, EUR/USD is a format error. Crypto symbols are the token plus USD (BTCUSD, ETHUSD, SOLUSD), aggregated across centralized venues. Venue-suffixed spellings like BTCUSDT don't exist in this catalog.
Last trade is a separate endpoint#
A quote and a trade answer different questions. The quote is what the market is willing to do next; the trade is what it just did. GET /v1/last/trade/{venue}/{symbol} returns s, p (last trade price), P (trade size), and t, with the same string typing on p and P.
curl -H "X-API-Key: $SIFTING_KEY" \
"https://api.sifting.io/v1/last/trade/crypto/BTCUSD"
If a UI shows last price alongside bid and ask, that's two REST calls per symbol per refresh. That cost is one reason to move to the stream once you're watching more than a handful of symbols continuously.
Streaming ticks over WebSocket#
Connect to wss://stream.sifting.io/ws/v1?key=$SIFTING_KEY. The query parameter is the preferred way to authenticate. The alternative is connecting bare and sending {"op":"auth","key":"sft_..."} as the first frame; do it within about five seconds or the server closes the connection with auth_timeout.
One naming trap sits right at the subscribe step. The WebSocket uses product codes that differ from the REST venue slugs: forex is fx (REST says forex) and centralized crypto is cex (REST says crypto). 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"] }));
ws.send(JSON.stringify({ op: "subscribe", product: "cex", symbols: ["BTCUSD"] }));
keepalive = setInterval(() => ws.send(JSON.stringify({ op: "ping" })), 30_000);
});
ws.on("close", () => clearInterval(keepalive));
ws.on("message", (raw) => {
const msg = JSON.parse(raw);
if (msg.f !== "tick") return;
const bid = Number(msg.b);
const ask = Number(msg.a);
const last = Number(msg.p);
console.log(msg.class, msg.s, {
bid,
ask,
last,
at: new Date(msg.t).toISOString(),
});
});
Tick frames are marked f: "tick" and carry s, p and P for last price and size, b/B and a/A for the top of book, t in epoch milliseconds, and class, which labels the market (fx, cex, dex, us, or com). The string rule from REST applies here too, hence the Number() calls above. On subscribe, the server first emits the last cached value for each symbol, so the client gets an immediate paint before live updates flow.
The keepalive contract catches many first implementations. The client must send frames: if the server sees no client-to-server traffic for 90 seconds it closes the connection, and inbound ticks do not reset that timer. A subscriber receiving thousands of ticks a minute in total silence still gets cut off at the 90-second mark. Send {"op":"ping"} at least every 60 seconds (the sample uses 30) and expect {"f":"pong"} back.
Common pitfalls#
String prices in comparisons. In JavaScript, msg.b > msg.a compares lexicographically and passes casual testing anyway. "9.5" > "10.2" evaluates to true. Convert at the parse boundary, once, and keep everything downstream numeric. In Python, mixing q["b"] into arithmetic raises immediately, which is the friendlier failure.
Treating ticks as keepalive. The symptom is a WebSocket that dies after exactly 90 seconds of one-way traffic, usually blamed on the network. The fix is a client-side ping interval, cleared on close and restarted on reconnect, as in the sample above.
Polling REST until a 429. Every REST response carries X-RateLimit-Limit and X-RateLimit-Remaining. There is no X-RateLimit-Reset header to schedule around, so watch the remaining count instead of 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.
A workable production pattern combines both transports. Call /v1/last/quote once to render instantly, open the socket for live updates, and fall back to the snapshot if the socket drops while a reconnect is in flight. The full field reference, error codes, and product list are in the documentation. Read the docs



