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 %
Все статьи
СтримингJavaScriptТуториал

Цены на металлы в реальном времени через Server-Sent Events

Цены на золото и серебро в реальном времени через Server-Sent Events: эндпоинт /stream, его события, EventSource и Node.js, переподключения, квоты и тикер.

Автор: MetalAPI Team··10 мин чтения

Стриминг или опрос?

При опросе цены запрашиваются по таймеру. При стриминге одно HTTP-соединение остаётся открытым, и сервер сам присылает новые цены по мере их публикации. Поток MetalAPI работает на Server-Sent Events (SSE): это обычный HTTP-ответ с типом содержимого text/event-stream, который остаётся открытым и доставляет именованные события. Для него не нужна библиотека WebSocket, он проходит через большинство прокси, а браузеры поддерживают его нативно через EventSource.

Окупается ли стриминг, зависит от интервала обновления на вашем тарифе. Поток стоит один запрос при подключении и по одному запросу в минуту, пока он открыт, — независимо от того, как часто меняются цены. При опросе каждый вызов стоит один запрос. Для одного клиента, который следит за ценами круглосуточно в течение месяца из 31 дня:

ТарифИнтервал обновленияОпрос с этим интерваломОдин постоянно открытый поток
Pro1 мин44 640 запросовоколо 45 400 запросов
Ultra, Business15 с178 560 запросовоколо 45 400 запросов
Enterprise5 с535 680 запросовоколо 45 400 запросов

В расход потока включён один запрос на каждое ежечасное переподключение (подробнее ниже). На Pro стриминг и ежеминутный опрос обходятся примерно одинаково; преимущество потока в том, что новые цены приходят сразу после публикации и не нужно настраивать цикл опроса. На Ultra, Business и Enterprise поток к тому же намного дешевле опроса на максимальной частоте.

Стриминг не подходит, если цена должна быть верной лишь в момент открытия страницы, для ночных задач и для проверки пороговых значений. В первых двух случаях используйте кешированный вызов /latest, в третьем — ценовые оповещения с вебхуками.

Эндпоинт /stream

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

Поток принимает те же параметры base, currencies (или symbols) и unit, что и /latest (см. документацию по эндпоинту latest), и ту же аутентификацию: api_key в строке запроса, заголовок Authorization: Bearer или заголовок X-API-Key. Параметры проверяются до открытия потока, поэтому неизвестный символ или единица измерения возвращают обычную ошибку в JSON, а не пустой поток.

ТарифОбновления цен в потокеЗапросов в месяц
ProКаждые 60 с100 000
UltraКаждые 15 с500 000
BusinessКаждые 15 с500 000
EnterpriseКаждые 5 с1 500 000

На младших тарифах возвращается ошибка 106 (HTTP 403) с сообщением о том, что стриминг в реальном времени доступен на тарифе Pro и выше. Для каждого соединения действует несколько ограничений:

  • Не более 5 одновременных потоков на один ключ API. Шестое соединение отклоняется с ошибкой 104 (HTTP 429).
  • Соединение длится не более 60 минут. Через час сервер отправляет событие reconnect и закрывает поток, а клиент подключается заново.
  • Ключ перепроверяется каждую минуту. Если ключ отозван или месячная квота исчерпана, сервер отправляет событие error и закрывает поток.
  • Подключение учитывается в поминутном лимите запросов, как и любой другой запрос, поэтому клиент, застрявший в цикле быстрых переподключений, начинает получать ошибки 104.

Что присылает сервер

Так выглядит начало потока в виде необработанного текста (цены приведены для примера):

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}}
СобытиеДанныеЧто делать
pricesПолный снимок: timestamp, date, base, rates, а также unit, если цены указаны не за тройскую унциюЗаменить отображаемые цены
reconnect{"reason":"max connection time reached"}Ничего; переподключиться, когда поток закроется
errorcode, type и info с теми же кодами, что и в REST APIОстановиться или повторить позже — в зависимости от кода
: ping …Строка-комментарий каждые 15 секундПарсеры её игнорируют; это признак того, что соединение живо

Код клиента определяют три свойства потока. Каждое событие prices — это полный снимок запрошенных символов, а не дельта, поэтому после переподключения клиент снова получает актуальные данные с первым же событием. Это первое событие приходит сразу после подключения. Дальше события отправляются только при изменении курсов: сервер проверяет их с интервалом вашего тарифа и молчит, если ничего не изменилось, — для металлов в выходные это нормально. Тишина с пингами — это нормально; тишина без пингов означает, что соединение оборвалось.

У событий нет id, поэтому Last-Event-ID здесь не используется. Воспроизводить пропущенное нечего: каждый снимок заменяет всё, что было до него. Ключи в rates соответствуют формату REST: USDXAU — цена одной тройской унции золота в долларах США, а XAU — обратная величина.

В браузере с EventSource

EventSource сам управляет соединением, разбором событий и переподключениями. Он не умеет отправлять собственные заголовки, поэтому ключ передаётся в строке запроса. Используйте ключ, ограниченный вашими доменами: браузер отправляет с запросом заголовок Origin, и ограниченный ключ отклоняет потоки, открытые с других сайтов, с ошибкой 107.

Браузер
// Используйте ключ, ограниченный вашими доменами: его видит любой, кто откроет страницу.
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) {
    // MetalAPI отправляет это перед закрытием потока: {"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(); // переподключение снова завершится ошибкой
  } else if (es.readyState === EventSource.CLOSED) {
    console.warn("Stream refused; check the key and plan with /usage");
  }
  // Иначе соединение оборвалось, и браузер переподключится сам.
});

Слушатель error получает два разных вида событий. Проблема с соединением приходит как обычный Event, и EventSource сам переподключается через 5 секунд, заданных строкой retry от сервера. Ошибка, отправленная MetalAPI, приходит как MessageEvent, в поле data которого находится ошибка в формате JSON, и сразу после неё сервер закрывает поток. Для кодов, которые повторная попытка не исправит, вызывайте close(), иначе браузер будет переподключаться снова и снова и каждый раз получать отказ.

Если уже первый ответ — HTTP-ошибка, например 106, потому что тариф не включает стриминг, EventSource не даёт доступа к телу ответа и не повторяет попытку: readyState становится CLOSED. Чтобы выяснить причину, проверьте тариф ключа через бесплатный эндпоинт usage.

Прямые потоки из браузера подходят для внутренних дашбордов с небольшим числом пользователей. Каждая открытая вкладка — это отдельный поток: он стоит один запрос в минуту и учитывается в лимите 5 одновременных потоков на ключ. Для публичной страницы держите один поток на своём сервере и раздавайте его браузерам, как это делает тикер ниже.

В Node.js с fetch

На сервере ключ можно передать в заголовке, а переподключениями — управлять точно. В Node.js 18 и новее есть fetch с потоковым телом ответа, поэтому полноценный клиент умещается в один модуль без зависимостей:

metal-stream.ts
// metal-stream.ts — Node.js 18+, без зависимостей
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]); // повторная попытка не поможет
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; // сервер присылает `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 пропущенных пинга
    };
    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; // комментарий-пинг
            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`: затем сервер закрывает соединение, и цикл подключается заново.
        }
      }
    } catch (err) {
      if (err instanceof FatalStreamError) throw err;
      failures++;
      console.warn("stream interrupted:", (err as Error).message);
    } finally {
      clearTimeout(watchdog);
    }
    // 5 с после штатного закрытия, а при повторных сбоях — растущая пауза, максимум минута.
    const backoff = Math.min(60_000, retryMs * 2 ** Math.min(failures, 4));
    await sleep(failures ? backoff + Math.random() * 1_000 : retryMs);
  }
}

Используйте его из любого скрипта или сервиса:

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); // код из FATAL: проверьте ключ, тариф или квоту
  process.exit(1);
});

Парсер следует формату SSE: события разделяются пустой строкой, event: задаёт имя события, строки data: объединяются через перевод строки, а строки, начинающиеся с двоеточия, — это комментарии. Подойдёт и любая клиентская библиотека SSE, которая позволяет задавать заголовки запроса, если она соблюдает правила переподключения из следующего раздела.

Переподключения и обработка сбоев

Модуль выше следует небольшому набору правил, которые нужны любому клиенту потока:

СитуацияПризнакРеакция
Ежечасная ротацияСобытие reconnect, затем поток завершаетсяПереподключиться после задержки retry от сервера (5 с)
Обрыв сетиОшибка чтения или завершение потока без reconnectПереподключаться с экспоненциальной задержкой и случайным разбросом, с паузой не более 60 с
Молчащее соединениеНи данных, ни пингов в течение 45 сПрервать и переподключиться: пропущено три пинга
Временный отказ104, 500, 503Подождать и повторить
Постоянный отказ101, 102, 103, 105, 106, 107Остановиться и оповестить человека

Сторожевой таймер важнее, чем кажется. Соединение, которое обрывается без TCP-сброса, например за NAT-шлюзом или балансировщиком нагрузки, может минутами выглядеть открытым, прежде чем операционная система это заметит. При пинге каждые 15 секунд клиент обнаружит обрыв в течение 45 секунд.

Остерегайтесь циклов переподключения: каждое успешное подключение стоит запрос, и каждая попытка учитывается в поминутном лимите. Экспоненциальная задержка ограничивает ущерб, когда что-то постоянно даёт сбой.

Исчерпание квоты (105) заслуживает отдельного оповещения. Поток сообщает о нём событием error при следующей ежеминутной проверке, и переподключение не поможет, пока квота не обновится 1-го числа (UTC) или вы не перейдёте на старший тариф.

Живой тикер цен

Публичный тикер цен не должен открывать поток MetalAPI для каждого посетителя. Вместо этого небольшой ретранслятор держит один поток на сервере и рассылает каждый снимок всем браузерам через собственный SSE-эндпоинт. Тогда расход запросов зависит от числа ретрансляторов, а не посетителей, и ключ никогда не покидает сервер:

relay.ts
// relay.ts — один поток MetalAPI на сервере, любое число браузеров.
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`); // новые зрители сразу получают цены
  clients.add(res);
  req.on("close", () => clients.delete(res));
}).listen(3000, () => console.log("Ticker on http://localhost:3000"));

Ретранслятор сразу отправляет каждому новому зрителю последний снимок, передаёт дальше только метку времени, базовую валюту и курсы и рассылает собственные пинги. Странице хватит нескольких строк, чтобы отрисовать тикер и окрасить каждую цену по направлению её изменения с прошлого обновления:

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>

Запустите его командой METALAPI_KEY=mk_live_... npx tsx relay.ts и откройте http://localhost:3000. Тикер сравнивает каждую цену с предыдущим событием. Чтобы вместо этого показывать изменение за день, один раз получите сегодняшнюю цену открытия через эндпоинт OHLC и сравнивайте с ней каждую новую цену.

Советы для продакшена

  • Отключите буферизацию в прокси. MetalAPI отправляет X-Accel-Buffering: no и Cache-Control: no-transform. Если перед вашим ретранслятором стоит nginx или другой прокси, отключите буферизацию ответов для SSE-маршрута (proxy_buffering off; в nginx), иначе события будут приходить пачками.
  • Не сжимайте поток событий. Middleware сжатия может придерживать небольшие фрагменты, пока не заполнится его буфер. Исключите из сжатия ответы text/event-stream.
  • Запускайте ретранслятор как долгоживущий процесс. У serverless-функций обычно есть ограничение на время выполнения, поэтому они плохо подходят для круглосуточно открытого потока.
  • Показывайте время данных. Отображайте timestamp события, а не время его получения браузером, чтобы зрители могли отличить спокойный рынок в выходные от зависшей ленты.
  • Не перегружайте экранные дикторы. Не помещайте быстро обновляющийся тикер в область aria-live: озвучивание каждого обновления только мешает.
  • Планируйте квоту. Один постоянно открытый поток расходует около 45 400 запросов в месяц, поэтому тарифа Pro хватит на два таких потока и обычные вызовы API. Проверить расход можно бесплатно через эндпоинт usage.

Параметры потока перечислены в документации API, а на странице тарифов видно, какие тарифы его включают.

Попробуйте на своих данных

Создайте бесплатный аккаунт и отправьте первый запрос меньше чем за две минуты. 100 запросов в месяц, без банковской карты.

Читайте также