Realtime: подключение и подписка
Pusher-совместимый WebSocket: каналы заказов, клонов голоса и подарочных кодов. Почему `progress` нельзя разбирать, не глядя на канал.
Статусы заказов приходят по WebSocket в Pusher-совместимом протоколе: подходят pusher-js
и Laravel Echo, своего SDK у Lumean нет. Подписка бесплатна и окна ключа не расходует — их
тратит только REST.
Подключение
| Параметр | Значение |
|---|---|
| Хост / порт / схема | ws.lumean.app / 443 / wss (TLS обязателен) |
| App key | 7c4dd881d8116ac76fe89aff71d8c2ff |
cluster | пустая строка (кластеров у сервера нет) |
| Транспорты | ['ws', 'wss'] |
| Auth-эндпоинт | POST https://api.lumean.app/api/public/broadcasting/auth |
App key публичный: он идентифицирует приложение, а не вас, и лежит открытым в спеке. Секрет
здесь один — X-API-KEY, и он уходит только на auth-эндпоинт, в кадры WebSocket не попадая.
import Pusher from 'pusher-js'
const pusher = new Pusher('7c4dd881d8116ac76fe89aff71d8c2ff', {
wsHost: 'ws.lumean.app', wsPort: 443, wssPort: 443,
forceTLS: true, enabledTransports: ['ws', 'wss'], cluster: '',
channelAuthorization: {
endpoint: 'https://api.lumean.app/api/public/broadcasting/auth',
headers: { 'X-API-KEY': process.env.LUMEAN_API_KEY },
},
})
const ch = pusher.subscribe('private-orders.42') // 42 = user_id владельца ключа
ch.bind('status.changed', (e) => console.log(e.previous_status, '→', e.new_status))У Laravel Echo те же параметры (authEndpoint + auth.headers), но префикс private- он
ставит сам: echo.private('orders.42').
Авторизация подписки
Приватный канал требует подписи, и библиотека получает её сама: socket_id из кадра
pusher:connection_established уходит вместе с channel_name (с префиксом private-)
на POST /broadcasting/auth под заголовком X-API-KEY, ответная строка auth кладётся
в кадр pusher:subscribe. Право зависит от канала: orders.read у заказных и канала превью, voices.read у каналов клонов голоса, reseller.read у канала кодов. Ответ идёт
вне общего конверта API: ни success, ни data в нём нет, только auth.
| Код | Что значит | Что делать |
|---|---|---|
401 | Ключа нет, он неизвестен, деактивирован или истёк | Ретрай бесполезен; проверить ключ через GET /user |
403 | Нет права этого канала (orders.read, voices.read или reseller.read) либо канал чужой (владелец сверяется по user_id в имени) | Брать user_id из GET /user, а не из своей конфигурации |
422 | Невалидные socket_id/channel_name — чаще всего имя без private- | Чинить имя: то же тело даст тот же ответ |
429 | Лимит auth-эндпоинта — 300 запросов в минуту | Не переподписываться в цикле: одно соединение держит все каналы |
Ловушка
300 — число из OpenAPI (и описание операции, и текст ответа 429); llm.md §10 называет 1200
в минуту. Расхождение не разрешено — планируйте по меньшему. Лимит у эндпоинта собственный:
окна ключа он не расходует.
Каналы
| Канал | Что шлёт | Событий | Параметр | Право |
|---|---|---|---|---|
private-orders.{userId} | все заказы аккаунта | 14 | числовой id владельца ключа из GET /user | orders.read |
private-order.task.{orderId} | один заказ | 8 | UUID заказа (data.id из POST /orders) | orders.read |
private-chunks.preview.{userId} | итог фонового превью разбиения | 1 | тот же числовой userId | orders.read |
private-cartesia-voice-clones.{userId} | ход клонирования голосов Cartesia | 1 | тот же числовой userId | voices.read |
private-fishaudio-voice-clones.{userId} | ход клонирования голосов Fish Audio | 1 | тот же числовой userId | voices.read |
private-giftcodes.{userId} | подарочные коды и статус реселлера | 4 | тот же числовой userId | reseller.read |
Ловушка
В AsyncAPI параметр orderId объявлен integer — ошибка генератора, верно текстовое описание:
идентификатор заказа везде UUID, его и подставляйте. А {userId} в остальных каналах и правда
целое число.
Get profile of API key owner
Канал аккаунта (14): created, status.changed, item.status.changed, progress,
bulk_retry.completed, paragraph_regenerate.completed, voice_search.queue.updated,
renamed, stitch.changed, policy_cooldown.applied/.cleared, archive.progress/.ready/.failed.
Канал заказа (8): те же, кроме created, archive.* и policy_cooldown.*.
Канал превью (1): ready (status, preview_id, summary, page) — чанков в нём нет,
их берут через GET /orders/chunks/preview/{previewId}?page=1.
Каналы клонов (по 1): voice_clone.updated на каждую смену статуса клона
(pending → cloning → ready или failed); без WebSocket статус опрашивают списком клонов.
Канал кодов (4): issued, status.changed, reseller.status.changed,
reseller.application.status.changed. Значений кодов в событиях нет — только маски; полный код
отдают GET /reseller/codes и GET /reseller/codes/{uuid}.
Готовность ловите по status.changed (previous_status → new_status), а не по счётчикам
прогресса: последний чанк и терминальный статус — разные моменты.
Ловушка: два разных progress
Событие progress есть на обоих заказных каналах, но payload у них разный, а различить их
можно только по каналу: имя одинаковое, и общий обработчик читает несуществующие поля.
private-orders.{userId} | private-order.task.{orderId} | |
|---|---|---|
| Идентификатор | order_id (UUID) | task_id (тот же UUID заказа) |
| Счётчики | completed / total | chunks_done / chunks_total |
| Доля | percent — целое 0..100 | progress — число, пример 42.5 |
| Ещё | — | duration_seconds, eta_seconds, eta_confidence, timestamp |
Осторожно
Поля progress и chunks_done на канале одного заказа сохранены для совместимости и
фактического прогресса не отражают — так сказано в самой спеке. Настоящие числа — percent
и completed/total с канала аккаунта, оценка времени — eta_seconds с eta_confidence
(при low показывайте со знаком «≈»). Прогресс-бар на progress из order.task врёт молча.
Оба progress считают только исходные чанки: повторы и перегенерации в них не входят, поэтому
у переделанного заказа доля до 100 может не дойти вовсе.
Ловушка: ведущая точка в имени события
Правильно
Echo — с точкой: echo.private('orders.42').listen('.status.changed', fn).
pusher-js — без: pusher.subscribe('private-orders.42').bind('status.changed', fn).
Неправильно
Скопировать имя из примера для Echo в bind('.status.changed'). Подписка не сработает молча:
ошибки нет, соединение живо, событий не приходит ни одного.
Чего в payload нет
createdиstatus.changedнесут снимок заказа без списка чанков — вместо негоitems_summary: { total, by_status }; чанки берите изGET /orders/{order}.- Файлы приходят метаданными: ссылка на скачивание — всегда
POST /storage/url. renamedиarchive.*по ключу инициировать нечем — они живут только в веб-кабинете; событие прилетит, если владелец сделал это руками.policy_cooldown.appliedпредупреждает о кулдауне до того, как вы упрётесь в429: ограничение накладывается при финализации провалившейся задачи, а не в ответ на запрос. Считайте поretry_at(ISO-8601): событие идёт через очередь, иretry_afterстареет в пути.
- Что произошло
- Пятый источник 429 и единственный «заказный», у которого есть `Retry-After`: в теле дополнительно приходят `retry_after` (секунды) и `retry_at` (ISO-8601). Создание новых заказов приостановлено целиком, а не только для спорного текста.
- Что сделать прямо сейчас
- Пауза лечит только формально. Если после кулдауна отправить тот же текст, он снова заблокируется, счётчик пойдёт по новой и ступень вырастет — лечится это правкой текста, а не ожиданием. Любая успешно выполненная задача обнуляет и счётчик, и достигнутую ступень.
Когда WebSocket не нужен
Одиночный короткий заказ дешевле дождаться поллингом GET /orders/{order}: соединение и разбор
событий окупаются на потоке заказов или там, где прогресс видит пользователь. И даже с
подпиской источник истины остаётся у REST — событие может не дойти. Ожидание без WS — в гайде
Асинхронная модель.
Скормить страницу ИИ
Обновлено 17.10.2026 · спека 0.0.1