Sinua
Connect your appVoice

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 }
VendorcredentialAlso
OpenAI Realtimean ephemeral client secret, ek_…expiresAt
Gemini Livean ephemeral token, auth_tokens/…expiresAt
ElevenLabsa signed wss:// URL for a private agentexpiresAt (15 minutes)
LiveKita 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:

openai-realtime-web.ts
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:

credentials-provider-web.ts
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>;
PlatformEndpointProviderOne fixed value
WebcredentialUrlcredential: async () => …credential: "…"
iOScredentialUrl:credential: .provider { SinuaCredential(…) }credential: "…"
AndroidCredentialSource.url(…), or withCredentialUrlCredentialSource.provider { … } (a background thread; blocking I/O is fine)CredentialSource.fixed(…)
React NativecredentialUrlcredential: 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.

credentials-server.ts
// 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> };
FunctionReturnsDefaults
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 it1800 s, a new session within 60 s, 1 use
signElevenLabsUrl({ apiKey, agentId })a signed wss:// URLvalid 15 minutes
mintLiveKitCredential({ apiKey, apiSecret, url, room, identity, name?, ttlSeconds? })a room token, signed locally, plus url600 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 variableFor
OPENAI_API_KEYOpenAI Realtime
GEMINI_API_KEYGemini Live
ELEVENLABS_API_KEY, ELEVENLABS_AGENT_IDElevenLabs
LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URLLiveKit
VOICE_INSTRUCTIONSoptional: the agent's instructions

Try it before you write a backend

npx @sinua/voice dev-proxy

The 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.

On this page