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%
Todos os artigos
StreamingJavaScriptTutorial

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.

Por MetalAPI Team··10 min de leitura

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:

PlanoIntervalo de atualizaçãoPolling nesse intervaloUm stream, sempre aberto
Pro1 min44.640 requisiçõescerca de 45.400 requisições
Ultra, Business15 s178.560 requisiçõescerca de 45.400 requisições
Enterprise5 s535.680 requisiçõescerca 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

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

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

PlanoAtualizações de preço no streamRequisições por mês
ProA cada 60 s100.000
UltraA cada 15 s500.000
BusinessA cada 15 s500.000
EnterpriseA cada 5 s1.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 reconnect e 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 error e 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):

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}}
EventoDadosO que fazer
pricesUm snapshot completo: timestamp, date, base, rates, além de unit quando a unidade não for a onça troySubstitua os preços que você exibe
reconnect{"reason":"max connection time reached"}Nada; reconecte quando o stream for fechado
errorcode, type e info, com os mesmos códigos da API RESTPare ou aguarde antes de tentar de novo, dependendo do código
: ping …Uma linha de comentário a cada 15 segundosOs 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 navegador com EventSource

O EventSource cuida da conexão, do parsing e das reconexões para você. Ele não consegue enviar cabeçalhos personalizados, então a chave vai na query string. Use uma chave restrita aos seus domínios: o navegador envia um cabeçalho Origin com a requisição, e uma chave restrita recusa streams abertos a partir de outros sites com o erro 107.

Navegador
// Use uma chave restrita aos seus domínios: ela fica visível para qualquer pessoa que abrir a página.
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) {
    // Enviado pela MetalAPI antes de fechar o stream: {"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(); // reconectar falharia de novo
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // Caso contrário, a conexão caiu e o navegador se reconecta sozinho.
});

O listener de error recebe duas coisas diferentes. Um problema de conexão chega como um Event simples, e o EventSource se reconecta por conta própria depois dos 5 segundos definidos pela linha retry do servidor. Um erro enviado pela MetalAPI chega como um MessageEvent cujo data contém o erro em JSON, e o servidor fecha o stream logo em seguida. Para códigos que uma nova tentativa não resolve, chame close(); caso contrário, o navegador continua se reconectando e continua sendo recusado.

Se a primeira resposta já for um erro HTTP, por exemplo 106 porque o plano não inclui streaming, o EventSource não expõe o corpo da resposta e não tenta de novo: readyState passa a ser CLOSED. O endpoint de uso, que é gratuito, mostra o plano da chave quando você precisa descobrir o motivo.

Streams diretos no navegador são adequados para painéis internos com poucos usuários. Cada aba aberta é um stream próprio: custa uma requisição por minuto e conta para o limite de 5 streams simultâneos por chave. Para uma página pública, mantenha um único stream no seu servidor e distribua-o para os visitantes, como faz o ticker mais abaixo.

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

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); // 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çãoSinalResposta
Rotação a cada horaEvento reconnect, depois o stream terminaReconecte após o atraso retry do servidor (5 s)
Queda de redeErro de leitura, ou o stream termina sem reconnectReconecte com backoff exponencial e jitter, com no máximo 60 s entre as tentativas
Conexão silenciosaNenhum dado e nenhum heartbeat por 45 sAborte e reconecte; três heartbeats foram perdidos
Recusa temporária104, 500, 503Aguarde com backoff e tente de novo
Recusa permanente101, 102, 103, 105, 106, 107Pare 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
// 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:

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>

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: no e Cache-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 timestamp do 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