MetalAPI
XAU3,997.89−1.54%XAG53.936−1.51%XPT1,554.47−0.83%XPD1,047.03−0.44%XCU6.5080+2.29%WTI103.27+3.82%BRENT111.70−1.30%EUR1.120400−0.01%GBP1.337500−0.01%JPY0.0063761+0.14%CHF1.213445−0.25%TRY0.020125−0.12%BTC80,329.75−0.55%ETH2,329.36−1.13%
All articles
StreamingJavaScriptTutorial

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.

By MetalAPI Team··10 min read

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:

PlanUpdate intervalPolling at that intervalOne stream, always open
Pro1 min44,640 requestsabout 45,400 requests
Ultra, Business15 s178,560 requestsabout 45,400 requests
Enterprise5 s535,680 requestsabout 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

http
GET https://metalapi.com/api/v1/stream?base=USD&currencies=XAU,XAG,XPT,XPD

The 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.

PlanPrice updates on the streamRequests per month
ProEvery 60 s100,000
UltraEvery 15 s500,000
BusinessEvery 15 s500,000
EnterpriseEvery 5 s1,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 reconnect event 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 error event 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 104 errors.

What the server sends

This is the beginning of a stream as raw text (illustrative prices):

text/event-stream
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}}
EventDataWhat to do
pricesA full snapshot: timestamp, date, base, rates, plus unit when it is not troy ouncesReplace the prices you display
reconnect{"reason":"max connection time reached"}Nothing; reconnect when the stream closes
errorcode, type and info, with the same codes as the REST APIStop or back off, depending on the code
: ping …A comment line every 15 secondsParsers 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.

Browser
// 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&currencies=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
// 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&currencies=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:

watch.ts
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:

SituationSignalResponse
Hourly rotationreconnect event, then the stream endsReconnect after the server's retry delay (5 s)
Network dropRead error, or the stream ends without reconnectReconnect with exponential backoff and jitter, at most 60 s apart
Silent connectionNo data and no heartbeat for 45 sAbort and reconnect; three heartbeats were missed
Temporary refusal104, 500, 503Back off and retry
Permanent refusal101, 102, 103, 105, 106, 107Stop 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
// 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:

ticker.html
<!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: no and Cache-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-stream responses 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-live region; 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