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 %
Alle Artikel
StreamingJavaScriptTutorial

Echtzeit-Metallpreise mit Server-Sent Events streamen

Gold- und Silberpreise per Server-Sent Events streamen: der /stream-Endpunkt, seine Events, EventSource und Node.js, Reconnects, Kontingent und ein Live-Ticker.

Von MetalAPI Team··10 Min. Lesezeit

Streaming oder Polling?

Beim Polling fragen Sie Preise in festen Zeitabständen ab. Beim Streaming bleibt eine HTTP-Verbindung offen, und der Server schickt neue Preise, sobald sie veröffentlicht werden. Der Stream von MetalAPI nutzt Server-Sent Events (SSE): eine gewöhnliche HTTP-Antwort mit dem Content-Type text/event-stream, die offen bleibt und benannte Events liefert. Dafür ist keine WebSocket-Bibliothek nötig, die Verbindung passiert die meisten Proxys, und Browser unterstützen sie nativ mit EventSource.

Ob sich Streaming lohnt, hängt vom Aktualisierungsintervall Ihres Tarifs ab. Ein Stream kostet eine Anfrage beim Verbindungsaufbau und eine Anfrage pro Minute, solange er offen bleibt – unabhängig davon, wie oft sich die Preise ändern. Polling kostet eine Anfrage pro Aufruf. Für einen einzelnen Client, der die Preise in einem Monat mit 31 Tagen rund um die Uhr beobachtet, ergibt sich:

TarifAktualisierungsintervallPolling in diesem IntervallEin Stream, durchgehend offen
Pro1 min44.640 Anfragenetwa 45.400 Anfragen
Ultra, Business15 s178.560 Anfragenetwa 45.400 Anfragen
Enterprise5 s535.680 Anfragenetwa 45.400 Anfragen

Der Wert für den Stream enthält eine Anfrage pro stündlichem Reconnect, wie weiter unten erklärt. Im Pro-Tarif kosten Streaming und minütliches Polling etwa gleich viel; der Vorteil des Streams ist, dass neue Preise ankommen, sobald sie veröffentlicht werden, ohne dass Sie eine Polling-Schleife abstimmen müssen. In Ultra, Business und Enterprise ist der Stream außerdem deutlich günstiger als Polling mit voller Geschwindigkeit.

Streaming ist das falsche Werkzeug, wenn ein Preis nur einmal pro Seitenaufruf stimmen muss, für nächtliche Jobs und für Schwellenwertprüfungen. Verwenden Sie für die ersten beiden Fälle einen gecachten /latest-Aufruf und für den dritten Preisalarme mit Webhooks.

Der Endpunkt /stream

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

Der Stream akzeptiert dieselben Parameter base, currencies (oder symbols) und unit wie /latest (siehe Dokumentation des latest-Endpunkts) und dieselbe Authentifizierung: api_key im Query-String, einen Authorization: Bearer-Header oder einen X-API-Key-Header. Die Parameter werden geprüft, bevor der Stream geöffnet wird. Ein unbekanntes Symbol oder eine unbekannte Einheit liefert daher einen normalen JSON-Fehler statt eines leeren Streams.

TarifPreisaktualisierungen im StreamAnfragen pro Monat
ProAlle 60 s100.000
UltraAlle 15 s500.000
BusinessAlle 15 s500.000
EnterpriseAlle 5 s1.500.000

Niedrigere Tarife erhalten Fehler 106 (HTTP 403) mit dem Hinweis, dass Echtzeit-Streaming ab dem Pro-Tarif verfügbar ist. Für jede Verbindung gelten einige Grenzen:

  • Höchstens 5 gleichzeitige Streams pro API-Schlüssel. Eine sechste Verbindung wird mit 104 (HTTP 429) abgelehnt.
  • Verbindungen bestehen bis zu 60 Minuten. Nach einer Stunde sendet der Server ein reconnect-Event und schließt den Stream; der Client verbindet sich daraufhin neu.
  • Der Schlüssel wird jede Minute erneut geprüft. Wurde er widerrufen oder ist das Monatskontingent aufgebraucht, sendet der Server ein error-Event und schließt den Stream.
  • Der Verbindungsaufbau zählt zum Limit pro Minute wie jede andere Anfrage. Ein Client, der in einer schnellen Reconnect-Schleife hängt, erhält deshalb bald 104-Fehler.

Was der Server sendet

So beginnt ein Stream als Rohtext (Preise beispielhaft):

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}}
EventDatenWas zu tun ist
pricesEin vollständiger Snapshot: timestamp, date, base, rates, dazu unit, wenn die Einheit nicht Feinunzen istDie angezeigten Preise ersetzen
reconnect{"reason":"max connection time reached"}Nichts; neu verbinden, sobald der Stream schließt
errorcode, type und info, mit denselben Codes wie die REST-APIJe nach Code aufhören oder mit Backoff erneut versuchen
: ping …Eine Kommentarzeile alle 15 SekundenParser ignorieren sie; nutzen Sie sie als Lebenszeichen

Drei Eigenschaften prägen den Client-Code. Jedes prices-Event ist ein vollständiger Snapshot der angeforderten Symbole, niemals ein Delta. Ein Client, der sich neu verbindet, ist daher mit dem ersten Event wieder auf dem aktuellen Stand. Dieses erste Event kommt sofort nach dem Verbindungsaufbau. Danach wird ein Event nur gesendet, wenn sich Kurse ändern: Der Server prüft im Intervall Ihres Tarifs und bleibt still, wenn sich nichts bewegt hat – bei Metallen am Wochenende völlig normal. Stille mit Heartbeats ist in Ordnung; Stille ohne Heartbeats bedeutet, dass die Verbindung tot ist.

Events tragen keine id, daher spielt Last-Event-ID keine Rolle. Es gibt nichts nachzuholen, denn jeder Snapshot ersetzt alles Vorherige. Die Schlüssel in rates folgen dem REST-Format: USDXAU ist der Preis einer Feinunze Gold in US-Dollar, XAU ist der Kehrwert.

Im Browser mit EventSource

EventSource übernimmt Verbindung, Parsing und Reconnects für Sie. Es kann keine eigenen Header senden, daher steht der Schlüssel im Query-String. Verwenden Sie einen Schlüssel, der auf Ihre Domains beschränkt ist: Der Browser sendet mit der Anfrage einen Origin-Header, und ein beschränkter Schlüssel lehnt Streams, die von anderen Websites geöffnet werden, mit Fehler 107 ab.

Browser
// Schlüssel verwenden, der auf Ihre Domains beschränkt ist: Jeder, der die Seite öffnet, kann ihn sehen.
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) {
    // Von MetalAPI gesendet, bevor der Stream geschlossen wird: {"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(); // ein Reconnect würde erneut scheitern
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // Sonst ist die Verbindung abgebrochen, und der Browser verbindet sich selbst neu.
});

Der error-Listener empfängt zwei verschiedene Dinge. Ein Verbindungsproblem kommt als einfaches Event an, und EventSource verbindet sich nach den 5 Sekunden, die die retry-Zeile des Servers vorgibt, selbstständig neu. Ein von MetalAPI gesendeter Fehler kommt als MessageEvent an, dessen data den JSON-Fehler enthält, und der Server schließt den Stream direkt danach. Rufen Sie bei Codes, die ein erneuter Versuch nicht behebt, close() auf; sonst verbindet sich der Browser immer wieder neu und wird immer wieder abgewiesen.

Ist bereits die erste Antwort ein HTTP-Fehler, zum Beispiel 106, weil der Tarif kein Streaming enthält, gibt EventSource den Antwortinhalt nicht preis und versucht es nicht erneut: readyState wird CLOSED. Der kostenlose usage-Endpunkt zeigt den Tarif des Schlüssels, wenn Sie der Ursache auf den Grund gehen müssen.

Direkte Browser-Streams eignen sich für interne Dashboards mit wenigen Nutzern. Jeder offene Tab ist ein eigener Stream: Er kostet eine Anfrage pro Minute und zählt zum Limit von 5 gleichzeitigen Streams pro Schlüssel. Betreiben Sie für eine öffentliche Seite einen einzigen Stream auf Ihrem Server und verteilen Sie ihn weiter, wie es der Ticker weiter unten tut.

In Node.js mit fetch

Auf dem Server kann der Schlüssel in einen Header, und Sie steuern Reconnects genau. Node.js 18 und neuer enthalten fetch mit streamenden Response-Bodys, sodass ein vollständiger Client ohne Abhängigkeiten in ein einziges Modul passt:

metal-stream.ts
// metal-stream.ts — Node.js 18+, keine Abhängigkeiten
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]); // ein erneuter Versuch hilft nicht
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; // der Server sendet `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 verpasste 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-Kommentar
            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`: Der Server schließt als Nächstes die Verbindung; die Schleife verbindet neu.
        }
      }
    } catch (err) {
      if (err instanceof FatalStreamError) throw err;
      failures++;
      console.warn("stream interrupted:", (err as Error).message);
    } finally {
      clearTimeout(watchdog);
    }
    // 5 s nach einem normalen Schließen, bei anhaltenden Fehlern Backoff bis zu einer Minute.
    const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
    await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
  }
}

Verwenden Sie es in jedem beliebigen Skript oder Dienst:

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); // ein FATAL-Code: Schlüssel, Tarif oder Kontingent korrigieren
  process.exit(1);
});

Der Parser folgt dem SSE-Format: Events werden durch eine Leerzeile getrennt, event: benennt das Event, data:-Zeilen werden mit Zeilenumbrüchen verbunden, und Zeilen, die mit einem Doppelpunkt beginnen, sind Kommentare. Jede SSE-Client-Bibliothek, mit der Sie Request-Header setzen können, funktioniert ebenso, solange sie die Reconnect-Regeln aus dem nächsten Abschnitt umsetzt.

Reconnects und Fehlerbehandlung

Das Modul oben folgt einigen wenigen Regeln, die in jeden Stream-Client gehören:

SituationSignalReaktion
Stündliche Rotationreconnect-Event, danach endet der StreamNach der retry-Verzögerung des Servers (5 s) neu verbinden
NetzwerkabbruchLesefehler, oder der Stream endet ohne reconnectMit exponentiellem Backoff und Jitter neu verbinden, höchstens 60 s Abstand
Stumme Verbindung45 s lang weder Daten noch HeartbeatAbbrechen und neu verbinden; drei Heartbeats wurden verpasst
Vorübergehende Ablehnung104, 500, 503Backoff, dann erneut versuchen
Dauerhafte Ablehnung101, 102, 103, 105, 106, 107Aufhören und einen Menschen benachrichtigen

Der Watchdog ist wichtiger, als er aussieht. Eine Verbindung, die ohne TCP-Reset abbricht, etwa hinter einem NAT-Gateway oder einem Load Balancer, kann minutenlang offen wirken, bevor das Betriebssystem es bemerkt. Mit einem Heartbeat alle 15 Sekunden erkennt der Client das innerhalb von 45 Sekunden.

Achten Sie auf Reconnect-Schleifen: Jede erfolgreiche Verbindung kostet eine Anfrage, und jeder Versuch zählt zum Limit pro Minute. Exponentielles Backoff begrenzt den Schaden, wenn etwas dauerhaft fehlschlägt.

Ein aufgebrauchtes Kontingent (105) verdient einen eigenen Alarm. Der Stream meldet es bei der nächsten minütlichen Prüfung als error-Event, und ein Reconnect hilft erst wieder, wenn das Kontingent am 1. (UTC) zurückgesetzt oder der Tarif hochgestuft wurde.

Einen Live-Ticker bauen

Ein öffentlicher Preisticker sollte nicht für jeden Besucher einen eigenen MetalAPI-Stream öffnen. Stattdessen hält ein kleines Relay einen Stream auf dem Server und leitet jeden Snapshot über einen eigenen SSE-Endpunkt an alle Browser weiter. Ihr Verbrauch hängt dann von der Zahl der Relays ab, nicht von der Zahl der Besucher, und der Schlüssel verlässt den Server nie:

relay.ts
// relay.ts — ein MetalAPI-Stream auf dem Server, beliebig viele Browser.
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`); // neue Besucher erhalten sofort Preise
  clients.add(res);
  req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));

Das Relay sendet jedem neuen Besucher sofort den neuesten Snapshot, gibt nur Zeitstempel, Basiswährung und Kurse weiter und verschickt eigene Heartbeats. Die Seite braucht nur wenige Zeilen, um den Ticker darzustellen und jeden Preis je nach Richtung seit der vorherigen Aktualisierung einzufärben:

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>

Starten Sie es mit METALAPI_KEY=mk_live_... npx tsx relay.ts und öffnen Sie http://localhost:3000. Der Ticker vergleicht jeden Preis mit dem vorherigen Event. Um stattdessen die Tagesveränderung anzuzeigen, rufen Sie den heutigen Eröffnungskurs einmal über den OHLC-Endpunkt ab und vergleichen jeden neuen Preis damit.

Hinweise für den Produktivbetrieb

  • Proxy-Buffering abschalten. MetalAPI sendet X-Accel-Buffering: no und Cache-Control: no-transform. Steht nginx oder ein anderer Proxy vor Ihrem Relay, deaktivieren Sie das Response-Buffering für die SSE-Route (proxy_buffering off; in nginx), sonst kommen Events stoßweise an.
  • Den Event-Stream nicht komprimieren. Kompressions-Middleware kann kleine Chunks zurückhalten, bis ihr Puffer voll ist. Nehmen Sie text/event-stream-Antworten davon aus.
  • Das Relay als langlebigen Prozess betreiben. Serverless-Funktionen haben meist Limits für die Ausführungszeit und eignen sich daher schlecht, um einen Stream rund um die Uhr offen zu halten.
  • Die Zeit der Daten anzeigen. Zeigen Sie den timestamp des Events an, nicht den Zeitpunkt, zu dem der Browser es empfangen hat. So erkennen Besucher, ob nur der Markt am Wochenende ruht oder der Feed hängt.
  • Screenreader nicht überfordern. Legen Sie einen schnell aktualisierten Ticker nicht in eine aria-live-Region; jede Aktualisierung anzusagen, stört nur.
  • Das Kontingent einplanen. Ein durchgehend offener Stream verbraucht etwa 45.400 Anfragen im Monat. Ein Pro-Tarif bietet also Platz für zwei Streams plus reguläre API-Aufrufe. Prüfen Sie Ihren Verbrauch kostenlos mit dem usage-Endpunkt.

Die API-Dokumentation listet die Parameter des Streams auf, und die Preisseite zeigt, welche Tarife ihn enthalten.

Probieren Sie es mit Ihren eigenen Daten aus

Erstellen Sie ein kostenloses Konto und senden Sie Ihre erste Anfrage in unter zwei Minuten. 100 Anfragen pro Monat, keine Kreditkarte.

Weiterlesen