Streaming Real-time Metal Prices with Server-Sent Events
Stream real-time gold and silver prices with Server-Sent Events: the /stream endpoint, its events, EventSource vs Node.js, reconnects, quotas and a live ticker.
Streaming or polling?
Polling asks for prices on a timer. Streaming keeps one HTTP connection open and lets the server push new prices when they are published. MetalAPI's stream uses Server-Sent Events (SSE): an ordinary HTTP response with the text/event-stream content type that stays open and delivers named events. It needs no WebSocket library, passes through most proxies, and browsers support it natively with EventSource.
Whether streaming pays off depends on your plan's update interval. A stream costs one request when it connects and one request per minute while it stays open, no matter how often prices change. Polling costs one request per call. For a single client watching prices around the clock in a 31-day month:
| Plan | Update interval | Polling at that interval | One stream, always open |
|---|---|---|---|
| Pro | 1 min | 44,640 requests | about 45,400 requests |
| Ultra, Business | 15 s | 178,560 requests | about 45,400 requests |
| Enterprise | 5 s | 535,680 requests | about 45,400 requests |
The stream figure includes one request per hourly reconnect, explained below. On Pro, streaming and per-minute polling cost about the same; the stream's advantage is that new prices arrive as soon as they are published, without a polling loop to tune. On Ultra, Business and Enterprise, the stream is also far cheaper than polling at full speed.
Streaming is the wrong tool when a price only has to be right once per page view, for nightly jobs, and for threshold checks. Use a cached /latest call for the first two and price alerts with webhooks for the third.
The /stream endpoint
GET https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPDThe stream accepts the same base, currencies (or symbols) and unit parameters as /latest (see the latest endpoint docs), and the same authentication: api_key in the query string, an Authorization: Bearer header or an X-API-Key header. Parameters are validated before the stream opens, so an unknown symbol or unit returns a normal JSON error instead of an empty stream.
| Plan | Price updates on the stream | Requests per month |
|---|---|---|
| Pro | Every 60 s | 100,000 |
| Ultra | Every 15 s | 500,000 |
| Business | Every 15 s | 500,000 |
| Enterprise | Every 5 s | 1,500,000 |
Lower plans get error 106 (HTTP 403) with the message that realtime streaming is available on Pro plans and above. A few limits apply to each connection:
- At most 5 concurrent streams per API key. A sixth connection is refused with
104(HTTP 429). - Connections last up to 60 minutes. After an hour the server sends a
reconnectevent and closes the stream, and the client connects again. - The key is re-checked every minute. If it has been revoked or the monthly quota is used up, the server sends an
errorevent and closes the stream. - Connecting counts against the per-minute rate limit like any other request, so a client stuck in a fast reconnect loop starts receiving
104errors.
What the server sends
This is the beginning of a stream as raw text (illustrative prices):
retry: 5000
event: prices
data: {"timestamp":1792834892,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002501319,"USDXAU":3997.89,"XAG":0.0185392857,"USDXAG":53.94}}
: ping 1792834907000
event: prices
data: {"timestamp":1792834952,"date":"2026-10-24","base":"USD","rates":{"XAU":0.0002500812764,"USDXAU":3998.7,"XAG":0.01852194851,"USDXAG":53.99}}| Event | Data | What to do |
|---|---|---|
prices | A full snapshot: timestamp, date, base, rates, plus unit when it is not troy ounces | Replace the prices you display |
reconnect | {"reason":"max connection time reached"} | Nothing; reconnect when the stream closes |
error | code, type and info, with the same codes as the REST API | Stop or back off, depending on the code |
: ping … | A comment line every 15 seconds | Parsers ignore it; use it as a sign of life |
Three properties shape the client code. Every prices event is a complete snapshot of the symbols you requested, never a delta, so a client that reconnects is up to date again with the first event. That first event arrives immediately after connecting. After it, an event is sent only when rates change: the server checks at your plan's interval and stays quiet when nothing moved, which is normal for metals over the weekend. Silence with heartbeats is fine; silence without heartbeats means the connection is dead.
Events carry no id, so Last-Event-ID plays no role. There is nothing to replay, because each snapshot replaces everything before it. The rates keys follow the REST format: USDXAU is the price of one troy ounce of gold in US dollars, and XAU is its inverse.
In the browser with EventSource
EventSource handles the connection, the parsing and the reconnects for you. It cannot send custom headers, so the key goes in the query string. Use a key that is restricted to your domains: the browser sends an Origin header with the request, and a restricted key refuses streams opened from other sites with error 107.
// Use a key restricted to your domains: it is visible to anyone who opens the page.
const url = "https://metalapi.com/api/v1/stream?api_key=YOUR_RESTRICTED_KEY&base=EUR¤cies=XAU,XAG&unit=gram";
const FATAL = [101, 102, 103, 105, 106, 107];
const es = new EventSource(url);
es.addEventListener("prices", (e) => {
const { timestamp, base, rates } = JSON.parse(e.data);
document.querySelector("#gold").textContent = rates[`${base}XAU`].toFixed(2);
document.querySelector("#silver").textContent = rates[`${base}XAG`].toFixed(3);
document.querySelector("#updated").textContent = new Date(timestamp * 1000).toLocaleTimeString();
});
es.addEventListener("error", (e) => {
if (e instanceof MessageEvent && e.data) {
// Sent by MetalAPI before it closes the stream: {"code":105,"type":"quota_exceeded",...}
const err = JSON.parse(e.data);
console.warn("MetalAPI stream error", err.code, err.info);
if (FATAL.includes(err.code)) es.close(); // reconnecting would fail again
} else if (es.readyState === EventSource.CLOSED) {
console.warn("Stream refused; check the key and plan with /usage");
}
// Otherwise the connection dropped and the browser reconnects by itself.
});The error listener receives two different things. A connection problem arrives as a plain Event, and EventSource reconnects on its own after the 5 seconds set by the server's retry line. An error sent by MetalAPI arrives as a MessageEvent whose data holds the JSON error, and the server closes the stream right after it. For codes that a retry cannot fix, call close(); otherwise the browser keeps reconnecting and keeps being refused.
If the first response is already an HTTP error, for example 106 because the plan does not include streaming, EventSource does not expose the response body and does not retry: readyState becomes CLOSED. The free usage endpoint shows the key's plan when you need to find out why.
Direct browser streams suit internal dashboards with a handful of users. Every open tab is its own stream: it costs a request per minute and counts towards the limit of 5 concurrent streams per key. For a public page, run one stream on your server and fan it out, as the ticker below does.
In Node.js with fetch
On the server, the key can go in a header and you control reconnects precisely. Node.js 18 and newer include fetch with streaming response bodies, so a complete client fits in one module without dependencies:
// metal-stream.ts — Node.js 18+, no dependencies
export interface Prices {
timestamp: number;
date: string;
base: string;
unit?: string;
rates: Record<string, number>;
}
const STREAM_URL = "https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPD";
const FATAL = new Set([101, 102, 103, 105, 106, 107]); // retrying will not help
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
class FatalStreamError extends Error {}
export async function streamPrices(onPrices: (p: Prices) => void): Promise<never> {
let retryMs = 5_000; // the server sends `retry: 5000`
let failures = 0;
for (;;) {
const ctrl = new AbortController();
let watchdog: ReturnType<typeof setTimeout> | undefined;
const kick = () => {
clearTimeout(watchdog);
watchdog = setTimeout(() => ctrl.abort(), 45_000); // 3 missed heartbeats
};
try {
const res = await fetch(STREAM_URL, {
headers: { Authorization: `Bearer ${process.env.METALAPI_KEY}`, Accept: "text/event-stream" },
signal: ctrl.signal,
});
if (!res.ok || !res.body) {
const body = await res.json().catch(() => null);
const code = body?.error?.code;
throw FATAL.has(code) ? new FatalStreamError(`${code}: ${body.error.info}`) : new Error(`HTTP ${res.status}`);
}
kick();
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
kick();
buffer += decoder.decode(value, { stream: true }).replace(/\r\n?/g, "\n");
let end: number;
while ((end = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
let event = "message";
const data: string[] = [];
for (const line of block.split("\n")) {
if (!line || line.startsWith(":")) continue; // heartbeat comment
const i = line.indexOf(":");
const field = i === -1 ? line : line.slice(0, i);
const val = i === -1 ? "" : line.slice(i + 1).replace(/^ /, "");
if (field === "event") event = val;
else if (field === "data") data.push(val);
else if (field === "retry" && /^\d+$/.test(val)) retryMs = Number(val);
}
if (event === "prices") {
failures = 0;
onPrices(JSON.parse(data.join("\n")));
} else if (event === "error") {
const err = JSON.parse(data.join("\n"));
if (FATAL.has(err.code)) throw new FatalStreamError(`${err.code}: ${err.info}`);
}
// `reconnect`: the server closes the connection next; the loop reconnects.
}
}
} catch (err) {
if (err instanceof FatalStreamError) throw err;
failures++;
console.warn("stream interrupted:", (err as Error).message);
} finally {
clearTimeout(watchdog);
}
// 5 s after a normal close, then back off up to a minute while it keeps failing.
const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
}
}Use it from any script or service:
import { streamPrices } from "./metal-stream";
streamPrices((p) => {
console.log(new Date(p.timestamp * 1000).toISOString(), "gold", p.rates.USDXAU, "silver", p.rates.USDXAG);
}).catch((err) => {
console.error("Stream stopped:", err.message); // a FATAL code: fix the key, plan or quota
process.exit(1);
});The parser follows the SSE format: events are separated by a blank line, event: names the event, data: lines are joined with newlines, and lines that start with a colon are comments. Any SSE client library that lets you set request headers works as well, as long as it applies the reconnect rules in the next section.
Reconnects and failure handling
The module above follows a short set of rules that belong in any stream client:
| Situation | Signal | Response |
|---|---|---|
| Hourly rotation | reconnect event, then the stream ends | Reconnect after the server's retry delay (5 s) |
| Network drop | Read error, or the stream ends without reconnect | Reconnect with exponential backoff and jitter, at most 60 s apart |
| Silent connection | No data and no heartbeat for 45 s | Abort and reconnect; three heartbeats were missed |
| Temporary refusal | 104, 500, 503 | Back off and retry |
| Permanent refusal | 101, 102, 103, 105, 106, 107 | Stop and alert a person |
The watchdog matters more than it looks. A connection that dies without a TCP reset, for example behind a NAT gateway or a load balancer, can look open for minutes before the operating system notices. With a heartbeat every 15 seconds, the client can detect it within 45.
Watch out for reconnect loops: every successful connection costs a request, and every attempt counts against the per-minute rate limit. Exponential backoff limits the damage when something keeps failing.
Running out of quota (105) deserves its own alert. The stream reports it as an error event at the next per-minute check, and reconnecting does not help until the quota resets on the 1st (UTC) or the plan is upgraded.
Building a live ticker
A public price ticker should not open a MetalAPI stream for every visitor. Instead, a small relay holds one stream on the server and pushes each snapshot to every browser through an SSE endpoint of its own. Your usage then depends on the number of relays, not the number of visitors, and the key never leaves the server:
// relay.ts — one MetalAPI stream on the server, any number of browsers.
import { readFileSync } from "node:fs";
import http from "node:http";
import { streamPrices } from "./metal-stream";
const page = readFileSync(new URL("./ticker.html", import.meta.url));
const clients = new Set<http.ServerResponse>();
let latest: string | null = null;
const broadcast = (chunk: string) => {
for (const res of clients) res.write(chunk);
};
streamPrices((p) => {
latest = JSON.stringify({ timestamp: p.timestamp, base: p.base, rates: p.rates });
broadcast(`event: prices\ndata: ${latest}\n\n`);
}).catch((err) => {
console.error("upstream stream stopped:", err.message);
process.exit(1);
});
setInterval(() => broadcast(": ping\n\n"), 15_000);
http.createServer((req, res) => {
if (req.url === "/") return void res.writeHead(200, { "Content-Type": "text/html" }).end(page);
if (req.url !== "/prices") return void res.writeHead(404).end();
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
});
res.write("retry: 3000\n\n");
if (latest) res.write(`event: prices\ndata: ${latest}\n\n`); // new viewers get prices at once
clients.add(res);
req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));The relay sends the latest snapshot to each new viewer immediately, passes on only the timestamp, base and rates, and sends heartbeats of its own. The page needs a few lines to render the ticker and colour each price by its direction since the previous update:
<!doctype html>
<meta charset="utf-8">
<title>Live metal prices</title>
<style>
.ticker { display: flex; flex-wrap: wrap; gap: 1.5rem; font: 15px/1.4 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
.up { color: #15803d; }
.down { color: #dc2626; }
</style>
<div id="ticker" class="ticker">Loading prices…</div>
<script>
const NAMES = { XAU: "Gold", XAG: "Silver", XPT: "Platinum", XPD: "Palladium" };
const previous = {};
const ticker = document.getElementById("ticker");
new EventSource("/prices").addEventListener("prices", (e) => {
const { base, rates, timestamp } = JSON.parse(e.data);
const money = new Intl.NumberFormat(undefined, { style: "currency", currency: base });
ticker.replaceChildren(
...Object.entries(NAMES).map(([code, name]) => {
const price = rates[base + code];
const el = document.createElement("span");
if (previous[code] !== undefined && price !== previous[code]) {
el.className = price > previous[code] ? "up" : "down";
}
previous[code] = price;
el.textContent = `${name} ${money.format(price)}`;
return el;
}),
);
ticker.title = `Prices as of ${new Date(timestamp * 1000).toLocaleString()}`;
});
</script>Start it with METALAPI_KEY=mk_live_... npx tsx relay.ts and open http://localhost:3000. The ticker compares each price with the previous event. To show a daily change instead, fetch today's open once from the OHLC endpoint and compare every new price with it.
Production notes
- Turn off proxy buffering. MetalAPI sends
X-Accel-Buffering: noandCache-Control: no-transform. If nginx or another proxy sits in front of your relay, disable response buffering for the SSE route (proxy_buffering off;in nginx), or events arrive in bursts. - Don't compress the event stream. Compression middleware can hold small chunks back until its buffer fills. Exclude
text/event-streamresponses from it. - Run the relay as a long-lived process. Serverless functions usually have execution time limits, which makes them a poor fit for holding a stream open around the clock.
- Show the data time. Display the event's
timestamp, not the time the browser received it, so viewers can tell a quiet weekend market from a stalled feed. - Keep screen readers calm. Don't put a fast-updating ticker in an
aria-liveregion; announcing every update is noisy. - Budget the quota. One always-open stream uses about 45,400 requests a month, so a Pro plan has room for two plus regular API calls. Check usage for free with the usage endpoint.
The API docs list the stream's parameters, and the pricing page shows which plans include it.
Try it with your own data
Create a free account and make your first request in under two minutes. 100 requests a month, no credit card.
Keep reading
Gold Price API: The Complete Developer Guide
How to use a gold price API: get a key, call it from curl, JavaScript and Python, read the rates correctly, and handle history, errors, quotas and caching.
How to Add a Live Gold Price Widget to Your Website
Add a live gold price widget to WordPress, Shopify or plain HTML: build it in the MetalAPI dashboard, choose symbols, layout and theme, then paste two lines.
Gold Karat Prices Explained: 24K, 22K, 18K and How to Calculate Them
What 24K, 22K and 18K mean, how fineness marks like 916 and 750 relate, how to calculate karat gold prices per gram and how to get them from a gold price API.