---
title: "Realtime: connecting and subscribing"
url: https://lumean.app/tr/developers/docs/guide/realtime
locale: tr
description: "Pusher-compatible WebSocket: channels for orders, voice clones and gift codes. Why you can't parse `progress` without looking at the channel."
updated: 2026-10-17
source_locale: en
sources: [get-user, get-orders-order, post-voices-cartesia-clones, post-voices-fishaudio-clones, get-reseller-codes]
errors: ["401", "403", "429", policy-cooldown-active]
spec_version: 0.0.1
---

# Realtime: connecting and subscribing

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.

```js
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 |

> [!WARNING]
> 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` |

> [!WARNING]
> 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 /user

Get profile of API key owner

Справочник: https://lumean.app/tr/developers/docs/api/get-user

**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` |

> [!CAUTION]
> 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

**Правильно**

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)`.

**Неправильно**

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.

### 429 · `policy_cooldown_active` — policy_cooldown_active — cooldown after policy blocks

| Что произошло | Что делать |
| --- | --- |
| 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. | 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. |

`Retry-After`: есть. Разбор: https://lumean.app/tr/developers/docs/errors/policy-cooldown-active

## 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](/developers/docs/guide/async) guide.

---

- Bu sayfa tarayıcıda: https://lumean.app/tr/developers/docs/guide/realtime
- Makine tarafından okunabilir tam başvuru: https://api.lumean.app/docs/llm.md
