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 %
Tous les articles
StreamingJavaScriptTutoriel

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.

Par MetalAPI Team··10 min de lecture

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 :

PlanIntervalle de mise à jourPolling à cet intervalleUn flux, toujours ouvert
Pro1 min44 640 requêtesenviron 45 400 requêtes
Ultra, Business15 s178 560 requêtesenviron 45 400 requêtes
Enterprise5 s535 680 requêtesenviron 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

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

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

PlanMises à jour des prix dans le fluxRequêtes par mois
ProToutes les 60 s100 000
UltraToutes les 15 s500 000
BusinessToutes les 15 s500 000
EnterpriseToutes les 5 s1 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 reconnect et 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 error et 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) :

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}}
ÉvénementDonnéesQue faire
pricesUn instantané complet : timestamp, date, base, rates, plus unit lorsque l'unité n'est pas l'once troyRemplacer les prix affichés
reconnect{"reason":"max connection time reached"}Rien ; se reconnecter à la fermeture du flux
errorcode, type et info, avec les mêmes codes que l'API RESTArrêter ou temporiser, selon le code
: ping …Une ligne de commentaire toutes les 15 secondesLes 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 le navigateur avec EventSource

EventSource gère pour vous la connexion, l'analyse des événements et les reconnexions. Il ne peut pas envoyer d'en-têtes personnalisés : la clé passe donc dans la chaîne de requête. Utilisez une clé restreinte à vos domaines : le navigateur envoie un en-tête Origin avec la requête, et une clé restreinte refuse les flux ouverts depuis d'autres sites avec l'erreur 107.

Navigateur
// Utilisez une clé restreinte à vos domaines : elle est visible par quiconque ouvre la 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) {
    // Envoyé par MetalAPI avant la fermeture du flux : {"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(); // une reconnexion échouerait de nouveau
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // Sinon, la connexion a été coupée et le navigateur se reconnecte tout seul.
});

L'écouteur error reçoit deux choses différentes. Un problème de connexion arrive sous la forme d'un simple Event, et EventSource se reconnecte de lui-même après les 5 secondes fixées par la ligne retry du serveur. Une erreur envoyée par MetalAPI arrive sous la forme d'un MessageEvent dont data contient l'erreur JSON, et le serveur ferme le flux juste après. Pour les codes qu'une nouvelle tentative ne peut pas résoudre, appelez close() ; sinon, le navigateur continue de se reconnecter et d'être refusé.

Si la première réponse est déjà une erreur HTTP, par exemple 106 parce que le plan n'inclut pas le streaming, EventSource n'expose pas le corps de la réponse et ne réessaie pas : readyState passe à CLOSED. L'endpoint usage, gratuit, indique le plan de la clé lorsque vous devez en trouver la cause.

Les flux ouverts directement depuis le navigateur conviennent aux tableaux de bord internes utilisés par une poignée de personnes. Chaque onglet ouvert est un flux à part entière : il coûte une requête par minute et compte dans la limite de 5 flux simultanés par clé. Pour une page publique, faites tourner un seul flux sur votre serveur et redistribuez-le, comme le fait le bandeau de prix ci-dessous.

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

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); // 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 :

SituationSignalRéaction
Rotation horaireÉvénement reconnect, puis fin du fluxSe reconnecter après le délai retry du serveur (5 s)
Coupure réseauErreur de lecture, ou fin du flux sans reconnectSe reconnecter avec un backoff exponentiel et une part d'aléatoire (jitter), à 60 s d'intervalle au maximum
Connexion muetteNi données ni ping pendant 45 sInterrompre et se reconnecter ; trois pings ont été manqués
Refus temporaire104, 500, 503Temporiser et réessayer
Refus définitif101, 102, 103, 105, 106, 107S'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
// 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 :

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>

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: no et Cache-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 timestamp de 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