Get started
Authentication
One API key, one header, and a session route that keeps that key out of the browser.
Every request carries one credential: an API key, in the X-ZW-Api-Key header. The WebSocket is the exception, and the exception is the interesting part of this page.
API keys#
Create and revoke keys on the API Keys page. A key is scoped to your account and inherits its plan, its credit balance and its voices.
GET /api/v1/avatars/bundle/avatar_123 HTTP/1.1
Host: api.zeroweight.ai
X-ZW-Api-Key: your-api-keyA key is a server-side credential. Anything you ship to a browser or a mobile binary can be read out of it. The streaming session route below exists so that a browser can open a socket without ever holding one.
Authenticating a WebSocket#
A browser cannot set a header on a WebSocket. Whatever authenticates the connection therefore has to travel in the URL, and a URL ends up in browser history, in referrer headers and in every proxy log along the way. So your key is never what opens the socket.
Instead you exchange it. Post the session you want to POST /api/v1/realtime/stream/session with your key in the header, and you get back a wss:// URL that is ready to open. The URL carries a token that lasts about thirty seconds and authorises exactly one thing: opening one connection.
What the exchange buys#
- Your key stays where you put it. The only thing that reaches the browser is a token that is worthless a minute later.
- Refusals arrive as HTTP statuses. The key, the plan and the credit balance are all checked before the URL exists, so an unpaid account gets a
402it can act on instead of a WebSocket close code it has to look up. - No hostname in your client. Which gateway a session lands on is decided server-side, per session. Regions, tiers and drained instances change nothing in your code.
The recommended pattern#
Mint the session on your own backend, hand the URL to the client, and let the client open it. Your key never leaves your server; the browser never learns it.
// Your endpoint. Authenticate YOUR user first, then mint on their behalf.
export async function POST(request: Request) {
const { voiceId } = await request.json();
const response = await fetch(
"https://api.zeroweight.ai/api/v1/realtime/stream/session",
{
method: "POST",
headers: {
"Content-Type": "application/json",
// Server-side only. Never inline this into a client bundle.
"X-ZW-Api-Key": process.env.ZEROWEIGHT_API_KEY!,
},
body: JSON.stringify({
voice_id: voiceId ?? "maichi",
format: "pcm_s16le",
sample_rate: 24000,
}),
},
);
if (!response.ok) {
// 401 bad key · 402 no plan or no credits · 429 rate limited · 503 ours
return new Response(await response.text(), { status: response.status });
}
// { url, expires_in_seconds, sample_rate, max_concurrent_sessions, ... }
return Response.json(await response.json());
}The URL is short-lived by design, because that is the only thing bounding a token which leaks into a log. Open it promptly and mint another when it lapses. It does not bound the session: once the socket is open it lives until something closes it.
What a refusal means#
| Status | Meaning | What to do |
|---|---|---|
401 | The key is wrong, revoked, or absent. | Fail loudly. Do not retry. |
402 | The account has no API plan, or it is out of credits. | Surface it. Neither is fixed by retrying. |
422 | A sampling parameter you sent is not a known name. | Fix the spelling. The response names it. |
429 | Too many requests. | Back off with jitter. |
503 | The gateway is unreachable or unconfigured. Ours, not yours. | Retry in a second or two. |
Capacity and voice entitlement are not checked here. The gateway owns both, so those still arrive as close codes on the socket.
