Skip to documentation
svelte/elevenlabs Docs

Docs Signed URLs

Signed URLs

Keep the ElevenLabs API key on your server and hand the browser a short-lived WebSocket URL instead.

Why signed URLs

A public agent id can open a socket directly from the browser. That is convenient for demos and openly shareable agents, but it is not a good fit for a private agent or for production traffic where you want control over who can start a conversation.

A signed URL is minted on your server with the workspace API key and expires quickly. The browser receives only the URL. The key never enters the bundle, the HTML, the WebSocket handshake, or the network tab.

Route contract

The controller calls a same-origin route when you pass signed: true. The default endpoint is /api/signed-url.

HTTP
GET /api/signed-url?agent_id=agent_...

Success response:

JSON
{ "signed_url": "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=..." }

Error response:

JSON
{ "error": { "message": "Human-readable explanation." } }
Status codes used by the included route
StatusMeaning
200Signed URL created.
400Missing or malformed agent id.
404ElevenLabs could not find the agent.
500ELEVENLABS_API_KEY is missing on the server.
502Upstream rejection, unexpected payload, or network failure.
503ElevenLabs is rate limiting the request.
499The client cancelled the request.

Every response sets Cache-Control: no-store. The route validates agent_ and seng_ ids before it calls ElevenLabs, and it never echoes the API key.

Server route

The repository route at src/routes/api/signed-url/+server.ts is the reference implementation. A condensed version looks like this:

src/routes/api/signed-url/+server.ts
import { json } from '@sveltejs/kit';import type { RequestHandler } from './$types';const ORIGIN = 'https://api.elevenlabs.io';const AGENT_ID = /^(agent|seng)_[A-Za-z0-9]+$/;export const prerender = false;export const GET: RequestHandler = async ({ url, fetch, request, setHeaders }) => {  setHeaders({ 'cache-control': 'no-store' });  const agentId = url.searchParams.get('agent_id')?.trim() ?? '';  if (!AGENT_ID.test(agentId)) {    return json({ error: { message: 'Invalid agent id.' } }, { status: 400 });  }  const apiKey = process.env.ELEVENLABS_API_KEY?.trim();  if (!apiKey) {    return json({ error: { message: 'Missing server key.' } }, { status: 500 });  }  const upstream = new URL('/v1/convai/conversation/get-signed-url', ORIGIN);  upstream.searchParams.set('agent_id', agentId);  const response = await fetch(upstream, {    headers: { accept: 'application/json', 'xi-api-key': apiKey },    signal: request.signal  });  if (!response.ok) {    return json({ error: { message: 'Signed URL request failed.' } }, { status: 502 });  }  const body: unknown = await response.json();  const signedUrl =    typeof body === 'object' && body !== null && 'signed_url' in body      ? body.signed_url      : null;  if (typeof signedUrl !== 'string') {    return json({ error: { message: 'Unexpected upstream response.' } }, { status: 502 });  }  return json({ signed_url: signedUrl });};

Client usage

Pass signed: true as the second argument. The controller appends the agent id to your endpoint and fetches JSON with an Accept header.

TS
const conversation = useConversation({  agentId: 'agent_your_public_id',  signedUrlEndpoint: '/api/signed-url'});await conversation.startConversation('agent_your_public_id', { signed: true });

An already-minted wss:// URL skips the exchange and connects directly:

TS
await conversation.startConversation('wss://api.elevenlabs.io/v1/convai/conversation?agent_id=...');

The signed URL request has a 12 second timeout. A failed exchange appears as signed-url-failed with a message from your route when one is available.

Security checklist

  • Read ELEVENLABS_API_KEY from process.env inside the server route only.
  • Never use a PUBLIC_* name for the key and never import the route into client code.
  • Set Cache-Control: no-store on success and error responses.
  • Validate the agent id shape before calling ElevenLabs.
  • Add authentication or rate limiting when the endpoint is public. A route that mints URLs for arbitrary ids can spend your quota.
  • Do not log the key, the signed URL, or full request headers.
  • Keep the key out of static builds, preview deployments, and issue reports.

Static hosting

A static host has no server runtime, so it cannot mint signed URLs. The showcase handles this by building with PUBLIC_HOSTING_MODE=static: signed mode is hidden and disabled, and visitors use a public agent id or paste a signed URL they already have.

SH
PUBLIC_ELEVENLABS_AGENT_ID=agent_examplePublicId bun run build:pages

The static output is written to build. Set PAGES_BASE_PATH for a GitHub project site, for example /svelte-elevenlabs, so links and assets resolve under the repository path. The workflow supplies it automatically and the same build also works at a domain root.

Read Troubleshooting if signed mode works locally but fails after deployment, or see the README for the full Pages workflow.

Edit this page on GitHub