# Authentication

> Get a key, keep it on your server, and decide who may start a call.

- Canonical: https://realtimeavatar.ai/docs/authentication
- Updated: 2026-09-10

## Keys

Create a key in [workspace Settings → API keys](https://realtimeavatar.ai/platform/settings#api-keys) and save the full key securely on your server. The list keeps keys masked; the masked text cannot be used for API calls. Workspace owners and admins can use the **Copy** action to copy newly created keys again while they are active and unexpired, including after leaving Settings and returning later.

Legacy keys created before repeat copying was available were stored as hashes only and cannot be recovered. If you lose one, create a replacement and update your integration. Revoked or expired keys cannot be copied; replace them to resume API access.

Use the full key in a bearer header:

```text
Authorization: Bearer YOUR_API_KEY
```

New keys use one format: `tic_<keyId>_<secret>`. Existing `tic_live_` and `tic_test_` keys remain valid without any changes. Use names to distinguish integrations. Each key can carry its own spend limit, which is useful when handing a key to a subsystem you would rather cap. The cap is enforced against the wallet, so it does nothing on a workspace billed as **unlimited**: that account never consults a balance, and the per-key check sits behind the one it skips. The limit is still accepted and echoed back, so if you are on an unlimited plan, treat a per-key cap as a note to yourself rather than a control.

**There is no separate test or sandbox mode.** All keys call real services and use the same balance in their workspace. The legacy prefixes do not change permissions or billing. To keep development usage separate, use a separate workspace, or a per-key `spendLimitCreditMicros` low enough to fail closed.

### Scopes

You pick these at creation. A key made in the dashboard starts with every scope enabled except `*`, so it works straight away — untick whatever this key has no business doing, particularly `api_keys:write`. Creating one over the API instead and omitting `scopes` gives you the narrower `realtime:write`, `avatars:read`, `credits:read`.

| Scope | Grants |
| --- | --- |
| `realtime:write` | Start and end calls |
| `avatars:read` | List and fetch avatars and assets |
| `avatars:write` | Create and update avatars, upload assets, sync clips |
| `credits:read` | Read the balance |
| `usage:read` · `usage:write` | Usage reporting |
| `api_keys:write` | Mint further keys |
| `*` | Everything — avoid outside trusted back-office jobs |

## The one rule: the key stays on your server

A browser holding the key could start unlimited calls on your account, so it never gets one. Your client talks to your app; your app talks to us. That is what the route adapters are — `createRealtimeAvatarRoute` and its siblings — mount one and `authorize` is your gate:

**Next.js**

```tsx
import { createRealtimeAvatarRoute } from "realtime-avatar/nextjs";

export const { GET, POST } = createRealtimeAvatarRoute({
  apiKey: process.env.REALTIME_AVATAR_API_KEY!,   // never NEXT_PUBLIC_ prefixed

  authorize: async ({ request, operation }) => {
    const user = await currentUser(request);
    if (!user) return new Response("Unauthorized", { status: 401 });

    // "connect" is the only operation that costs money to START.
    if (operation === "connect" && !(await hasCredits(user))) {
      return Response.json({ code: "insufficient_credits" }, { status: 402 });
    }
  },
});
```

**TanStack Start**

```tsx
import { realtimeAvatarServerRoute } from "realtime-avatar/tanstack-start";

const handlers = realtimeAvatarServerRoute({
  apiKey: () => getEnv().REALTIME_AVATAR_API_KEY,  // a factory works on Workers

  authorize: async ({ request, operation }) => {
    const user = await requireUser(request);
    if (!user) return new Response("Unauthorized", { status: 401 });
    if (operation === "connect" && !(await hasCredits(user))) {
      return Response.json({ code: "insufficient_credits" }, { status: 402 });
    }
  },
});
```

**Python**

```python
# No handler to mount — your own endpoint is the gate.
from fastapi import Depends, HTTPException
from fastapi.responses import JSONResponse

@app.post("/api/calls")
def start_call(req: StartCallRequest, user=Depends(current_user)):
    if not user:
        raise HTTPException(401)
    if not has_credits(user):                 # starting a call is the costly one
        # HTTPException would nest this under "detail"; keep code top-level.
        return JSONResponse({"code": "insufficient_credits"}, 402)
    ...
```

Return a `Response` to refuse, or nothing to allow. There are four operations: `connect`, `end`, `avatars`, and `credits`. Put your wallet check on `connect` — it is the only one that costs money to start — and leave the reads cheap. The route exposes no avatar mutation at all: creating or deleting a character is a server-side job for the `RealtimeAvatar` class, never something a browser reaches through this handler — so there is nothing to forget to gate. Every operation still passes `authorize`, so this callback is the gate, not a second line behind one.

A key with a `NEXT_PUBLIC_` or `VITE_` prefix is inlined into the client bundle at build time. That is the one mistake that actually leaks a key, and the prefix is the only warning you get.

## What the client is not allowed to decide

The persona, the memory, the voice, and the time limit are all yours. The handler strips them from whatever the browser sent and uses your `session` policy instead — and a field your policy does not set is **absent** rather than inherited, so forgetting one fails closed.

The one worth getting right on day one is `maxSeconds`: it is what stops a call your balance cannot cover. See [Calls](https://realtimeavatar.ai/docs/sessions) for the full policy.

---

Realtime Avatar — realtime AI avatar API & SDK. Docs: https://realtimeavatar.ai/docs · Agent guide: https://realtimeavatar.ai/llms.txt
