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.
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:
| Tarif | Aktualisierungsintervall | Polling in diesem Intervall | Ein Stream, durchgehend offen |
|---|---|---|---|
| Pro | 1 min | 44.640 Anfragen | etwa 45.400 Anfragen |
| Ultra, Business | 15 s | 178.560 Anfragen | etwa 45.400 Anfragen |
| Enterprise | 5 s | 535.680 Anfragen | etwa 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
GET https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPDDer 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.
| Tarif | Preisaktualisierungen im Stream | Anfragen pro Monat |
|---|---|---|
| Pro | Alle 60 s | 100.000 |
| Ultra | Alle 15 s | 500.000 |
| Business | Alle 15 s | 500.000 |
| Enterprise | Alle 5 s | 1.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):
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 | Daten | Was zu tun ist |
|---|---|---|
prices | Ein vollständiger Snapshot: timestamp, date, base, rates, dazu unit, wenn die Einheit nicht Feinunzen ist | Die angezeigten Preise ersetzen |
reconnect | {"reason":"max connection time reached"} | Nichts; neu verbinden, sobald der Stream schließt |
error | code, type und info, mit denselben Codes wie die REST-API | Je nach Code aufhören oder mit Backoff erneut versuchen |
: ping … | Eine Kommentarzeile alle 15 Sekunden | Parser 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.
// 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¤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) {
// 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 — 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¤cies=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:
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:
| Situation | Signal | Reaktion |
|---|---|---|
| Stündliche Rotation | reconnect-Event, danach endet der Stream | Nach der retry-Verzögerung des Servers (5 s) neu verbinden |
| Netzwerkabbruch | Lesefehler, oder der Stream endet ohne reconnect | Mit exponentiellem Backoff und Jitter neu verbinden, höchstens 60 s Abstand |
| Stumme Verbindung | 45 s lang weder Daten noch Heartbeat | Abbrechen und neu verbinden; drei Heartbeats wurden verpasst |
| Vorübergehende Ablehnung | 104, 500, 503 | Backoff, dann erneut versuchen |
| Dauerhafte Ablehnung | 101, 102, 103, 105, 106, 107 | Aufhö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 — 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:
<!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: noundCache-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
timestampdes 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
Goldpreis-API: Der vollständige Leitfaden für Entwickler
So nutzen Sie eine Goldpreis-API: Schlüssel holen, Aufrufe mit curl, JavaScript und Python, Kurse richtig lesen sowie Historie, Fehler, Kontingente und Caching.
So binden Sie ein Live-Goldpreis-Widget in Ihre Website ein
Live-Goldpreis-Widget für WordPress, Shopify oder HTML: im MetalAPI-Dashboard erstellen, Symbole, Layout und Design wählen und zwei Zeilen Code einfügen.
Goldpreise nach Karat erklärt: 24K, 22K, 18K und wie man sie berechnet
Was 24, 22 und 18 Karat bedeuten, wie Feingehalte wie 916 und 750 zusammenhängen, wie Sie Karatpreise pro Gramm berechnen und per Goldpreis-API abrufen.