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.

5 min readSume
All posts

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.

Who does what in a browser-to-Sume flow (read 2026-10-07)
StepBrowserYour serverSume
Authenticate the personSends its session cookieResolves the user; rejects anonymous callsNever sees the user
Choose what to generateSends a prompt and an order idAllowlists fields, caps length, picks the modelValidates the body
SubmitWaits for 202Sends the key and an Idempotency-KeyCreates one job per key
Learn the outcomePolls your status routeReads status_url or receives a webhookServes status and result
Retry after a network errorPresses the button againReuses the same derived keyReturns 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

All Developers posts

Written by Sume