GET /v1/events — поток server-sent events, по которому приходит каждая смена статуса ваших генераций. Отправили генерацию — держите поток открытым и узнаёте о её продвижении, ничего не спрашивая. Маршруту нужна область generations:read, та же, что и чтению генераций.
Подключение
Поток открывается обычным GET с ключом в Authorization и отвечает 200 с Content-Type: text/event-stream. Соединение остаётся открытым, а события приходят по мере того, как они происходят. Поток открывается с текущего момента: прошлые события новое подключение не пересказывает. Поэтому открывайте поток до первой отправки генерации, а если подключились позже — один раз запросите POST /v1/generations/status по отправленным генерациям, у которых ещё нет финального статуса.
curl -N https://api.coremind.ru/v1/events \
-H "Authorization: Bearer cmk_..." \
-H "Accept: text/event-stream"Каждое событие — кадр generation.status_changed с полями event:, id: и data:. В data пять полей: id генерации, type, новый status, признак deleted и updated_at — когда переход записан. Строка, начинающаяся с двоеточия, — пульс, о нём ниже.
event: generation.status_changed
id: 128
data: {"id":"<generation_id>","type":"video","status":"RUNNING","deleted":false,"updated_at":"2026-09-24T09:15:02.418Z"}
: pingФильтр по типу
?types= принимает опубликованные типы генераций через запятую — те же значения, что type в записи генерации. В поток попадут только события этих типов. Неизвестный тип — отказ 400 с кодом invalid_request, а не тихо пустой поток: иначе каждое переподключение повторяло бы ту же ошибку.
curl -N "https://api.coremind.ru/v1/events?types=video,image" \
-H "Authorization: Bearer cmk_..." \
-H "Accept: text/event-stream"Переподключение без потерь
У каждого кадра, кроме пульса, есть id:. Это непрозрачный курсор вашего аккаунта: сравнивать, разбирать и собирать его не нужно — только сохранить последний полученный. Соединение оборвалось — подключитесь снова и передайте его в заголовке Last-Event-ID: поток продолжится сразу после этого события, ничего не потеряв и ничего не повторив, на каком бы сервере ни открылось новое соединение.
curl -N https://api.coremind.ru/v1/events \
-H "Authorization: Bearer cmk_..." \
-H "Accept: text/event-stream" \
-H "Last-Event-ID: 128"Обрывы долгих соединений — обычный режим, а не авария: на сетях, где долгие соединения режут, клиент будет переподключаться постоянно. Поэтому переподключение по Last-Event-ID — основной путь, и строить клиента нужно вокруг него.
Браузерный EventSource сам запоминает последний id: и переподключается, но не умеет отправить заголовок Authorization, а ключ из браузера не принимается вовсе — CORS для этого канала закрыт. Держите поток на своём сервере. Пример клиента на Node.js: он читает поток через fetch, запоминает последний id: и возвращает его в Last-Event-ID при каждом переподключении.
const EVENTS_URL = 'https://api.coremind.ru/v1/events';
let lastEventId;
async function follow() {
for (;;) {
const headers = {
Authorization: `Bearer ${process.env.COREMIND_API_KEY}`,
Accept: 'text/event-stream',
};
if (lastEventId !== undefined) headers['Last-Event-ID'] = lastEventId;
try {
const res = await fetch(EVENTS_URL, { headers });
if (res.status === 429) {
await pause(Number(res.headers.get('retry-after') ?? 5));
continue;
}
if (!res.ok) {
// Отказ — обычный JSON с кодом ошибки: переподключение его не исправит.
const { error } = await res.json();
console.error('stream refused:', error.code);
return;
}
await readFrames(res.body);
} catch (err) {
console.error('stream dropped:', err);
}
await pause(1);
}
}
async function readFrames(body) {
const decoder = new TextDecoder();
let buffer = '';
for await (const chunk of body) {
buffer += decoder.decode(chunk, { stream: true });
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
handleFrame(buffer.slice(0, end));
buffer = buffer.slice(end + 2);
}
}
}
function handleFrame(frame) {
const fields = { data: '' };
for (const line of frame.split('\n')) {
if (line.startsWith(':')) continue; // пульс
const colon = line.indexOf(':');
const name = line.slice(0, colon);
const value = line.slice(colon + 1).replace(/^ /, '');
if (name === 'data') fields.data += value;
else fields[name] = value;
}
if (fields.id !== undefined) lastEventId = fields.id;
if (!fields.event) return;
const data = JSON.parse(fields.data);
if (fields.event === 'generation.status_changed') onStatusChanged(data);
if (fields.event === 'reset') catchUp(); // POST /v1/generations/status
if (fields.event === 'terminated') console.error('stream ended:', data.error.code);
}
const pause = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000));Кадр reset
Если присланный Last-Event-ID продолжить нельзя, поток всё равно отвечает 200, первым кадром присылает reset и дальше идёт как обычно. Причин две:
cursor_expired— события после этого курсора уже удалены: поток хранит историю за окно в днях, равное7;cursor_unknown— курсор испорчен, не выдавался или принадлежит не этому потоку.
У reset есть свежий id: — сохраните его, как любой другой, иначе следующее переподключение упрётся в тот же разрыв. Пропущенное между старым курсором и этим кадром нужно добрать отдельно: поле catch_up указывает на POST /v1/generations/status — пакетное чтение статусов из быстрого старта, которому передают генерации, чей исход ещё не известен.
event: reset
id: 131
data: {"reason":"cursor_expired","catch_up":"/v1/generations/status"}Кодом ошибки reset не является намеренно: на ответ не 200 браузерный EventSource закрывает поток навсегда и больше не переподключается.
Кадр terminated
Доступ проверяется не только при открытии, но и пока поток открыт. Если ключ отозвали, у аккаунта забрали доступ к API или API выключен, поток присылает кадр terminated и закрывается. В его data — обычное тело ошибки с кодом причины, тот же конверт, что у любого отказа. Автоматическое переподключение после этого получит тот же отказ уже обычным ответом с HTTP-статусом — дальше переподключаться незачем. Полный словарь кодов — на странице кодов ошибок.
event: terminated
id: 137
data: {"error":{"code":"api_key_revoked","message":"This API key has been revoked.","user_message":"Этот API-ключ отозван.","request_id":"8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11"}}Что попадает в поток
- Каждая смена статуса, включая промежуточные: генерация, созданная отправкой, тоже приходит событием.
- Удаление — событие с
deleted: true, даже если статус при этом не изменился: удалённая готовая генерация приходит со своим прежнимstatus. - Отмена — это
status: "FAILED"вместе сdeleted: true, одним событием. Отдельного статуса «отменено» нет, а сама запись после отмены отвечает404. FAILEDне всегда последнее слово: если результат пришёл с опозданием, та же генерация перейдёт вDONE, и этот переход придёт отдельным событием.
Событие сообщает, какая генерация сдвинулась, но не несёт её данных. Результат, ссылки и возврат кредитов перечитывайте через GET /v1/generations/{id} — или пакетом через POST /v1/generations/status, если событий пришло сразу много.
Лимиты
- Пульс — строка-комментарий, начинающаяся с двоеточия; интервал в секундах —
15.EventSourceего пропускает сам, собственный разборщик должен пропускать тоже. Долгая тишина без пульса означает мёртвое соединение: его стоит закрыть и открыть снова. - Одновременных потоков на один ключ —
10, на всех серверах вместе. Следующий получит429с кодомtoo_many_streams, пока один из открытых не закроется. - Каждое открытие и каждое переподключение расходует один запрос из окна частоты, как любой вызов
/v1; кадры открытого потока ничего не стоят. Исчерпанное окно —429с кодомrate_limitedиRetry-After. Окна описаны на странице лимитов.
HTTP/1.1 429 Too Many Requests
{
"error": {
"code": "too_many_streams",
"message": "This key already holds the maximum number of open event streams. Close one before opening another.",
"user_message": "Для этого ключа уже открыто максимальное число потоков событий. Закройте один из них, прежде чем открывать новый.",
"request_id": "8f3c1d2e-5a47-4b90-9a1e-2f6b0c7d4e11"
}
}