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
- 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.
- Mandarin Chinese speech to text API: Sume STT language_code zh
Transcribe Mandarin audio with Sume STT using language_code zh, then check the result and timings. $0.01 per audio minute and no accuracy claim without a test.
- Migrate a real-time avatar prototype to Sume async jobs: what changes
Moving from a live avatar session to Sume means replacing a stream with submit, poll and fetch. The code changes, the UX changes, and a Node example that runs.
- Model an AI generation job as a state machine in your database
A schema and update rule for tracking Sume jobs: five statuses, sticky terminal states, a separate webhook delivery column, and the idempotency key on the row.
Written by Sume