A currency watchlist usually starts with three rows: EURUSD, GBPUSD and USDJPY. Fetching each pair separately works, but every refresh becomes three requests. Add more pairs and the request count grows with the list.
The SiftingIO Forex API can return those pairs as JSON in one snapshot request. This guide builds a small Node.js client that preserves your chosen row order, keeps price strings intact and leaves a visible row when a pair is missing. It also explains what the response timestamps tell you, so a successful refresh does not become a misleading “live” badge.
You will need a SiftingIO API key with Forex access. Create a free account, then keep the key in your server environment. The Forex data API overview covers the wider product, including historical bars and streaming.
Request several currency pairs in one JSON response#
Use GET /v1/snapshot/forex with a comma-separated symbols parameter. Send your key in X-API-Key and request gzip encoding. With curl, --compressed decodes the compressed response for you:
curl --compressed \
-H "X-API-Key: $SIFTINGIO_API_KEY" \
-H "Accept-Encoding: gzip" \
"https://api.sifting.io/v1/snapshot/forex?symbols=EURUSD,GBPUSD,USDJPY"
The endpoint accepts up to 250 distinct symbols per call. Specifying the list keeps the response focused; omitting it requests the full market. The snapshot endpoint reference documents the complete envelope and error responses.
One batch is a convenient transport format. It does not mean that every currency pair was observed simultaneously, or that the returned prices are offers you can trade against. SiftingIO produces aggregated reference data; a broker's executable quote can differ.
These are the fields the watchlist will use:
| JSON field | Use in the watchlist | Detail to preserve |
|---|---|---|
data[].s | Match a row to a currency pair | Use the symbol as the key, not the array position. |
data[].p | Display the reference price | It is a decimal string. |
data[].b, data[].a | Show reference bid and ask | These are also strings, not executable broker commitments. |
data[].t | Label the aggregate tick time | Unix milliseconds; this is not the original upstream quote time. |
meta.as_of | Label when the response was assembled | A response timestamp does not certify the age of every underlying input. |
meta.count | Count returned entries | It can be smaller than the number of pairs requested. |
The response also has size fields. This tutorial leaves them out of the display because they should not be presented as observed market-wide FX volume or executable liquidity.
Build the watchlist in JavaScript on your server#
The following script uses the built-in fetch API in Node.js. Save it as forex-watchlist.mjs and run it with Node.js 22 or newer. Set SIFTINGIO_API_KEY in your server or local shell environment first; do not paste a real key into source code.
const requested = ["EURUSD", "GBPUSD", "USDJPY"];
const symbols = [...new Set(requested.map(s => s.trim().toUpperCase()).filter(Boolean))];
if (symbols.length === 0 || symbols.length > 250) {
throw new Error("Choose between 1 and 250 distinct currency pairs");
}
const key = process.env.SIFTINGIO_API_KEY;
if (!key) throw new Error("Set SIFTINGIO_API_KEY in the server environment");
const url = new URL("https://api.sifting.io/v1/snapshot/forex");
url.searchParams.set("symbols", symbols.join(","));
const response = await fetch(url, {
headers: {
"X-API-Key": key,
"Accept-Encoding": "gzip",
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Forex snapshot failed: HTTP ${response.status}`);
}
const snapshot = await response.json();
if (!Array.isArray(snapshot.data)) {
throw new Error("Expected a snapshot data array");
}
const bySymbol = new Map(snapshot.data.map(tick => [tick.s, tick]));
const rows = symbols.map(symbol => {
const tick = bySymbol.get(symbol);
return {
symbol,
price: tick?.p ?? null,
bid: tick?.b ?? null,
ask: tick?.a ?? null,
tickTimeMs: tick?.t ?? null,
status: tick ? "Returned" : "Not returned",
};
});
console.log("Response assembled:", snapshot.meta?.as_of ?? "Not provided");
console.table(rows);
Run node forex-watchlist.mjs. Node's fetch decodes the gzip body before response.json() parses it. The ten-second timeout bounds this example request; it is an application choice, not an API latency promise.
The symbol map matters. If USDJPY is absent, its row stays in the table with null values and “Not returned”. GBPUSD cannot accidentally move into USDJPY's place. This also preserves the order you chose rather than depending on the API's array order. The length check prevents an empty selection from requesting the full market.
“Returned” means an entry exists. It does not validate the price, prove source freshness or authorize a trade. If you add a browser interface, validate the fields it consumes and render nulls as “Unavailable”, never as a zero price. Keep any previous value explicitly labeled as a previous value.
This example preserves decimal strings because it only displays prices. For arithmetic, use a deliberate numeric policy rather than relying on JavaScript's automatic coercion: "1.10" + "0.01" concatenates text. The guide to parsing JSON numbers and decimal prices covers that separate problem.
Check the missing-pair case before adding a UI#
A batch can succeed without returning every requested pair. A well-formed pair with no current publishable record can be omitted, and an empty data array is possible. That differs from a malformed symbol, which rejects the entire request.
To test without a key, keep the symbol setup and length check at the top. Replace everything from const key = ... through the snapshot.data array check with this fixture, and keep const bySymbol = ... and the output code below it. These are invented values for testing the client, not a captured API response. The size fields are intentionally omitted.
const snapshot = {
data: [
{ s: "GBPUSD", p: "1.30000", b: "1.29990", a: "1.30010", t: 1792411200000 },
{ s: "EURUSD", p: "1.10000", b: "1.09990", a: "1.10010", t: 1792411200000 },
],
meta: { venue: "forex", count: 2, as_of: "2026-10-19T12:00:01Z" },
};
The rows should remain in the requested order:
| Pair | Reference price | Status |
|---|---|---|
| EURUSD | 1.10000 | Returned |
| GBPUSD | 1.30000 | Returned |
| USDJPY | Unavailable | Not returned |
Also try data: []. All three rows should remain, with missing prices. This is useful for a watchlist: removing a user's selection would hide the difference between an unavailable value and a pair they never requested.
Keep response time separate from market freshness#
Display meta.as_of as “Response assembled”, and t as “Aggregate tick time”. Neither should be relabeled “Broker quote time”. The snapshot does not expose a separate original-provider timestamp for each input, and a recent aggregate tick alone cannot prove that every source input is equally recent.
A weekend refresh needs the same care. A reachable API and a successful HTTP response do not imply that the FX market is trading. Keep market-session status separate from transport status, and use the market hours reference where relevant to your application. Avoid a green “Live” indicator driven only by the success of fetch().
| Situation | What the client should do |
|---|---|
200, fewer entries than requested | Keep every requested row and mark missing entries. |
200, empty data | Show an unavailable state for the list; do not invent prices. |
400 | Correct symbol syntax or reduce an oversized list before sending it again. |
401 or 403 | Check the key or the account's Forex access; repeated retries will not fix permissions. |
406 | Include Accept-Encoding: gzip and use a client that decodes it. |
429 | Check Retry-After and whether the response reports a rate limit or monthly quota before trying again. |
| Timeout or network failure | Show that refresh failed; distinguish retained values from a new result. |
The script stops on HTTP errors so they remain visible during setup. For a recurring production job, add bounded retries and pacing using the API rate-limit guide. Do not start another refresh while the previous one is still running.
Put one refresh behind the whole watchlist#
Keep the SiftingIO key on your server. A public environment variable bundled into frontend JavaScript is still public, and CORS does not make an exposed key secret. Your backend can fetch an entitled snapshot and return only the fields your interface needs. The web-app integration guide shows this boundary in more detail.
Batching changes the request arithmetic: three pairs fetched separately require three HTTP calls per refresh; the same three pairs in this snapshot require one. That is a count of requests, not a promise about your subscription's total allowance. Choose the refresh interval from your plan limits and the screen's needs. For continuously updating displays, consider the REST and WebSocket tradeoffs rather than polling faster by default.
If you want a ready-made dashboard instead of writing a frontend, the Forex API in Grafana tutorial covers that route. For this Node watchlist, start with three pairs, check the missing-row case, then expand the selection. You can get a free API key and use the snapshot reference alongside the example.



