Authentication
Get a key, keep it on your server, and decide who may start a call.
Keys
Create one in the dashboard. It is shown once, and it goes in a bearer header:
Authorization: Bearer tic_live_…Keys are environment-tagged — tic_test_… for development, tic_live_… for production — and each one 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.
The tag is organisational, not a sandbox. It picks the prefix and nothing else: a tic_test_… key mints real sessions on the same fleet and bills the same credits against the same balance as a tic_live_… one. Nothing about your account is different in "test". That is worth saying plainly, because the naming is the one most APIs use for a free, isolated test mode, and it is not that here — the way to develop without spending production credits is 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:
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 });
}
},
});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 });
}
},
});# 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 for the full policy.