Streaming de precios de metales en tiempo real con Server-Sent Events
Recibe precios del oro y la plata en tiempo real con Server-Sent Events: el endpoint /stream, sus eventos, EventSource y Node.js, reconexiones, cuotas y una cinta.
¿Streaming o consultas periódicas?
Las consultas periódicas (polling) piden los precios cada cierto tiempo. El streaming mantiene abierta una única conexión HTTP y deja que el servidor envíe los nuevos precios en cuanto se publican. El stream de MetalAPI usa Server-Sent Events (SSE): una respuesta HTTP normal con el tipo de contenido text/event-stream que permanece abierta y entrega eventos con nombre. No necesita ninguna biblioteca de WebSocket, atraviesa la mayoría de los proxies y los navegadores lo admiten de forma nativa con EventSource.
Que el streaming compense depende del intervalo de actualización de tu plan. Un stream cuesta una solicitud al conectarse y una solicitud por minuto mientras permanece abierto, por mucho que cambien los precios. Las consultas periódicas cuestan una solicitud por llamada. Para un solo cliente que vigila los precios las 24 horas durante un mes de 31 días:
| Plan | Intervalo de actualización | Consultas a ese intervalo | Un stream siempre abierto |
|---|---|---|---|
| Pro | 1 min | 44.640 solicitudes | unas 45.400 solicitudes |
| Ultra, Business | 15 s | 178.560 solicitudes | unas 45.400 solicitudes |
| Enterprise | 5 s | 535.680 solicitudes | unas 45.400 solicitudes |
La cifra del stream incluye una solicitud por la reconexión de cada hora, que se explica más abajo. En Pro, el streaming y las consultas cada minuto cuestan más o menos lo mismo; la ventaja del stream es que los nuevos precios llegan en cuanto se publican, sin un bucle de consultas que ajustar. En Ultra, Business y Enterprise, el stream es además mucho más barato que consultar a máxima velocidad.
El streaming no es la herramienta adecuada cuando un precio solo tiene que ser correcto una vez por vista de página, ni para tareas nocturnas, ni para comprobar umbrales. Usa una llamada a /latest en caché para los dos primeros casos y alertas de precio con webhooks para el tercero.
El endpoint /stream
GET https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPDEl stream acepta los mismos parámetros base, currencies (o symbols) y unit que /latest (consulta la documentación del endpoint latest), y la misma autenticación: api_key en la cadena de consulta, una cabecera Authorization: Bearer o una cabecera X-API-Key. Los parámetros se validan antes de abrir el stream, así que un símbolo o una unidad desconocidos devuelven un error JSON normal en lugar de un stream vacío.
| Plan | Actualizaciones de precios en el stream | Solicitudes al mes |
|---|---|---|
| Pro | Cada 60 s | 100.000 |
| Ultra | Cada 15 s | 500.000 |
| Business | Cada 15 s | 500.000 |
| Enterprise | Cada 5 s | 1.500.000 |
Los planes inferiores reciben el error 106 (HTTP 403) con el mensaje de que el streaming en tiempo real está disponible en el plan Pro y superiores. Cada conexión tiene algunos límites:
- Como máximo 5 streams simultáneos por clave API. Una sexta conexión se rechaza con
104(HTTP 429). - Las conexiones duran hasta 60 minutos. Pasada una hora, el servidor envía un evento
reconnecty cierra el stream, y el cliente vuelve a conectarse. - La clave se vuelve a comprobar cada minuto. Si se ha revocado o se ha agotado la cuota mensual, el servidor envía un evento
errory cierra el stream. - Conectarse cuenta para el límite por minuto como cualquier otra solicitud, así que un cliente atrapado en un bucle de reconexiones rápidas empieza a recibir errores
104.
Qué envía el servidor
Este es el comienzo de un stream como texto sin procesar (precios ilustrativos):
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}}| Evento | Datos | Qué hacer |
|---|---|---|
prices | Una instantánea completa: timestamp, date, base, rates, además de unit cuando no son onzas troy | Sustituye los precios que muestras |
reconnect | {"reason":"max connection time reached"} | Nada; vuelve a conectarte cuando se cierre el stream |
error | code, type e info, con los mismos códigos que la API REST | Detente o espera antes de reintentar, según el código |
: ping … | Una línea de comentario cada 15 segundos | Los parsers la ignoran; úsala como señal de vida |
Tres propiedades determinan el código del cliente. Cada evento prices es una instantánea completa de los símbolos que has solicitado, nunca un delta, así que un cliente que se reconecta vuelve a estar al día con el primer evento. Ese primer evento llega inmediatamente después de conectarse. A partir de ahí, solo se envía un evento cuando cambian las cotizaciones: el servidor comprueba con el intervalo de tu plan y no envía nada si nada se ha movido, algo normal en los metales durante el fin de semana. El silencio con latidos (heartbeats) es normal; el silencio sin latidos significa que la conexión está muerta.
Los eventos no llevan id, así que Last-Event-ID no interviene. No hay nada que reproducir, porque cada instantánea sustituye todo lo anterior. Las claves de rates siguen el formato REST: USDXAU es el precio de una onza troy de oro en dólares estadounidenses y XAU es su inverso.
En Node.js con fetch
En el servidor, la clave puede ir en una cabecera y tú controlas las reconexiones con precisión. Node.js 18 y versiones posteriores incluyen fetch con cuerpos de respuesta en streaming, así que un cliente completo cabe en un solo módulo sin dependencias:
// metal-stream.ts — Node.js 18+, sin dependencias
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]); // reintentar no servirá de nada
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; // el servidor envía `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 latidos perdidos
};
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; // comentario de latido
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`: a continuación el servidor cierra la conexión; el bucle se reconecta.
}
}
} catch (err) {
if (err instanceof FatalStreamError) throw err;
failures++;
console.warn("stream interrupted:", (err as Error).message);
} finally {
clearTimeout(watchdog);
}
// 5 s tras un cierre normal; si sigue fallando, espera cada vez más, hasta un minuto.
const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
}
}Úsalo desde cualquier script o servicio:
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 código FATAL: revisa la clave, el plan o la cuota
process.exit(1);
});El parser sigue el formato SSE: los eventos se separan con una línea en blanco, event: da nombre al evento, las líneas data: se unen con saltos de línea y las líneas que empiezan por dos puntos son comentarios. También sirve cualquier biblioteca cliente de SSE que te permita definir cabeceras de solicitud, siempre que aplique las reglas de reconexión de la siguiente sección.
Reconexiones y gestión de fallos
El módulo anterior sigue un breve conjunto de reglas que debería cumplir cualquier cliente de stream:
| Situación | Señal | Respuesta |
|---|---|---|
| Rotación cada hora | Evento reconnect y después fin del stream | Reconectar tras el retardo retry del servidor (5 s) |
| Caída de red | Error de lectura, o el stream termina sin reconnect | Reconectar con backoff exponencial y jitter, con un intervalo máximo de 60 s |
| Conexión silenciosa | Ni datos ni latidos durante 45 s | Abortar y reconectar; se han perdido tres latidos |
| Rechazo temporal | 104, 500, 503 | Esperar y reintentar |
| Rechazo permanente | 101, 102, 103, 105, 106, 107 | Detenerse y avisar a una persona |
El watchdog importa más de lo que parece. Una conexión que muere sin un reset de TCP, por ejemplo detrás de una pasarela NAT o de un balanceador de carga, puede parecer abierta durante minutos antes de que el sistema operativo se dé cuenta. Con un latido cada 15 segundos, el cliente puede detectarlo en 45.
Cuidado con los bucles de reconexión: cada conexión correcta cuesta una solicitud y cada intento cuenta para el límite por minuto. El backoff exponencial limita el daño cuando algo falla una y otra vez.
Quedarse sin cuota (105) merece su propia alerta. El stream lo notifica como un evento error en la siguiente comprobación por minuto, y reconectarse no sirve de nada hasta que la cuota se restablezca el día 1 (UTC) o se cambie a un plan superior.
Crear una cinta de cotizaciones en vivo
Una cinta de cotizaciones pública no debería abrir un stream de MetalAPI por cada visitante. En su lugar, un pequeño relay mantiene un único stream en el servidor y envía cada instantánea a todos los navegadores a través de un endpoint SSE propio. Así, tu consumo depende del número de relays y no del número de visitantes, y la clave nunca sale del servidor:
// relay.ts — un stream de MetalAPI en el servidor, cualquier número de navegadores.
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`); // los nuevos visitantes reciben precios al instante
clients.add(res);
req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));El relay envía la última instantánea a cada nuevo visitante de inmediato, transmite solo la marca de tiempo, la base y las cotizaciones, y envía sus propios latidos. A la página le bastan unas pocas líneas para mostrar la cinta y colorear cada precio según su dirección desde la actualización anterior:
<!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>Inícialo con METALAPI_KEY=mk_live_... npx tsx relay.ts y abre http://localhost:3000. La cinta compara cada precio con el del evento anterior. Para mostrar en su lugar la variación diaria, obtén una vez el precio de apertura de hoy del endpoint OHLC y compara cada nuevo precio con él.
Notas para producción
- Desactiva el buffering del proxy. MetalAPI envía
X-Accel-Buffering: noyCache-Control: no-transform. Si nginx u otro proxy está delante de tu relay, desactiva el buffering de respuestas en la ruta SSE (proxy_buffering off;en nginx); de lo contrario, los eventos llegan a ráfagas. - No comprimas el stream de eventos. El middleware de compresión puede retener los fragmentos pequeños hasta que se llene su búfer. Excluye de él las respuestas
text/event-stream. - Ejecuta el relay como un proceso de larga duración. Las funciones serverless suelen tener límites de tiempo de ejecución, lo que las hace poco adecuadas para mantener un stream abierto las 24 horas.
- Muestra la hora de los datos. Muestra el
timestampdel evento, no la hora en que lo recibió el navegador, para que los visitantes distingan un mercado tranquilo de fin de semana de un feed detenido. - No satures los lectores de pantalla. No pongas una cinta que se actualiza rápido en una región
aria-live; anunciar cada actualización resulta molesto. - Planifica la cuota. Un stream siempre abierto usa unas 45.400 solicitudes al mes, así que un plan Pro tiene margen para dos, más las llamadas normales a la API. Consulta el uso gratis con el endpoint de uso.
La documentación de la API recoge los parámetros del stream y la página de precios muestra qué planes lo incluyen.
Pruébalo con tus propios datos
Crea una cuenta gratuita y haz tu primera solicitud en menos de dos minutos. 100 solicitudes al mes, sin tarjeta de crédito.
Sigue leyendo
API de precios del oro: la guía completa para desarrolladores
Cómo usar una API de precios del oro: obtén una clave, llámala desde curl, JavaScript y Python, lee bien las cotizaciones y gestiona historial, errores, cuotas y caché.
Cómo añadir un widget del precio del oro en vivo a tu sitio web
Añade un widget del precio del oro en vivo a WordPress, Shopify o HTML: créalo en el panel de MetalAPI, elige símbolos, diseño y tema, y pega dos líneas de código.
Precios del oro por quilate: 24K, 22K, 18K y cómo calcularlos
Qué significan 24K, 22K y 18K, cómo se relacionan con leyes como 916 y 750, cómo calcular el precio por gramo del oro por quilate y cómo obtenerlo de una API.