Diffuser les prix des métaux en temps réel avec les Server-Sent Events
Recevez en temps réel les prix de l'or et de l'argent via Server-Sent Events : endpoint /stream, événements, EventSource ou Node.js, reconnexions et quotas.
Streaming ou polling ?
Le polling demande les prix à intervalles réguliers. Le streaming garde une seule connexion HTTP ouverte et laisse le serveur pousser les nouveaux prix dès leur publication. Le flux de MetalAPI utilise les Server-Sent Events (SSE) : une réponse HTTP ordinaire, de type de contenu text/event-stream, qui reste ouverte et transmet des événements nommés. Il ne nécessite aucune bibliothèque WebSocket, passe à travers la plupart des proxys, et les navigateurs le prennent en charge nativement avec EventSource.
L'intérêt du streaming dépend de l'intervalle de mise à jour de votre plan. Un flux coûte une requête à la connexion, puis une requête par minute tant qu'il reste ouvert, quelle que soit la fréquence de variation des prix. Le polling coûte une requête par appel. Pour un seul client qui suit les prix 24 h/24 pendant un mois de 31 jours :
| Plan | Intervalle de mise à jour | Polling à cet intervalle | Un flux, toujours ouvert |
|---|---|---|---|
| Pro | 1 min | 44 640 requêtes | environ 45 400 requêtes |
| Ultra, Business | 15 s | 178 560 requêtes | environ 45 400 requêtes |
| Enterprise | 5 s | 535 680 requêtes | environ 45 400 requêtes |
Le chiffre du flux inclut une requête par reconnexion horaire, expliquée plus bas. Sur Pro, le streaming et le polling à la minute coûtent à peu près la même chose ; l'avantage du flux est que les nouveaux prix arrivent dès leur publication, sans boucle de polling à régler. Sur Ultra, Business et Enterprise, le flux est aussi bien moins cher que le polling à pleine vitesse.
Le streaming n'est pas le bon outil lorsqu'un prix ne doit être exact qu'une fois par affichage de page, pour les traitements nocturnes et pour les vérifications de seuils. Utilisez un appel /latest mis en cache pour les deux premiers cas et les alertes de prix par webhook pour le troisième.
L'endpoint /stream
GET https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPDLe flux accepte les mêmes paramètres base, currencies (ou symbols) et unit que /latest (voir la documentation de l'endpoint latest), ainsi que la même authentification : api_key dans la chaîne de requête, un en-tête Authorization: Bearer ou un en-tête X-API-Key. Les paramètres sont validés avant l'ouverture du flux : un symbole ou une unité inconnus renvoient donc une erreur JSON normale plutôt qu'un flux vide.
| Plan | Mises à jour des prix dans le flux | Requêtes par mois |
|---|---|---|
| Pro | Toutes les 60 s | 100 000 |
| Ultra | Toutes les 15 s | 500 000 |
| Business | Toutes les 15 s | 500 000 |
| Enterprise | Toutes les 5 s | 1 500 000 |
Les plans inférieurs reçoivent l'erreur 106 (HTTP 403), avec un message indiquant que le streaming en temps réel est disponible à partir du plan Pro. Quelques limites s'appliquent à chaque connexion :
- 5 flux simultanés au maximum par clé API. Une sixième connexion est refusée avec
104(HTTP 429). - Les connexions durent jusqu'à 60 minutes. Au bout d'une heure, le serveur envoie un événement
reconnectet ferme le flux, puis le client se reconnecte. - La clé est revérifiée chaque minute. Si elle a été révoquée ou si le quota mensuel est épuisé, le serveur envoie un événement
erroret ferme le flux. - Chaque connexion compte dans la limite par minute, comme toute autre requête : un client pris dans une boucle de reconnexion rapide commence donc à recevoir des erreurs
104.
Ce qu'envoie le serveur
Voici le début d'un flux sous forme de texte brut (prix donnés à titre d'illustration) :
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}}| Événement | Données | Que faire |
|---|---|---|
prices | Un instantané complet : timestamp, date, base, rates, plus unit lorsque l'unité n'est pas l'once troy | Remplacer les prix affichés |
reconnect | {"reason":"max connection time reached"} | Rien ; se reconnecter à la fermeture du flux |
error | code, type et info, avec les mêmes codes que l'API REST | Arrêter ou temporiser, selon le code |
: ping … | Une ligne de commentaire toutes les 15 secondes | Les parseurs l'ignorent ; servez-vous-en comme signe de vie |
Trois propriétés déterminent le code client. Chaque événement prices est un instantané complet des symboles demandés, jamais un delta : un client qui se reconnecte est donc à jour dès le premier événement. Ce premier événement arrive immédiatement après la connexion. Ensuite, un événement n'est envoyé que lorsque les taux changent : le serveur vérifie à l'intervalle de votre plan et reste silencieux quand rien n'a bougé, ce qui est normal pour les métaux le week-end. Un silence ponctué de pings est normal ; un silence sans ping signifie que la connexion est morte.
Les événements ne portent pas d'id : Last-Event-ID ne joue donc aucun rôle. Il n'y a rien à rejouer, puisque chaque instantané remplace tout ce qui précède. Les clés de rates suivent le format REST : USDXAU est le prix d'une once troy d'or en dollars américains, et XAU en est l'inverse.
Dans Node.js avec fetch
Côté serveur, la clé peut passer dans un en-tête et vous contrôlez précisément les reconnexions. Node.js 18 et les versions ultérieures incluent fetch avec des corps de réponse en streaming : un client complet tient donc dans un seul module, sans dépendance :
// metal-stream.ts — Node.js 18+, sans dépendance
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]); // réessayer n'y changera rien
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; // le serveur envoie `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 pings manqués
};
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; // commentaire de ping
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` : le serveur ferme ensuite la connexion ; la boucle se reconnecte.
}
}
} catch (err) {
if (err instanceof FatalStreamError) throw err;
failures++;
console.warn("stream interrupted:", (err as Error).message);
} finally {
clearTimeout(watchdog);
}
// 5 s après une fermeture normale, puis backoff jusqu'à une minute tant que les échecs se répètent.
const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
}
}Utilisez-le depuis n'importe quel script ou 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); // un code FATAL : corrigez la clé, le plan ou le quota
process.exit(1);
});L'analyseur suit le format SSE : les événements sont séparés par une ligne vide, event: nomme l'événement, les lignes data: sont jointes par des retours à la ligne, et les lignes qui commencent par deux-points sont des commentaires. Toute bibliothèque cliente SSE qui permet de définir des en-têtes de requête fonctionne aussi, à condition d'appliquer les règles de reconnexion de la section suivante.
Reconnexions et gestion des pannes
Le module ci-dessus suit quelques règles qui ont leur place dans tout client de flux :
| Situation | Signal | Réaction |
|---|---|---|
| Rotation horaire | Événement reconnect, puis fin du flux | Se reconnecter après le délai retry du serveur (5 s) |
| Coupure réseau | Erreur de lecture, ou fin du flux sans reconnect | Se reconnecter avec un backoff exponentiel et une part d'aléatoire (jitter), à 60 s d'intervalle au maximum |
| Connexion muette | Ni données ni ping pendant 45 s | Interrompre et se reconnecter ; trois pings ont été manqués |
| Refus temporaire | 104, 500, 503 | Temporiser et réessayer |
| Refus définitif | 101, 102, 103, 105, 106, 107 | S'arrêter et prévenir une personne |
Le chien de garde (watchdog) compte plus qu'il n'y paraît. Une connexion qui meurt sans réinitialisation TCP, par exemple derrière une passerelle NAT ou un répartiteur de charge, peut sembler ouverte pendant plusieurs minutes avant que le système d'exploitation ne s'en aperçoive. Avec un ping toutes les 15 secondes, le client peut la détecter en 45 secondes.
Méfiez-vous des boucles de reconnexion : chaque connexion réussie coûte une requête, et chaque tentative compte dans la limite par minute. Le backoff exponentiel limite les dégâts lorsqu'un problème persiste.
L'épuisement du quota (105) mérite sa propre alerte. Le flux le signale par un événement error lors de la vérification suivante, effectuée chaque minute, et se reconnecter n'y change rien tant que le quota n'a pas été réinitialisé le 1er du mois (UTC) ou que le plan n'a pas été mis à niveau.
Construire un bandeau de prix en direct
Un bandeau de prix public ne doit pas ouvrir un flux MetalAPI pour chaque visiteur. À la place, un petit relais maintient un seul flux sur le serveur et pousse chaque instantané vers tous les navigateurs via son propre endpoint SSE. Votre consommation dépend alors du nombre de relais, et non du nombre de visiteurs, et la clé ne quitte jamais le serveur :
// relay.ts — un seul flux MetalAPI sur le serveur, autant de navigateurs que nécessaire.
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`); // les nouveaux visiteurs reçoivent les prix immédiatement
clients.add(res);
req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));Le relais envoie immédiatement le dernier instantané à chaque nouveau visiteur, ne transmet que l'horodatage, la base et les taux, et envoie ses propres pings. La page n'a besoin que de quelques lignes pour afficher le bandeau et colorer chaque prix selon son sens de variation depuis la mise à jour précédente :
<!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>Lancez-le avec METALAPI_KEY=mk_live_... npx tsx relay.ts et ouvrez http://localhost:3000. Le bandeau compare chaque prix à celui de l'événement précédent. Pour afficher plutôt la variation du jour, récupérez une fois le prix d'ouverture du jour via l'endpoint OHLC et comparez-lui chaque nouveau prix.
Conseils pour la production
- Désactivez la mise en tampon du proxy. MetalAPI envoie
X-Accel-Buffering: noetCache-Control: no-transform. Si nginx ou un autre proxy se trouve devant votre relais, désactivez la mise en tampon des réponses pour la route SSE (proxy_buffering off;dans nginx) ; sinon, les événements arrivent par rafales. - Ne compressez pas le flux d'événements. Un middleware de compression peut retenir les petits fragments jusqu'à ce que son tampon soit plein. Excluez-en les réponses
text/event-stream. - Exécutez le relais comme un processus de longue durée. Les fonctions serverless ont généralement une durée d'exécution limitée, ce qui les rend peu adaptées au maintien d'un flux ouvert 24 h/24.
- Affichez l'heure des données. Affichez le
timestampde l'événement, et non l'heure de réception par le navigateur, afin que les visiteurs puissent distinguer un marché calme le week-end d'un flux bloqué. - Ménagez les lecteurs d'écran. Ne placez pas un bandeau qui se met à jour rapidement dans une région
aria-live: annoncer chaque mise à jour devient vite pénible. - Budgétez le quota. Un flux toujours ouvert consomme environ 45 400 requêtes par mois : un plan Pro peut donc en accueillir deux, en plus d'appels API classiques. Vérifiez gratuitement votre consommation avec l'endpoint usage.
La documentation de l'API liste les paramètres du flux, et la page des tarifs indique les plans qui l'incluent.
Essayez avec vos propres données
Créez un compte gratuit et envoyez votre première requête en moins de deux minutes. 100 requêtes par mois, sans carte bancaire.
À lire aussi
API du cours de l'or : le guide complet pour les développeurs
Utiliser une API du cours de l'or : obtenir une clé, l'appeler en curl, JavaScript et Python, bien lire les taux, gérer historique, erreurs, quotas et cache.
Ajouter un widget de prix de l'or en direct à votre site web
Ajoutez un widget de prix de l'or en direct à WordPress, Shopify ou une page HTML : créez-le dans le tableau de bord MetalAPI, réglez-le et collez deux lignes.
Prix de l'or par carat : 24K, 22K, 18K et comment les calculer
Ce que signifient 24K, 22K et 18K, le lien avec les titres 916 et 750, comment calculer le prix au gramme de l'or par carat et l'obtenir via une API.