Skip to content
Lumean API

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

This page has not been translated into your language yet — the version below is in English. The text and the code samples are the same.

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

ParameterValue
Host / port / schemews.lumean.app / 443 / wss (TLS required)
App key7c4dd881d8116ac76fe89aff71d8c2ff
clusterempty string (the server has no clusters)
Transports['ws', 'wss']
Auth endpointPOST 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.

CodeWhat it meansWhat to do
401The key is missing, unknown, deactivated, or expiredRetrying is useless; check the key with GET /user
403The 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
422Invalid socket_id/channel_name, most often a name without private-Fix the name: the same body will get the same response
429Auth endpoint limit: 300 requests per minuteDon'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

ChannelWhat it sendsEventsParameterScope
private-orders.{userId}all of the account's orders14numeric id of the key owner from GET /userorders.read
private-order.task.{orderId}a single order8order UUID (data.id from POST /orders)orders.read
private-chunks.preview.{userId}result of a background chunking preview1the same numeric userIdorders.read
private-cartesia-voice-clones.{userId}progress of Cartesia voice cloning1the same numeric userIdvoices.read
private-fishaudio-voice-clones.{userId}progress of Fish Audio voice cloning1the same numeric userIdvoices.read
private-giftcodes.{userId}gift codes and reseller status4the same numeric userIdreseller.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/userGet profile of API key owner

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}
Identifierorder_id (UUID)task_id (the same order UUID)
Counterscompleted / totalchunks_done / chunks_total
Fraction donepercent, an integer 0..100progress, 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

  • created and status.changed carry an order snapshot without the chunk list; instead there is items_summary: { total, by_status }. Get the chunks from GET /orders/{order}.
  • Files arrive as metadata: the download link always comes from POST /storage/url.
  • renamed and archive.* 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.applied warns about a cooldown before you hit a 429: the restriction is applied when a failed task is finalized, not in response to a request. Go by retry_at (ISO-8601): the event travels through a queue, and retry_after goes stale in transit.
429policy_cooldown_activepolicy_cooldown_active — cooldown after policy blocks
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.
Open the code page

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

Open .mdllm.md

Updated 17/10/2026 · spec 0.0.1