Credentials
Keep your vendor API key on your server: one small endpoint mints a short-lived credential, and every voice source asks it on each connect.
A voice source never needs your OpenAI, Gemini, ElevenLabs or LiveKit API key. The key stays on your server; one endpoint there mints a short-lived credential per session, and the source asks that endpoint every time it connects or reconnects. Your server also decides who gets a session and with which model, voice and instructions: the app can't change them.
The shape
Every vendor's endpoint answers the same JSON:
{ "credential": "ek_…", "expiresAt": 1790000000 }| Vendor | credential | Also |
|---|---|---|
| OpenAI Realtime | an ephemeral client secret, ek_… | expiresAt |
| Gemini Live | an ephemeral token, auth_tokens/… | expiresAt |
| ElevenLabs | a signed wss:// URL for a private agent | expiresAt (15 minutes) |
| LiveKit | a room token (JWT) | url: your LiveKit server, wss://… |
expiresAt is in Unix seconds.
On the client
Point the source at your endpoint with credentialUrl. It sends a POST (no body, not cached; on the web with your cookies, as any same-origin request) on every connect and reconnect, so a spent or expired credential is never reused. The option is the same for every vendor source on every platform:
import { OpenAIRealtimeVoiceSource } from "@sinua/voice/openai";
// Your endpoint mints a short-lived `ek_…` with your API key, which never reaches the
// browser (see Credentials). The source POSTs to it on every connect and reconnect,
// since an `ek_` works once. The model, voice and instructions are set on the server.
export const voice = new OpenAIRealtimeVoiceSource({ credentialUrl: "/api/voice/openai" });
If your request needs more than that, such as your app's auth header or a signed request, pass a provider instead. It is called at the same moments and returns the same shape:
import { OpenAIRealtimeVoiceSource } from "@sinua/voice/openai";
import type { SinuaCredential } from "@sinua/voice";
// When the request needs more than a POST (a bearer token, a different host), pass a
// provider instead of a URL. It is called on every connect and reconnect.
export const voice = new OpenAIRealtimeVoiceSource({
credential: async (): Promise<SinuaCredential> => {
const res = await fetch("https://api.example.com/voice/openai", {
method: "POST",
headers: { Authorization: `Bearer ${await getAppToken()}` },
});
if (!res.ok) throw new Error(`credential endpoint: ${res.status}`);
return res.json();
},
});
declare function getAppToken(): Promise<string>;
| Platform | Endpoint | Provider | One fixed value |
|---|---|---|---|
| Web | credentialUrl | credential: async () => … | credential: "…" |
| iOS | credentialUrl: | credential: .provider { SinuaCredential(…) } | credential: "…" |
| Android | CredentialSource.url(…), or withCredentialUrl | CredentialSource.provider { … } (a background thread; blocking I/O is fine) | CredentialSource.fixed(…) |
| React Native | credentialUrl | credential: async () => … | credential: "…" |
A fixed value is one credential you pasted for a single session, or an ElevenLabs public agent's id. It can't be refreshed, so a dropped OpenAI call can't come back with it.
Errors. A 4xx from your endpoint or an answer in another shape stops the connection with an error that says why. A network failure, a 429 or a 5xx is retried. The credential never appears in an error message.
A raw API key is always refused, on every platform, before the microphone opens.
On your server
@sinua/voice/server mints each vendor's credential with fetch and WebCrypto only, so it runs on Node, Next.js, Cloudflare Workers, Deno and Supabase Edge Functions. It throws if it is loaded in a browser.
// On your server only: the vendor key stays here. Any runtime with fetch and WebCrypto
// (Node, Next.js, Cloudflare Workers, Deno, Supabase Edge Functions).
import { credentialResponse, mintOpenAIRealtimeCredential } from "@sinua/voice/server";
// POST /api/voice/openai
export async function POST(req: Request): Promise<Response> {
if (!(await currentUser(req))) return Response.json({ error: "sign in first" }, { status: 401 });
const credential = await mintOpenAIRealtimeCredential({
apiKey: process.env.OPENAI_API_KEY!,
model: "gpt-realtime",
voice: "marin",
instructions: "You are a concise voice assistant.",
});
return credentialResponse(credential); // { credential, expiresAt }, Cache-Control: no-store
}
declare function currentUser(req: Request): Promise<string | null>; // your auth
declare const process: { env: Record<string, string | undefined> };
| Function | Returns | Defaults |
|---|---|---|
mintOpenAIRealtimeCredential({ apiKey, model, voice?, instructions?, expiresInSeconds? }) | ek_… | expires in 600 s |
mintGeminiLiveCredential({ apiKey, model, voice?, instructions?, expiresInSeconds?, newSessionWindowSeconds?, uses? }) | auth_tokens/…, with the model, voice and instructions locked into it | 1800 s, a new session within 60 s, 1 use |
signElevenLabsUrl({ apiKey, agentId }) | a signed wss:// URL | valid 15 minutes |
mintLiveKitCredential({ apiKey, apiSecret, url, room, identity, name?, ttlSeconds? }) | a room token, signed locally, plus url | 600 s |
credentialResponse(credential) | a JSON Response with Cache-Control: no-store |
A vendor error throws CredentialMintError with the vendor's HTTP status; your API key is removed from its message.
Check who is asking. The endpoint spends your vendor quota, so it should answer only a signed-in user of your app, and refuse everyone else with 401.
Ready-made endpoints
The repository has one endpoint per platform, all serving POST …/<vendor> for openai, gemini, elevenlabs and livekit: Next.js, Express, Cloudflare Workers and Supabase. Each one refuses every request until you connect it to your auth.
| Environment variable | For |
|---|---|
OPENAI_API_KEY | OpenAI Realtime |
GEMINI_API_KEY | Gemini Live |
ELEVENLABS_API_KEY, ELEVENLABS_AGENT_ID | ElevenLabs |
LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL | LiveKit |
VOICE_INSTRUCTIONS | optional: the agent's instructions |
Try it before you write a backend
npx @sinua/voice dev-proxyThe dev proxy reads your keys from .env or .env.local and serves POST http://127.0.0.1:8787/openai (and /gemini, /elevenlabs, /livekit) on your machine only, answering requests from localhost pages. Point credentialUrl at it while you develop. A missing key answers 501 with the variable's name. Options: --port, --allow-origin <origin>. Optional variables: SINUA_MODEL_OPENAI, SINUA_VOICE_OPENAI, SINUA_MODEL_GEMINI, SINUA_VOICE_GEMINI, SINUA_INSTRUCTIONS, LIVEKIT_ROOM.
From the iOS simulator it is http://127.0.0.1:8787/<vendor>; from the Android emulator, http://10.0.2.2:8787/<vendor>.
It is for your machine only: in production, the endpoint is yours.