> ## 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 an access token

> Mint a short-lived, single-use token so a browser can synthesize without your API key.

Exchanges your API key for a **short-lived, single-use access token** (`fin_tok_…`). Your server calls
this endpoint, hands the token to the browser, and the browser uses it to open the
[Realtime WebSocket](/voice-api/realtime-websocket) or call [speak](/voice-api/synthesize-speech) /
[stream](/voice-api/stream-speech) directly. The API key itself never leaves your server.

* **Short-lived** - expires after `ttl_seconds` (default 300, max 3600).
* **Single use** - consumed on its first successful use: one WebSocket handshake, or one `speak` /
  `stream` request. A request refused before it runs (a `429` rate limit, a plan check) keeps the
  token, so you can retry with it. Mint a new token for each session or request.
* **Text-to-speech only** - a token grants the `tts` scope and nothing else. It can't list, create or
  clone voices.
* **Optionally voice-restricted** - pass `voice_ids` to limit which voices the token can synthesize with.
* **Tied to its key** - it uses the creating key's workspace and stops working if that key is revoked or
  expires.

#### Authorization

<ParamField header="X-API-Key" type="string" required>
  Your workspace API key (`flv_…`). **Required scope:** `tts`.
</ParamField>

#### Body

All fields are optional; an empty body (or `{}`) mints a 5-minute token for any voice.

<ParamField body="ttl_seconds" type="integer" default={300} initialValue={300}>
  Token lifetime in seconds, `10`–`3600`. Out of range returns `400`.
</ParamField>

<ParamField body="voice_ids" type="string[]">
  Restrict the token to these voices. Each id must be a voice your key's workspace can use, otherwise
  the request returns `400` naming the bad id. Omit to allow any voice in the workspace. Using the
  token with another voice returns `voice_not_allowed`.
</ParamField>

#### Response

<ResponseField name="data.token" type="string">
  The access token (`fin_tok_…`). Shown only in this response - the server stores a hash.
</ResponseField>

<ResponseField name="data.expires_at" type="integer">
  When the token expires, as Unix epoch **milliseconds**.
</ResponseField>

<ResponseField name="data.ttl_seconds" type="integer">
  The effective lifetime in seconds.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/tokens" \
    -H "X-API-Key: $FINESSE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"ttl_seconds":300,"voice_ids":["665f1a2b3c4d5e6f7a8b9c0d"]}'
  ```

  ```javascript JavaScript (your server) theme={null}
  // e.g. 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/tokens",
      {
        method: "POST",
        headers: {
          "X-API-Key": process.env.FINESSE_API_KEY, // stays on the server
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ ttl_seconds: 300, voice_ids: ["665f1a2b3c4d5e6f7a8b9c0d"] }),
      },
    );
    if (!res.ok) return new Response("Could not mint token", { status: 502 });
    const { data } = await res.json();
    return Response.json({ token: data.token, expires_at: data.expires_at });
  }
  ```

  ```python Python (your server) theme={null}
  import os, requests

  res = requests.post(
      "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/tokens",
      headers={"X-API-Key": os.environ["FINESSE_API_KEY"]},
      json={"ttl_seconds": 300, "voice_ids": ["665f1a2b3c4d5e6f7a8b9c0d"]},
  )
  res.raise_for_status()
  token = res.json()["data"]["token"]  # hand this to the browser
  ```
</CodeGroup>

```json Response theme={null}
{
  "status": 201,
  "data": {
    "token": "fin_tok_9Qx2kLmP4vR7sT1uW3yZ5aB8cD0eF6gH",
    "expires_at": 1717939500000,
    "ttl_seconds": 300
  },
  "error": false
}
```

<ResponseExample>
  ```json 201 theme={null}
  {
    "status": 201,
    "data": {
      "token": "fin_tok_9Qx2kLmP4vR7sT1uW3yZ5aB8cD0eF6gH",
      "expires_at": 1717939500000,
      "ttl_seconds": 300
    },
    "error": false
  }
  ```
</ResponseExample>

## Browser flow

<Steps>
  <Step title="Your server mints a token">
    When a signed-in user needs audio, your backend calls `POST /tokens` with your API key (scope `tts`),
    optionally restricting `voice_ids` to what that user may use.
  </Step>

  <Step title="Hand the token to the browser">
    Return only the `token` (and `expires_at`) to your frontend. Never send the API key.
  </Step>

  <Step title="The browser uses it once">
    Open the WebSocket with the token as a subprotocol, or call `speak` / `stream` with
    `Authorization: Bearer <token>`. The token is consumed on that first successful use.
  </Step>
</Steps>

<CodeGroup>
  ```javascript Browser: WebSocket theme={null}
  const { token } = await fetch("/api/finesse-token", { method: "POST" }).then((r) => r.json());

  // Browsers can't set headers on a WebSocket handshake, so the token rides in the
  // subprotocol list. The server selects "finesse.v1".
  const ws = new WebSocket(
    "wss://fi.production.flamapis.com/flam-voice-svc/api/v1/public/stream",
    ["finesse.v1", token],
  );
  ws.binaryType = "arraybuffer";
  ws.addEventListener("open", () => {
    ws.send(JSON.stringify({
      type: "task_start",
      context_id: "ctx-1",
      voice_id: "665f1a2b3c4d5e6f7a8b9c0d",
      text: "Hello from Finesse.",
      finalize: true,
      output_format: "s16le",
    }));
  });
  ```

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

  const res = await fetch(
    "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/665f1a2b3c4d5e6f7a8b9c0d/speak",
    {
      method: "POST",
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
      body: JSON.stringify({ text: "Hello from Finesse.", output_format: "wav" }),
    },
  );
  if (!res.ok) throw new Error(`Synthesis failed: ${res.status}`);
  new Audio(URL.createObjectURL(await res.blob())).play();
  ```
</CodeGroup>

<Note>
  If your WebSocket client can't send subprotocols, pass the token as `?access_token=fin_tok_…` on the
  stream URL instead. The older `?api_key=` query parameter still works but is **deprecated** - it puts
  your long-lived key in URLs and browser history.
</Note>

#### Errors

| Status | Code | When |
| - | - | - |
| `400` | - | `ttl_seconds` out of range, or a `voice_ids` entry isn't a voice in your workspace. |
| `401` | `key_expired` | The API key used to mint the token has expired. |
| `401` | - | `X-API-Key` missing, invalid or revoked. |
| `403` | `insufficient_scope` | The API key doesn't have the `tts` scope. |

When the browser **uses** the token: an unknown, expired or already-used token returns `401`
`token_invalid` (on the WebSocket, the handshake fails with `401`); a voice outside `voice_ids` returns
`403` `voice_not_allowed` on HTTP, or a non-fatal `voice_not_allowed` error frame on the WebSocket. See
[Errors](/voice-api/errors).

## Prompt

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

```text theme={null}
You are implementing a typed client for one Finesse Voice API endpoint: "Create an access token".
Everything you need is in this prompt. Do not invent anything that is not specified here.

WHAT TO BUILD
- A SERVER-SIDE function createAccessToken(options) (name it per the project's conventions) that
  exchanges the workspace API key for a short-lived, single-use access token and returns:
    { token: string, expiresAt: number, ttlSeconds: number }
- A server route (in the project's web framework) that your own signed-in frontend calls to get a
  token, returning only { token, expires_at } to the browser. Protect it with the app's existing
  user authentication; never expose an unauthenticated token minting route.
- On the browser side, a helper that uses the token for ONE realtime session or ONE speak/stream
  request (see USING THE TOKEN).
- Detect the stack first: look for package.json, tsconfig.json, pyproject.toml,
  requirements.txt, go.mod, Gemfile, composer.json and similar. Use the project's language, HTTP
  client, module layout, error-handling conventions and test runner. If there is no project yet or
  the stack is unclear, use TypeScript on Node 18+ with the built-in fetch and no new dependencies.
- If the project already has a Finesse API client module, add this to it (reuse its auth, base
  URL, error type and retry helper) instead of creating a parallel one.
- Keep the base URL (https://fi.production.flamapis.com) in one constant or config value.

ENDPOINT
- Method: POST
- URL: https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/tokens
- Request Content-Type: application/json

REQUEST BODY (all optional; {} is valid)
- ttl_seconds (integer, optional, default 300): token lifetime, 10-3600. Out of range returns 400.
  Validate locally.
- voice_ids (string[], optional): restrict the token to these voice ids (24-character hex). Each
  must be a voice the key's workspace can use, otherwise 400 naming the bad id. Omit to allow any
  voice in the workspace.

    { "ttl_seconds": 300, "voice_ids": ["665f1a2b3c4d5e6f7a8b9c0d"] }

AUTHENTICATION
- Send the header  X-API-Key: <workspace API key>  on every request. Keys start with "flv_".
- Required scope: tts. Keys carry one or more scopes (voices:read, voices:write, clone,
  tts); a key without this scope gets 403 with code "insufficient_scope". Keys created before
  scopes existed have all four.
- Read the key from the environment variable FINESSE_API_KEY (or the project's existing secrets or
  config mechanism). Fail fast with a clear error if it is missing, before making any request.
- This call is SERVER-SIDE ONLY. Never hard-code, log or commit the key, and never ship it in
  browser/client-side code. The whole point of this endpoint is that only the token reaches the
  browser.
- Keys can expire: an expired key returns 401 with code "key_expired". Do not retry; surface it so
  an operator can rotate the key.

SUCCESS RESPONSE (201)
    {
      "status": 201,
      "data": {
        "token": "fin_tok_9Qx2kLmP4vR7sT1uW3yZ5aB8cD0eF6gH",
        "expires_at": 1717939500000,
        "ttl_seconds": 300
      },
      "error": false
    }
- token starts with "fin_tok_". Treat it as a secret: do not log it.
- expires_at is Unix epoch MILLISECONDS.

TOKEN RULES
- Single use: consumed atomically on its first successful use (one WebSocket handshake, or one
  speak/stream request). A request refused before it runs (429 rate_limited / concurrency_limited,
  402/403 plan check) keeps the token, so retry that request with the same token after
  Retry-After. Mint a new token per session or request; never cache or reuse a spent one.
- Grants the tts scope only.
- Inherits the creating key's workspace, and becomes invalid if that key is revoked or expires.

USING THE TOKEN (browser)
- WebSocket: new WebSocket("wss://fi.production.flamapis.com/flam-voice-svc/api/v1/public/stream",
  ["finesse.v1", token]). The server selects the "finesse.v1" subprotocol. If the client cannot
  send subprotocols, use ...?access_token=<token> instead (URL-encode it, never log the URL).
  Do NOT use ?api_key=: it is deprecated (responses carry a Deprecation header) and exposes the
  long-lived key.
- HTTP: POST .../voices/{id}/speak or .../voices/{id}/stream with  Authorization: Bearer <token>
  instead of X-API-Key.
- Unknown, expired or already-used token: 401 with code "token_invalid" (WebSocket: the handshake
  fails with 401). Fetch a fresh token from your server and retry once.
- Voice not in the token's voice_ids: 403 with code "voice_not_allowed" over HTTP; on the
  WebSocket a non-fatal error frame with code "voice_not_allowed" for that task_start.

ERROR RESPONSES
Errors use the standard envelope, plus a machine-readable "code" on auth/scope errors:

    {
      "status": 403,
      "error": true,
      "code": "insufficient_scope",
      "message": "This API key is missing the required scope: tts"
    }

- 400: ttl_seconds out of range, or a voice_ids entry is not usable by the workspace. Do not retry.
- 401: X-API-Key missing, malformed, invalid or revoked, or code "key_expired". Do not retry.
- 403: code "insufficient_scope" (the key lacks tts). Do not retry.
- 429 with code "rate_limited" (over the plan's requests per second): wait the number of seconds in the Retry-After header, then retry with backoff.
  See /guides/rate-limits.
- Surface failures as one typed error (for example FinesseApiError) carrying: HTTP status, code
  (when present), message, a retryable flag, the request id (the X-Request-Id response header; error bodies also carry it as
  request_id), and the raw body for debugging. Log the request id with every failure.

ACCEPTANCE CRITERIA
- The API key is only ever read and sent on the server; the browser receives only the token.
- ttl_seconds is validated locally (10-3600) and voice_ids is passed through when set.
- The browser helper opens the WebSocket with subprotocols ["finesse.v1", token], or sends
  Authorization: Bearer <token> for speak/stream, and requests a fresh token on token_invalid.
- A new token is minted for every session/request.

TEST PLAN
- Unit tests with mocked HTTP: 201 parses token/expires_at/ttl_seconds; {} body works; ttl 5 and
  4000 rejected locally; 400 bad voice id; 401 key_expired; 403 insufficient_scope.
- Smoke script (server-side): mint a token with ttl_seconds 60, then call POST .../voices/{id}/speak
  with Authorization: Bearer <token> and a ready voice id from FLAM_VOICE_ID; assert audio comes
  back. Call it again with the same token and assert 401 token_invalid (single use).

RULES
- Implement only what this spec describes. Do not invent parameters, fields, headers, endpoints or
  error codes. If something is ambiguous, missing, or conflicts with the existing codebase, stop
  and ask me before guessing.
- Match the project's existing style and conventions; do not refactor unrelated code.
```


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