Let browsers start Sume jobs through your server, not with your key
Browsers must never hold a Sume API key. A server route authenticates the user, checks the input, derives an Idempotency-Key and returns only the status URL.

Put the Sume API key on your server and give the browser a route of your own that starts the job. The route checks who the user is, validates and caps the input, sends one x-api-key or Authorization header with a derived Idempotency-Key, and returns only the status URL with 202. The docs say not to put API keys in frontend JavaScript, mobile apps or support tickets, and a paid API makes that rule a billing rule.
What each side owns
A key in a browser bundle is a key in everyone's browser, and every request made with it spends your wallet. The split below keeps the key, the money decisions and the retry logic in one place you control.
| Step | Browser | Your server | Sume |
|---|---|---|---|
| Authenticate the person | Sends its session cookie | Resolves the user; rejects anonymous calls | Never sees the user |
| Choose what to generate | Sends a prompt and an order id | Allowlists fields, caps length, picks the model | Validates the body |
| Submit | Waits for 202 | Sends the key and an Idempotency-Key | Creates one job per key |
| Learn the outcome | Polls your status route | Reads status_url or receives a webhook | Serves status and result |
| Retry after a network error | Presses the button again | Reuses the same derived key | Returns the original job |
The route
The handler below is a fetch-style route handler. The currentUser function is a stub so the file runs on its own, and in your app it is your session check. The model field is sume/auto, the Sume-only router id, and the mode is async, so the request returns immediately with the job envelope.
import { createHash } from "node:crypto";
export async function POST(request) {
const user = await currentUser(request); // your own auth comes first
if (!user) return new Response("unauthorized", { status: 401 });
const { orderId, prompt } = await request.json();
if (typeof prompt !== "string" || prompt.length > 500) {
return Response.json({ error: "bad prompt" }, { status: 400 });
}
// Stable per order and prompt, so a retry from the browser returns the same job.
const key = createHash("sha256").update(`${user.id}:${orderId}:${prompt}`).digest("hex");
const res = await fetch("https://api.sume.com/v1/images", {
method: "POST",
headers: {
"x-api-key": process.env.SUME_API_KEY, // server only, one header
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ model: "sume/auto", prompt, mode: "async" }),
});
const data = await res.json();
if (!res.ok) return Response.json({ error: data.error?.code ?? "upstream" }, { status: res.status });
return Response.json({ statusUrl: data.status_url }, { status: 202 });
}
async function currentUser(request) {
return request.headers.get("x-demo-user") ? { id: request.headers.get("x-demo-user") } : null;
}Why the key is derived, not random
A random key per click defeats the purpose, because two clicks make two paid jobs. A key that hashes the user, the order and the prompt is the same for a double click and for a retry after a timeout, so Sume returns the original job instead of billing again. The same key with a different body is 409 idempotency_conflict, so if a user edits the prompt, the key changes with it and a new job starts.
Keep the key under the 255-character limit, which a SHA-256 hex digest does with room to spare. Do not put personal text in the key, since it is a stable string that appears in logs.
Return less than you receive
The submit response carries more than a browser needs. Return the status URL or your own job handle and nothing else, and have a second route read the job for the browser. That second route should accept only a GET, check that the job belongs to the signed-in user in your own table, and pass back only the terminal flag and the media URL. A key that can read every job in the workspace should never be asked to look up an id the user typed.
Map errors on the way out as well. A 402 insufficient_credits or a 429 queue_full is information for you, not for the end user, so log the request_id and show a generic retry message.
If the browser must show progress, give it a reason to wait that does not depend on Sume's wording: your own states such as submitted, working and ready, mapped from the terminal and result_ready flags on the status payload. Poll your own route no faster than the next_poll_after_seconds value that the status response suggests, and stop when terminal is true. That keeps the read budget of your one key from becoming the bottleneck of many visitors.
Limits that still apply
A server route does not change Sume's limits. Each key has a per-minute write budget and the workspace has a cap on accepted jobs, so a popular page can hit 429 queue_full while no one is near the request rate limit. Add your own per-user limit in front of the route, small enough that one visitor cannot fill the queue, and treat queue_full as a signal to show a waiting state rather than a failure.
Sources
Related posts
More in Developers
- List every Format run: no GET /v1/format-runs, page per Format
GET /v1/format-runs does not exist. List runs per Format with limit, next_cursor and has_more, or keep your own index of the data.id you stored at create.
- List Sume image models that take references or masks with jq
New image models land weekly. One curl and one jq filter on GET /v1/images/models show which ids accept input_references, mask_url or background, and how many.
- List Sume TTS Router models and prices in Python: one GET
GET /v1/tts-router/models returns five Sonic ids with per-character price and constraints. A short Python script prints each model's price per 1,000 characters.
- How do I add a listen-to-this-page audio version with TTS?
Turn each article into an audio file with one async TTS job per page: a 9,000-character article costs 43 cents on Sume. What it does not replace.
Written by Sume