Skip to content
Lumean API

Realtime: events over WebSocket

You do not have to poll order status, voice cloning progress or gift codes: the server pushes events over WebSocket. The protocol is Pusher, the server is Reverb, and every subscription is private and authorized with your API key. Below: connection parameters, the four authorization steps, the channels and the two things people trip over.

Connection parameters

The values come from AsyncAPI, not from prose. Host, port and scheme must be set by hand: there is no Pusher cloud cluster here, the server is self-hosted.

ParameterValueWhat it means
Hostws.lumean.appThe WebSocket server address: wsHost in pusher-js, wsHost plus wssHost in Laravel Echo.
Port443The same value for wsPort and wssPort — there is no separate non-TLS port.
Schemewsswss means forceTLS: true. A plain ws connection from a production domain will not go through.
App key7c4dd881d8116ac76fe89aff71d8c2ffThe Reverb application id. It is neither a secret nor your API key — every client uses the same one.
Subscription authhttps://api.lumean.app/api/public/broadcasting/authThe client posts socket_id and the channel name here to get a signature. Your API key travels only in this HTTP request.

Tip

The app key is public: it ships in the frontend bundle and is visible in the browser of any Pusher client. There is exactly one secret here — your API key — and it travels in the X-API-KEY header of the subscription auth request, not over the socket.

How a subscription is authorized

You cannot simply listen to a private channel. The client library goes through four steps, and knowing them is what tells you at which one your 403 came from.

  1. The socket opens

    The client connects to the host using the app key. Your API key is not involved yet — the socket opens even with an invalid key.

  2. socket_id arrives

    The server replies with pusher:connection_established carrying socket_id, which identifies this particular connection.

  3. The subscription is signed

    The client posts socket_id and the channel name to POST /broadcasting/auth with an X-API-KEY header. The server checks the key, the scope and channel ownership, then returns a signature. This is where 401, 403 and 429 come from.

  4. Subscribed, events flow

    The client hands the signature back, the channel confirms with pusher:subscription_succeeded — and only then do the channel events start arriving.

orders.readvoices.readreseller.read— the scopes checked at step three: each channel has its own, see the channel table below.

Connection snippets

Three clients, the same sequence. The key in the code is a placeholder: substitute yours, but do not commit it.

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': 'YOUR_API_KEY' },
  },
});

const channel = pusher.subscribe('private-orders.{userId}');
channel.bind('policy_cooldown.applied', (data) => {
  console.log(data);
});

Channels

The channels are private: names start with private-, and you substitute the parameter in braces yourself. Each channel requires its own scope — it is listed in the table.

Two traps

The .progress event exists on two channels — with different payloads

Same name, different shape. On the user orders channel it counts completed chunks of an order; on a single-order channel it is task progress in percent with an ETA. One handler bound to both channels will read the second event as the first and silently get undefined.

// private-orders.{userId} → .progress
{ "order_id": "…", "completed": 3, "total": 7, "percent": 43 }
// private-order.task.{orderId} → .progress
{ "task_id": "…", "progress": 43, "chunks_done": 3, "chunks_total": 7, "eta_seconds": 12 }

Split handlers by channel, not by event name. The full field tables live on the channel pages.

The leading dot: required in Echo, wrong in pusher-js

Laravel Echo prefixes event names with the application namespace, and the leading dot cancels that. pusher-js has no namespace at all, so the event name carries no dot. Mix them up and the subscription works, no events arrive, and no error is raised.

echo.private(channel).listen('.status.changed', handler)
channel.bind('status.changed', handler)

Authorization errors

All three arrive as an HTTP response to POST /broadcasting/auth, not over the socket. The socket is already open by then — which is why “connected” says nothing about your scopes.

CodeWhen it happensWhat to do
401The X-API-KEY header is missing, or the key is revoked or truncated.Verify the key with GET /user. The library takes the key in the subscription auth request headers, not in its constructor.
403The key works but lacks the scope for this channel (orders.read for orders, voices.read for voice clones, reseller.read for gift codes) — or you are subscribing to someone else’s channel: the userId and orderId in the name must belong to the key owner.Add the scope in your account and substitute your own identifier. A subscription will never hand you someone else’s order.
429Too many authorization attempts in a row — usually a reconnect loop with a bad key.Stop reconnecting, fix the key, connect again. Exponential backoff helps, but the cause is almost always the 401 one row above.

POST /broadcasting/auth

This endpoint is deliberately absent from the reference: nobody calls it by hand, the client library does. But when a subscription returns 403, this request is what you will be debugging — so it is documented here.

POST/broadcasting/authorders.read

Authorizes a subscription to a private WebSocket channel using your API key. Client libraries (Laravel Echo, pusher-js) call this endpoint themselves — point their authEndpoint at it and add X-API-KEY to the auth headers.

Available channels are your own orders (private-orders.{userId}, private-order.task.{orderId}) and chunk-preview results (private-chunks.preview.{userId}, see POST /orders/chunks/preview) — permission orders.read — plus your Cartesia voice clones (private-cartesia-voice-clones.{userId}, see POST /voices/cartesia/clones) and Fish Audio voice clones (private-fishaudio-voice-clones.{userId}, see POST /voices/fishaudio/clones) — permission voices.read. Someone else's channel returns 403.

Subscribing removes the need to poll GET /orders/{order}: progress and completion arrive as events.

Required key permission: orders.read.

Request body

socket_idrequired

The WebSocket connection id delivered to the client in the pusher:connection_established event.

string
Example 12345.67890
channel_namerequired

Channel name. A key may only reach its own channels: orders private-orders.{userId} and private-order.task.{orderId}, chunk previews private-chunks.preview.{userId} (permission orders.read), Cartesia voice clones private-cartesia-voice-clones.{userId} and Fish Audio voice clones private-fishaudio-voice-clones.{userId} (permission voices.read), reseller gift codes private-giftcodes.{userId} (permission reseller.read).

string
Example private-orders.42

Possible responses

200

Subscription authorized. The client receives the subscription auth token.

Body example
{
  "auth": "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"
}
Response fields
authrequired

The channel subscription signature. Hand it back to the Pusher/Echo client verbatim — it fills the value in itself. This response carries no envelope: the protocol dictates the shape.

string
Example a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890
401

The key is missing, unknown, disabled or expired.

Analyse this response
Body example

This response has no body.

403

The key lacks the permission the channel requires (orders.read for orders and chunk previews, voices.read for Cartesia and Fish Audio voice clones), or the channel belongs to someone else.

Analyse this response
Body example

This response has no body.

422

Invalid socket_id/channel_name (for example a channel_name without the private- prefix).

Analyse this response
Body example

This response has no body.

429

Rate limit exceeded — 1200 requests per minute.

Analyse this response
Body example

This response has no body.

Authentication and scopes·Guide: realtime instead of polling

Feed this page to an AI

Open .mdllm.md

spec 0.0.1