Realtime: connecting and subscribing
Pusher-compatible WebSocket: channels for orders, voice clones and gift codes. Why you can't parse `progress` without looking at the channel.
Translation in progress
Order statuses arrive over WebSocket using a Pusher-compatible protocol: pusher-js
and Laravel Echo both work, and Lumean has no SDK of its own. Subscribing is free and doesn't consume the key's windows;
only REST uses them.
Connection
| Parameter | Value |
|---|---|
| Host / port / scheme | ws.lumean.app / 443 / wss (TLS required) |
| App key | 7c4dd881d8116ac76fe89aff71d8c2ff |
cluster | empty string (the server has no clusters) |
| Transports | ['ws', 'wss'] |
| Auth endpoint | POST https://api.lumean.app/api/public/broadcasting/auth |
The app key is public: it identifies the application, not you, and is published openly in the spec. The only secret
here is X-API-KEY, and it goes only to the auth endpoint, never into WebSocket frames.
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 of the key owner
ch.bind('status.changed', (e) => console.log(e.previous_status, '→', e.new_status))Laravel Echo takes the same parameters (authEndpoint + auth.headers), but it adds the private- prefix
itself: echo.private('orders.42').
Subscription authorization
A private channel requires a signature, and the library obtains it on its own: the socket_id from the
pusher:connection_established frame is sent along with channel_name (with the private- prefix)
to POST /broadcasting/auth with the X-API-KEY header, and the returned auth string goes
into the pusher:subscribe frame. The required scope depends on the channel: orders.read for the order channels and the preview channel, voices.read for the voice clone channels, reseller.read for the codes channel. The response comes
outside the standard API envelope: it has no success and no data, only auth.
| Code | What it means | What to do |
|---|---|---|
401 | The key is missing, unknown, deactivated, or expired | Retrying is useless; check the key with GET /user |
403 | The key lacks this channel's scope (orders.read, voices.read, or reseller.read) or the channel belongs to someone else (the owner is checked against the user_id in the name) | Take user_id from GET /user, not from your own configuration |
422 | Invalid socket_id/channel_name, most often a name without private- | Fix the name: the same body will get the same response |
429 | Auth endpoint limit: 300 requests per minute | Don't resubscribe in a loop: one connection holds all channels |
Gotcha
300 is the figure from OpenAPI (both the operation description and the 429 response text); llm.md §10 says 1200
per minute. The discrepancy is unresolved, so plan for the lower one. The endpoint has its own limit:
it doesn't consume the key's windows.
Channels
| Channel | What it sends | Events | Parameter | Scope |
|---|---|---|---|---|
private-orders.{userId} | all of the account's orders | 14 | numeric id of the key owner from GET /user | orders.read |
private-order.task.{orderId} | a single order | 8 | order UUID (data.id from POST /orders) | orders.read |
private-chunks.preview.{userId} | result of a background chunking preview | 1 | the same numeric userId | orders.read |
private-cartesia-voice-clones.{userId} | progress of Cartesia voice cloning | 1 | the same numeric userId | voices.read |
private-fishaudio-voice-clones.{userId} | progress of Fish Audio voice cloning | 1 | the same numeric userId | voices.read |
private-giftcodes.{userId} | gift codes and reseller status | 4 | the same numeric userId | reseller.read |
Gotcha
In AsyncAPI the orderId parameter is declared as integer. That is a generator bug, and the text description is correct:
the order identifier is a UUID everywhere, so that is what you pass. The {userId} in the other channels, however, really is
an integer.
Get profile of API key owner
Account channel (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.
Order channel (8): the same, except created, archive.*, and policy_cooldown.*.
Preview channel (1): ready (status, preview_id, summary, page). It contains no chunks;
fetch them with GET /orders/chunks/preview/{previewId}?page=1.
Clone channels (1 each): voice_clone.updated on every clone status change
(pending → cloning → ready or failed); without WebSocket, poll the status through the clone list.
Codes channel (4): issued, status.changed, reseller.status.changed,
reseller.application.status.changed. Events carry no code values, only masks; the full code
is returned by GET /reseller/codes and GET /reseller/codes/{uuid}.
Detect completion by status.changed (previous_status → new_status), not by progress
counters: the last chunk and the terminal status are different moments.
Trap: two different progress events
The progress event exists on both order channels, but its payload is different, and you can tell them apart
only by the channel: the name is the same, and a shared handler reads fields that don't exist.
private-orders.{userId} | private-order.task.{orderId} | |
|---|---|---|
| Identifier | order_id (UUID) | task_id (the same order UUID) |
| Counters | completed / total | chunks_done / chunks_total |
| Fraction done | percent, an integer 0..100 | progress, a number, e.g. 42.5 |
| Also | — | duration_seconds, eta_seconds, eta_confidence, timestamp |
Careful
The progress and chunks_done fields on the single-order channel are kept for compatibility and
do not reflect actual progress; the spec itself says so. The real numbers are percent
and completed/total from the account channel, and the time estimate is eta_seconds with eta_confidence
(when it is low, display it with a “≈” sign). A progress bar driven by progress from order.task lies silently.
Both progress events count only the original chunks: retries and regenerations are not included, so
the percentage of a reworked order may never reach 100 at all.
Trap: the leading dot in the event name
Do
Echo, with the dot: echo.private('orders.42').listen('.status.changed', fn).
pusher-js, without it: pusher.subscribe('private-orders.42').bind('status.changed', fn).
Don't
Copy the name from the Echo example into bind('.status.changed'). The subscription will fail silently:
no error, the connection is alive, and not a single event arrives.
What the payload doesn't contain
createdandstatus.changedcarry an order snapshot without the chunk list; instead there isitems_summary: { total, by_status }. Get the chunks fromGET /orders/{order}.- Files arrive as metadata: the download link always comes from
POST /storage/url. renamedandarchive.*can't be triggered with an API key; they exist only in the web dashboard. The event arrives if the owner did it manually.policy_cooldown.appliedwarns about a cooldown before you hit a429: the restriction is applied when a failed task is finalized, not in response to a request. Go byretry_at(ISO-8601): the event travels through a queue, andretry_aftergoes stale in transit.
- What happened
- The fifth 429 source, and the only "order-side" one that has `Retry-After`: the body additionally includes `retry_after` (seconds) and `retry_at` (ISO-8601). Creation of new orders is suspended entirely, not just for the disputed text.
- What to do right now
- The pause only helps on paper. If you send the same text after the cooldown, it will be blocked again, the counter starts over, and the step goes up — the fix is editing the text, not waiting. Any successfully completed task resets both the counter and the step reached.
When you don't need WebSocket
For a single short order, polling GET /orders/{order} is cheaper: the connection and event parsing
pay off with a stream of orders or where the user watches the progress. And even with
a subscription, REST remains the source of truth, because an event may never arrive. Waiting without WS is covered in the
Asynchronous order model guide.
Feed this page to an AI
Updated 17/10/2026 · spec 0.0.1