> ## Documentation Index
> Fetch the complete documentation index at: https://docs.finesse.flamapp.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Create a direct realtime session

> Stream speech straight from the synthesis GPU, without a relay in between.

Creates a realtime session that your client runs **directly against the synthesis GPU** in the
region you choose. Audio doesn't pass through the Finesse API, so each sentence starts sooner than
on the [Realtime WebSocket](https://docs.finesse.flamapp.ai/voice-api/realtime-websocket.md).

**1. Create the session on your server**

Call this endpoint with your API key and the voices the session may use. The response has a
WebSocket `url`, a `protocol` and a single-use `token`.

**2. Open the socket from your client**

`new WebSocket(url, [protocol, token])` before `connect_by`. The token travels in the subprotocol
list, so this works from a browser and your API key never leaves your server.

**3. Speak the same protocol**

Send `task_start` / `task_continue` / `task_cancel` / `session_end` and receive `task_started`,
`audio_chunk` + binary frames, `sentence_done` and `task_done`, exactly as described on the
[Realtime WebSocket](https://docs.finesse.flamapp.ai/voice-api/realtime-websocket.md#messages-you-send) page.

## How it differs from the Realtime WebSocket

| | Realtime WebSocket | Direct session |
| - | - | - |
| Where you connect | `fi.production.flamapis.com/.../public/stream` | the GPU `url` in this response |
| Auth | API key header or a `fin_tok_…` token | the `ft1.…` token in this response |
| Voices | any voice your workspace can use | only the `voice_ids` you list here |
| Credits | reserved in blocks of up to 1,000 characters as you go | the whole `budget_chars` reserved up front |
| Session length | until you close it or it is idle for 600 s | at most `ttl_seconds`, and at most `budget_chars` characters |
| Region | the service's region | `ap-south` or `us-east`, your choice |

Use a direct session when per-sentence latency matters most, for example a voice agent with
callers far from India: create it in `us-east` for callers in the Americas.

#### Authorization

**X-API-Key** `string` *required*

Your workspace API key (`flv_…`). **Required scope:** `tts`. Your plan must include streaming.

#### Body

**voice\_ids** `string[]` *required*

The voices this session may use: 1 to 16 of your voices or public platform voices, each enrolled.
Duplicates are ignored. A `task_start` for any other voice gets a non-fatal `voice_not_found`
error frame; the rest of the session continues.

**region** `string`

The GPU region to run in: `ap-south` (India) or `us-east` (US East). Omit it for the default
region. Any other value returns `400`.

**budget\_chars** `integer` (default: 20000)

Characters to reserve for this session, `1`–`200000`. They're taken from your balance now, and
whatever the session doesn't use is returned when it ends. If your balance can't cover the
budget, a budget of 1,000 is reserved instead and returned in `data.budget_chars`.

#### Response

`201 Created`. The response carries `Cache-Control: no-store`.

**data.session\_id** `string`

The session's id.

**data.url** `string`

The WebSocket URL of the GPU region, e.g.
`wss://…ap-south.modal.direct/v1/stream`. Connect to this URL as returned; don't build it yourself.

**data.protocol** `string`

Always `finesse.v1`. Offer it as the first subprotocol.

**data.token** `string`

The session token (`ft1.…`). Offer it as the second subprotocol. It works for **one** connection,
opened before `connect_by`, and is shown only in this response.

**data.connect\_by** `string`

RFC 3339 time by which you must connect, 60 seconds after creation.

**data.ttl\_seconds** `integer`

The longest the session can run once connected (`3600`).

**data.budget\_chars** `integer`

The characters actually reserved. The session can't synthesize more than this.

**data.region** `string`

The region the session runs in.

**data.voices** `string[]`

The voice ids the session may use.

```bash cURL theme={null}
curl -X POST "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/realtime/sessions" \
  -H "X-API-Key: $FINESSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"voice_ids":["665f1a2b3c4d5e6f7a8b9c0d"],"region":"us-east","budget_chars":5000}'
```

```javascript JavaScript (your server) theme={null}
// A route your own frontend calls once the user is signed in to YOUR app.
export async function POST() {
  const res = await fetch(
    "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/realtime/sessions",
    {
      method: "POST",
      headers: {
        "X-API-Key": process.env.FINESSE_API_KEY,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ voice_ids: ["665f1a2b3c4d5e6f7a8b9c0d"], region: "us-east" }),
    },
  );
  if (!res.ok) return new Response("could not start a session", { status: 502 });
  const { data } = await res.json();
  return Response.json({ url: data.url, protocol: data.protocol, token: data.token });
}
```

```javascript JavaScript (browser) theme={null}
const { url, protocol, token } = await fetch("/api/finesse-session", { method: "POST" })
  .then((r) => r.json());

const ws = new WebSocket(url, [protocol, token]);
ws.binaryType = "arraybuffer";
ws.addEventListener("open", () => {
  ws.send(JSON.stringify({
    type: "task_start",
    context_id: "ctx-1",
    voice_id: "665f1a2b3c4d5e6f7a8b9c0d", // must be one of the session's voices
    text: "Hello from Finesse, straight from the GPU.",
    finalize: true,
    output_format: "s16le",
  }));
});
// Handle messages and binary audio as on the Realtime WebSocket page.
```

```json Response theme={null}
{
  "status": 201,
  "error": false,
  "data": {
    "session_id": "4f1c2a9e8b7d6c5b4a3f2e1d",
    "url": "wss://…us-east.modal.direct/v1/stream",
    "protocol": "finesse.v1",
    "token": "ft1.eyJraWQiOi…",
    "connect_by": "2026-10-30T10:15:00Z",
    "ttl_seconds": 3600,
    "budget_chars": 20000,
    "region": "us-east",
    "voices": ["665f1a2b3c4d5e6f7a8b9c0d"]
  }
}
```

## Connecting

The server selects and echoes `finesse.v1`. The token is checked once the socket is open: a token
that is invalid, changed, already used, or used after `connect_by` closes the socket with code
`4401` (reason `unauthorized`). Create a new session to try again; a token can never be reused,
including to reconnect after a dropped connection.

## Limits

* **Budget.** Every character of `text` you send counts against `budget_chars`. The frame that would
  go over it is refused with a fatal error frame, and the socket closes with `1008` (reason
  `insufficient_credits`):

  ```json theme={null}
  { "type": "error", "code": "insufficient_credits", "message": "session character budget exhausted; create a new session", "fatal": true }
  ```

  Create a new session, with a larger `budget_chars` if you need one.
* **Length.** A session ends after `ttl_seconds` at most, however much budget is left.
* **Concurrency.** Each session holds one of your workspace's
  [concurrent stream slots](https://docs.finesse.flamapp.ai/guides/rate-limits.md) from creation until it ends, or until shortly after
  `connect_by` if it never connects. Direct sessions and [Realtime WebSocket](https://docs.finesse.flamapp.ai/voice-api/realtime-websocket.md)
  connections share the same slots. When every slot is in use, creating a session returns `429`
  with code `concurrency_limited` and a `Retry-After` header. Close a session (`session_end`) to
  free its slot.

## Billing

Characters are billed like every other synthesis: per character of `text` in `task_start` and
`task_continue` frames. The whole `budget_chars` is reserved from your balance when the session is
created, so your balance dips by that much while the session is open. When the session ends, you're
charged for the characters it used and the rest is returned. A session that is never connected is
returned in full shortly after `connect_by`.

If your balance can't cover even 1,000 characters, creating a session returns `402`.

## Errors

| Status | `code` | When |
| - | - | - |
| `400` | `invalid_request` | the body isn't valid JSON, `voice_ids` is empty or has more than 16 ids, `region` is unknown, `budget_chars` is out of range, or a voice isn't enrolled |
| `401` | `unauthorized` | the API key is missing or invalid |
| `402` | `payment_required` | the balance can't cover a 1,000-character budget, or your allowance is exhausted (the plan check's flat body, see [Errors](https://docs.finesse.flamapp.ai/voice-api/errors.md)) |
| `403` | `insufficient_scope` | the key lacks the `tts` scope, or streaming isn't in your plan (flat body, `feature_disabled`) |
| `404` | `not_found` | a voice doesn't exist or your workspace can't use it |
| `429` | `rate_limited` | over your plan's requests per second; wait `Retry-After` seconds |
| `429` | `concurrency_limited` | every concurrent stream slot is in use; wait `Retry-After` seconds, or close a session |

## Close codes

| Code | Meaning |
| - | - |
| `1000` | the session ended normally (reason `session_ended`), e.g. after your `session_end` |
| `1008` | the session's character budget is used up (reason `insufficient_credits`) - create a new session |
| `4401` | the token was refused (reason `unauthorized`) - create a new session |
| `1006` | no close frame: the network dropped - create a new session |

## Prompt

Copy this into your coding agent (Claude Code, Cursor, Codex…) to implement this endpoint.

```text theme={null}
You are adding direct realtime sessions to a Finesse Voice API client. Everything you need is in
this prompt and on https://docs.finesse.flamapp.ai/voice-api/realtime-websocket (the message
protocol). Do not invent anything that is not specified.

1. Server side, create a session:
   POST https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/realtime/sessions
   Header X-API-Key: <key from FINESSE_API_KEY> (scope tts). JSON body:
     voice_ids     required, 1-16 voice ids the session may use
     region        optional, "ap-south" or "us-east"
     budget_chars  optional, 1-200000, default 20000
   Response data: session_id, url, protocol ("finesse.v1"), token ("ft1..."), connect_by (RFC 3339),
   ttl_seconds, budget_chars (may be lower than asked: 1000 when the balance is short), region,
   voices. Hand only url, protocol and token to the client; never ship the API key to a browser.
2. Client side: new WebSocket(url, [protocol, token]) before connect_by, binaryType arraybuffer.
   Then use the Realtime WebSocket message protocol unchanged. voice_id must be one of the
   session's voices; any other gets a non-fatal "voice_not_found" error frame.
3. Close codes: 1000 normal end; 1008 "insufficient_credits" (budget used up); 4401 "unauthorized"
   (token invalid, reused or late); 1006 network drop. The token is single use: on any close,
   create a NEW session before reconnecting, and never replay text that was already sent.
4. Errors creating a session: 400 invalid input, 401 bad key, 402 balance too low, 403 scope or
   plan, 404 unknown voice, 429 code "rate_limited" (requests per second) or "concurrency_limited"
   (every concurrent stream slot in use): wait the Retry-After seconds, then retry. Do not retry
   400/401/402/403/404 automatically.
5. Each session holds a concurrent stream slot, shared with Realtime WebSocket connections, until it
   ends; send session_end when done.
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.