Цены на металлы в реальном времени через Server-Sent Events
Цены на золото и серебро в реальном времени через Server-Sent Events: эндпоинт /stream, его события, EventSource и Node.js, переподключения, квоты и тикер.
Стриминг или опрос?
При опросе цены запрашиваются по таймеру. При стриминге одно HTTP-соединение остаётся открытым, и сервер сам присылает новые цены по мере их публикации. Поток MetalAPI работает на Server-Sent Events (SSE): это обычный HTTP-ответ с типом содержимого text/event-stream, который остаётся открытым и доставляет именованные события. Для него не нужна библиотека WebSocket, он проходит через большинство прокси, а браузеры поддерживают его нативно через EventSource.
Окупается ли стриминг, зависит от интервала обновления на вашем тарифе. Поток стоит один запрос при подключении и по одному запросу в минуту, пока он открыт, — независимо от того, как часто меняются цены. При опросе каждый вызов стоит один запрос. Для одного клиента, который следит за ценами круглосуточно в течение месяца из 31 дня:
| Тариф | Интервал обновления | Опрос с этим интервалом | Один постоянно открытый поток |
|---|---|---|---|
| Pro | 1 мин | 44 640 запросов | около 45 400 запросов |
| Ultra, Business | 15 с | 178 560 запросов | около 45 400 запросов |
| Enterprise | 5 с | 535 680 запросов | около 45 400 запросов |
В расход потока включён один запрос на каждое ежечасное переподключение (подробнее ниже). На Pro стриминг и ежеминутный опрос обходятся примерно одинаково; преимущество потока в том, что новые цены приходят сразу после публикации и не нужно настраивать цикл опроса. На Ultra, Business и Enterprise поток к тому же намного дешевле опроса на максимальной частоте.
Стриминг не подходит, если цена должна быть верной лишь в момент открытия страницы, для ночных задач и для проверки пороговых значений. В первых двух случаях используйте кешированный вызов /latest, в третьем — ценовые оповещения с вебхуками.
Эндпоинт /stream
GET https://metalapi.com/api/v1/stream?base=USD¤cies=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.
Что присылает сервер
Так выглядит начало потока в виде необработанного текста (цены приведены для примера):
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"} | Ничего; переподключиться, когда поток закроется |
error | code, 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¤cies=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 — 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¤cies=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);
}
}Используйте его из любого скрипта или сервиса:
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 — один поток 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"));Ретранслятор сразу отправляет каждому новому зрителю последний снимок, передаёт дальше только метку времени, базовую валюту и курсы и рассылает собственные пинги. Странице хватит нескольких строк, чтобы отрисовать тикер и окрасить каждую цену по направлению её изменения с прошлого обновления:
<!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 запросов в месяц, без банковской карты.
Читайте также
API цен на золото: полное руководство для разработчиков
Как работать с API цен на золото: получить ключ, вызвать API из curl, JavaScript и Python, правильно читать курсы, работать с историей, ошибками, квотами и кэшем.
Как добавить на сайт виджет с актуальной ценой золота
Виджет с актуальной ценой золота для WordPress, Shopify или HTML: создайте его в личном кабинете MetalAPI, выберите символы, макет и тему и вставьте две строки.
Цены на золото по каратам: 24K, 22K, 18K и как их рассчитать
Что означают 24K, 22K и 18K, как с ними связаны пробы 916 и 750, как рассчитать цену грамма золота по каратам и как получить её через API цен на золото.