Browser voice app that starts Sume jobs: keep the key on your server

Voice apps run in the browser over WebRTC, but Sume keys belong on a server. A route handler that holds the key, allowlists models, reuses idempotency keys.

4 min readSume
All posts

If a voice app runs in the browser and can start Sume generation jobs, the browser must not hold the Sume API key. Sume's authentication docs say browser and mobile clients should call your backend, and the backend attaches the key.

OpenAI's Realtime guide puts browser sessions on WebRTC, so the page already talks to two parties. Only one of them, your server, should know the Sume key.

Who holds what

From the Sume authentication docs and the OpenAI Realtime guide, read 2026-10-03.

Where each credential and connection lives (read 2026-10-03)
PieceLives inRule
Voice session in the browserBrowserWebRTC, per the Realtime guide
Voice session on a serverYour serverWebSocket, per the Realtime guide
Sume API keyServer environment variableNever in frontend JavaScript or mobile apps
Job submitYour route handlerAttaches the key; validates input first
Job resultYour route handler or a webhookPoll status or accept a signed webhook

A route handler that holds the key

The handler below follows the proxy shape in Sume's docs, with three additions. It allowlists the models the page may ask for, it forwards only model and prompt, and it takes the idempotency key from the client instead of minting a new one per request. A fresh random key on every call defeats idempotency, because a retry of the same click would create a second paid job.

const ALLOWED = new Set(["sume/auto"]);

export async function POST(request: Request) {
  const key = request.headers.get("idempotency-key");
  if (!key) return new Response("idempotency-key required", { status: 400 });

  const { model, prompt } = await request.json();
  if (!ALLOWED.has(model) || typeof prompt !== "string" || prompt.length > 2000) {
    return new Response("bad request", { status: 400 });
  }
  // enforce your own user authorization here before forwarding

  const upstream = await fetch("https://api.sume.com/v1/images", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SUME_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": key,
    },
    body: JSON.stringify({ model, prompt, mode: "async" }),
  });

  return new Response(await upstream.text(), {
    status: upstream.status,
    headers: { "Content-Type": "application/json" },
  });
}

The browser side of the contract

Small rules that keep the split honest.

  • The page generates one idempotency key per user intent and reuses it for retries of that intent.
  • The page polls your status route, not Sume. Your route reads GET /v1/jobs/:id/status with the key attached.
  • There is no push channel from Sume: the Developer API has no SSE or WebSocket transport, so the page polls or your server relays a webhook.
  • Rotate the key if it ever appears in logs or chat history, as the authentication docs advise.

Webhook alternative

If you would rather not poll from the page, submit with mode: "webhook" and a public HTTPS webhook_url on your server. Sume sends terminal events only, signed with HMAC SHA 256, and your receiver should refuse to run with an empty secret. Keep polling available for deliveries that never arrive, as the webhook docs recommend.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume