Streaming de preços de metais em tempo real com Server-Sent Events
Receba preços de ouro e prata em tempo real com Server-Sent Events: o endpoint /stream, seus eventos, EventSource vs. Node.js, reconexões, cotas e um ticker.
Streaming ou polling?
O polling pede os preços em intervalos fixos. O streaming mantém uma conexão HTTP aberta e deixa o servidor enviar os novos preços assim que eles são publicados. O stream da MetalAPI usa Server-Sent Events (SSE): uma resposta HTTP comum, com o tipo de conteúdo text/event-stream, que permanece aberta e entrega eventos nomeados. Ele não exige nenhuma biblioteca de WebSocket, passa pela maioria dos proxies, e os navegadores oferecem suporte nativo com EventSource.
Se o streaming compensa ou não depende do intervalo de atualização do seu plano. Um stream custa uma requisição ao se conectar e uma requisição por minuto enquanto permanece aberto, não importa com que frequência os preços mudem. O polling custa uma requisição por chamada. Para um único cliente acompanhando os preços 24 horas por dia em um mês de 31 dias:
| Plano | Intervalo de atualização | Polling nesse intervalo | Um stream, sempre aberto |
|---|---|---|---|
| Pro | 1 min | 44.640 requisições | cerca de 45.400 requisições |
| Ultra, Business | 15 s | 178.560 requisições | cerca de 45.400 requisições |
| Enterprise | 5 s | 535.680 requisições | cerca de 45.400 requisições |
O número do stream inclui uma requisição por reconexão a cada hora, explicada mais abaixo. No Pro, o streaming e o polling a cada minuto custam mais ou menos o mesmo; a vantagem do stream é que os novos preços chegam assim que são publicados, sem um loop de polling para ajustar. No Ultra, no Business e no Enterprise, o stream também sai muito mais barato do que o polling na velocidade máxima.
O streaming é a ferramenta errada quando um preço só precisa estar correto uma vez por visualização de página, para jobs noturnos e para verificações de limiar. Use uma chamada a /latest em cache nos dois primeiros casos e alertas de preço com webhooks no terceiro.
O endpoint /stream
GET https://metalapi.com/api/v1/stream?base=USD¤cies=XAU,XAG,XPT,XPDO stream aceita os mesmos parâmetros base, currencies (ou symbols) e unit que /latest (veja a documentação do endpoint latest) e a mesma autenticação: api_key na query string, um cabeçalho Authorization: Bearer ou um cabeçalho X-API-Key. Os parâmetros são validados antes de o stream ser aberto, então um símbolo ou uma unidade desconhecidos retornam um erro JSON normal em vez de um stream vazio.
| Plano | Atualizações de preço no stream | Requisições por mês |
|---|---|---|
| Pro | A cada 60 s | 100.000 |
| Ultra | A cada 15 s | 500.000 |
| Business | A cada 15 s | 500.000 |
| Enterprise | A cada 5 s | 1.500.000 |
Os planos inferiores recebem o erro 106 (HTTP 403) com a mensagem de que o streaming em tempo real está disponível a partir do plano Pro. Alguns limites se aplicam a cada conexão:
- No máximo 5 streams simultâneos por chave de API. Uma sexta conexão é recusada com
104(HTTP 429). - As conexões duram até 60 minutos. Depois de uma hora, o servidor envia um evento
reconnecte fecha o stream, e o cliente se conecta novamente. - A chave é verificada de novo a cada minuto. Se ela tiver sido revogada ou a cota mensal tiver se esgotado, o servidor envia um evento
errore fecha o stream. - A conexão conta para o limite por minuto como qualquer outra requisição, então um cliente preso em um loop de reconexão rápida começa a receber erros
104.
O que o servidor envia
Este é o início de um stream em texto bruto (preços 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 | Dados | O que fazer |
|---|---|---|
prices | Um snapshot completo: timestamp, date, base, rates, além de unit quando a unidade não for a onça troy | Substitua os preços que você exibe |
reconnect | {"reason":"max connection time reached"} | Nada; reconecte quando o stream for fechado |
error | code, type e info, com os mesmos códigos da API REST | Pare ou aguarde antes de tentar de novo, dependendo do código |
: ping … | Uma linha de comentário a cada 15 segundos | Os parsers a ignoram; use-a como sinal de vida |
Três propriedades definem o código do cliente. Todo evento prices é um snapshot completo dos símbolos solicitados, nunca um delta, então um cliente que se reconecta volta a estar atualizado já com o primeiro evento. Esse primeiro evento chega imediatamente após a conexão. Depois dele, um evento é enviado somente quando as cotações mudam: o servidor verifica no intervalo do seu plano e fica em silêncio quando nada se moveu, o que é normal para os metais no fim de semana. Silêncio com heartbeats está tudo bem; silêncio sem heartbeats significa que a conexão morreu.
Os eventos não têm id, então Last-Event-ID não tem nenhum papel. Não há nada para reenviar, porque cada snapshot substitui tudo o que veio antes. As chaves de rates seguem o formato REST: USDXAU é o preço de uma onça troy de ouro em dólares americanos, e XAU é o seu inverso.
No Node.js com fetch
No servidor, a chave pode ir em um cabeçalho e você controla as reconexões com precisão. O Node.js 18 e as versões mais recentes incluem fetch com corpos de resposta em streaming, então um cliente completo cabe em um único módulo, sem dependências:
// metal-stream.ts — Node.js 18+, sem dependências
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]); // tentar de novo não vai ajudar
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; // o servidor envia `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 heartbeats 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; // comentário de heartbeat
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`: em seguida o servidor fecha a conexão; o loop se reconecta.
}
}
} catch (err) {
if (err instanceof FatalStreamError) throw err;
failures++;
console.warn("stream interrupted:", (err as Error).message);
} finally {
clearTimeout(watchdog);
}
// 5 s após um fechamento normal; depois, espera crescente de até um minuto enquanto continuar falhando.
const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
}
}Use-o a partir de qualquer script ou serviço:
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); // um código FATAL: corrija a chave, o plano ou a cota
process.exit(1);
});O parser segue o formato SSE: os eventos são separados por uma linha em branco, event: dá nome ao evento, as linhas data: são unidas com quebras de linha, e as linhas que começam com dois-pontos são comentários. Qualquer biblioteca cliente de SSE que permita definir cabeçalhos de requisição também funciona, desde que aplique as regras de reconexão da próxima seção.
Reconexões e tratamento de falhas
O módulo acima segue um pequeno conjunto de regras que deve fazer parte de qualquer cliente de stream:
| Situação | Sinal | Resposta |
|---|---|---|
| Rotação a cada hora | Evento reconnect, depois o stream termina | Reconecte após o atraso retry do servidor (5 s) |
| Queda de rede | Erro de leitura, ou o stream termina sem reconnect | Reconecte com backoff exponencial e jitter, com no máximo 60 s entre as tentativas |
| Conexão silenciosa | Nenhum dado e nenhum heartbeat por 45 s | Aborte e reconecte; três heartbeats foram perdidos |
| Recusa temporária | 104, 500, 503 | Aguarde com backoff e tente de novo |
| Recusa permanente | 101, 102, 103, 105, 106, 107 | Pare e alerte uma pessoa |
O watchdog é mais importante do que parece. Uma conexão que morre sem um reset de TCP, por exemplo atrás de um gateway NAT ou de um balanceador de carga, pode parecer aberta por minutos antes que o sistema operacional perceba. Com um heartbeat a cada 15 segundos, o cliente consegue detectá-la em até 45.
Cuidado com loops de reconexão: cada conexão bem-sucedida custa uma requisição, e cada tentativa conta para o limite por minuto. O backoff exponencial limita o estrago quando algo continua falhando.
O esgotamento da cota (105) merece um alerta próprio. O stream o informa como um evento error na próxima verificação por minuto, e reconectar não adianta até que a cota seja renovada no dia 1º (UTC) ou que você faça upgrade do plano.
Criando um ticker ao vivo
Um ticker de preços público não deve abrir um stream da MetalAPI para cada visitante. Em vez disso, um pequeno relay mantém um único stream no servidor e envia cada snapshot a todos os navegadores por meio de um endpoint SSE próprio. Assim, o seu uso depende do número de relays, não do número de visitantes, e a chave nunca sai do servidor:
// relay.ts — um stream da MetalAPI no servidor, qualquer 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`); // novos visitantes recebem os preços na hora
clients.add(res);
req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));O relay envia imediatamente o snapshot mais recente a cada novo visitante, repassa apenas o timestamp, a base e as cotações, e envia os seus próprios heartbeats. A página precisa de poucas linhas para renderizar o ticker e colorir cada preço de acordo com a direção desde a atualização 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>Inicie-o com METALAPI_KEY=mk_live_... npx tsx relay.ts e abra http://localhost:3000. O ticker compara cada preço com o evento anterior. Para mostrar a variação do dia, busque uma vez a abertura de hoje no endpoint OHLC e compare cada novo preço com ela.
Notas para produção
- Desative o buffering do proxy. A MetalAPI envia
X-Accel-Buffering: noeCache-Control: no-transform. Se o nginx ou outro proxy ficar na frente do seu relay, desative o buffering de respostas na rota SSE (proxy_buffering off;no nginx); caso contrário, os eventos chegam em rajadas. - Não comprima o event stream. Middlewares de compressão podem segurar pequenos blocos até que o buffer encha. Exclua deles as respostas
text/event-stream. - Rode o relay como um processo de longa duração. Funções serverless costumam ter limites de tempo de execução, o que as torna pouco adequadas para manter um stream aberto 24 horas por dia.
- Mostre o horário dos dados. Exiba o
timestampdo evento, não o horário em que o navegador o recebeu, para que os visitantes consigam distinguir um mercado tranquilo de fim de semana de um feed travado. - Poupe os leitores de tela. Não coloque um ticker que se atualiza rapidamente em uma região
aria-live; anunciar cada atualização só gera ruído. - Planeje a cota. Um stream sempre aberto usa cerca de 45.400 requisições por mês, então um plano Pro comporta dois, além das chamadas normais à API. Consulte o uso gratuitamente com o endpoint de uso.
A documentação da API lista os parâmetros do stream, e a página de preços mostra quais planos o incluem.
Experimente com seus próprios dados
Crie uma conta grátis e faça sua primeira requisição em menos de dois minutos. 100 requisições por mês, sem cartão de crédito.
Continue lendo
API de preços do ouro: o guia completo para desenvolvedores
Como usar uma API de preços do ouro: obtenha uma chave, chame-a com curl, JavaScript e Python, leia as cotações certo e trate histórico, erros, cotas e cache.
Como adicionar um widget de cotação do ouro ao vivo ao seu site
Widget com a cotação do ouro ao vivo no WordPress, na Shopify ou em HTML puro: crie no painel da MetalAPI, escolha símbolos, layout e tema e cole duas linhas.
Preços do ouro por quilate: 24K, 22K, 18K e como calculá-los
O que significam 24K, 22K e 18K, como se relacionam com teores como 916 e 750, como calcular o preço por grama de cada quilate e como obtê-lo por uma API.