Enterprise
Embed a Format in your product
Run a Sume Format from your own dashboard on behalf of your customer — key custody, idempotency, webhooks, spend caps, artifacts, and failures.
You have a product with your own customers. You want a button in your UI that makes a Sume-generated video or image for the customer who clicked the button. This page gives the end-to-end procedure for that.
The flow is always the same:
your customer's browser
│ (your own auth, your own request)
▼
your server ───── POST /v1/formats/{handle}/{slug}/runs ─────▶ Sume API
▲ │
│ POST /hooks/sume (signed, sume-v1) │
└────────────────────────────────────────────────────────────┘Your customer never talks to Sume. Your server holds one Sume API key. The server runs Formats for your customers and maps the results onto your own records.
For the invoke contract, read Calling a Format. To shape the result, read Structured output. This page gives the integration around those two topics.
0. The whole flow
The happy path is two HTTP calls: create the Format run, then read it until it is
terminal. In production, we recommend a webhook (§4) instead of the read loop.
Sume has no SSE stream yet. events_url (/v1/format-runs/{id}/events) is a
polled phase timeline.
const API = "https://api.sume.com";
const headers = {
Authorization: `Bearer ${process.env.SUME_API_KEY}`,
"Content-Type": "application/json",
};
// Your team's vanity path — the same {handle}/{slug} the Format detail page shows.
// Team Formats need a team (workspace) key — see Calling a Format.
const created = await fetch(`${API}/v1/formats/acme/product-promo/runs`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": runKey(customer, order) },
body: JSON.stringify({
input: { product_url: order.productUrl },
generation_spend_cap_credits: 300,
}),
});
if (!created.ok) throw new Error(JSON.stringify(await created.json()));
let run = (await created.json()).data;
while (run.status === "queued" || run.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 10_000));
const read = await fetch(`${API}/v1/format-runs/${run.id}`, { headers });
if (read.ok) run = (await read.json()).data;
}
if (run.status === "completed") {
await attachOutputs(order.id, run); // run.primary_output_url, run.artifacts
}Each call is one HTTP request. The API reference stays the
source of truth for fields. Waiting for runs and jobs has the full
loop, with backoff and the transient-error rule, in TypeScript and Python.
@sume-com/client gives you verifyWebhook for the push path (§4).
1. Key custody
One Sume account and one server-side key serve many of your customers. Sume has no per-end-user credential that you can give to a customer. Sume also has no browser-safe key.
| Rule | Why |
|---|---|
Keep the key in your server's environment. Never put the key in client JavaScript, a mobile bundle, or a NEXT_PUBLIC_* variable. | A Sume key spends your credits. Anyone who has the key can run any Format that you own, up to your caps. |
| Never proxy the key. Proxy the call. | A "pass-through" endpoint forwards the browser's payload and attaches your key. That is the same leak, one hop later. Your endpoint must accept your customer's identifiers and make the Sume request itself. |
| Give your endpoint your own authorization check. | Sume authenticates you, not your customer. Your product must decide if this customer can run that Format. |
| To rotate a key, create a new key and retire the old key. | You cannot add scopes to an existing key. Refer to the text below. |
Create the key at API Keys with the
formats:read and formats:write scopes.
Keys that Sume minted before the release of Format API triggers do not have
those scopes. You cannot add the scopes later. An old key fails every run with
403 insufficient_scope. Create a new key. Service-account keys cannot create
any Format runs. Their requests fail with details.reason of
service_account_format_runs_unsupported.
// server-only module. Importing this from a client component is the bug.
if (!process.env.SUME_API_KEY) throw new Error("SUME_API_KEY is not configured");
export async function startRunForCustomer(customer: Customer, order: Order) {
// 202 on a fresh run, 200 on an idempotent replay. Both carry the receipt.
const response = await fetch(
"https://api.sume.com/v1/formats/acme/product-promo/runs",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUME_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": runKey(customer, order),
},
body: JSON.stringify({
input: { product_url: order.productUrl },
generation_spend_cap_credits: spendCapForPlan(customer.plan),
communication: {
mode: "webhook",
webhook_url: "https://acme.example.com/hooks/sume",
},
}),
},
);
const body = await response.json();
if (!response.ok) throw new Error(`Sume rejected the run: ${JSON.stringify(body.error)}`);
return body.data;
}Send the key in one header only. If a request has both Authorization and
x-api-key, the API rejects it with 401 unauthorized. The create contract is
in Calling a Format.
2. Derive Idempotency-Key per customer
Your customers will double-click. Your job queue will redeliver. Each of these events makes two paid runs. To prevent this, derive the key from the item that the run makes. Do not derive the key from the time of the request.
import { createHash } from "node:crypto";
/** Stable for one (customer, order, format version) — not for one HTTP call. */
function runKey(customer: Customer, order: Order) {
return createHash("sha256")
.update(`${customer.id}:${order.id}:product-promo:v1`)
.digest("hex")
.slice(0, 40);
}| Do | Do not |
|---|---|
| Hash your own stable identifiers: tenant id, order id, Format slug, and a version. Bump the version only when you want a re-run. | uuidv4() per request. Then the header has no effect. |
| Namespace the key by customer. | A key that you build only from the order id. Two tenants with the same ids then share a run. |
Before you return to the browser, store the returned run_id with your record. | Derive the key again later to find the run. You can do this, but a stored id is one lookup, not one replay. |
These are the accurate replay semantics:
| Replay | Result |
|---|---|
| Same key, same body | 200 with the original run receipt and idempotency_hit: true. Sume does not start a second run or make a second charge. |
Same key, different body (a different instruction is also a different body) | 409 idempotency_conflict. Nothing runs. |
| No key | Every call starts a new paid run. |
3. Pick a spend cap per run
Every Format has a generation spend cap, in credits. A run can never use more credits than its own effective cap. If a run names no cap, the run inherits the Format's cap.
generation_spend_cap_credits on the run request names the ceiling of that run,
up to the platform maximum. Sume accepts a number above the Format's own cap. A
number above the platform maximum gets a 400.
Thus, the cap is the natural place for your own plan tiers:
function spendCapForPlan(plan: Plan) {
switch (plan) {
case "free":
return 50;
case "pro":
return 300;
case "enterprise":
return undefined; // inherit the Format's own cap
}
}Read the Format's own cap from
PublicFormat.generation_spend_cap_credits. This value is always a number.
If a Format never named a cap, it reports the platform default. The receipt
gives the effective cap of a run as usage.cap.limit_credits.
Caps set a limit on the credits of generation. The terminal receipt reports
the credits of the run against that ceiling as usage.cap.counted_credits.
This value is enough to show per-run credits in your own UI, but it does not
include the agent's own LLM turn. Thus, it is not the total credits of the run
(usage.debited_credits is), and it is not an invoice. Bill your customer from
your own records. Reconcile
against GET /v1/usage.
4. Receive the result
A Format run is asynchronous. You have two ways to know when the run is complete. Both ways give the same receipt.
| Webhook | Poll | |
|---|---|---|
| You do | Register communication.webhook_url, verify the signature, and return 2xx. | Loop on status_url until the run is terminal. |
| Available | api.dev.sume.com and api.sume.com. | Everywhere. |
| Costs you | One public HTTPS endpoint. | One timer per in-flight run. |
Build the webhook receiver first. Keep the poll path wired as the backup for a time when your endpoint is down. For the full contract, refer to Run webhooks. For a complete receiver in Node and Python, refer to Cookbook.
Verify every delivery
Sume signs the raw body with HMAC-SHA256 over <timestamp>.<raw_body>. Sume
sends sume-v1=<hex> in x-sume-webhook-signature. Verify the signature
before you parse the body.
import { verifyWebhook } from "@sume-com/client";
export async function handleSumeWebhook(req: Request) {
const raw = await req.text(); // raw string, not a re-serialized object
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!;
const ok = verifyWebhook(
raw,
req.headers.get("x-sume-webhook-timestamp") ?? undefined,
req.headers.get("x-sume-webhook-signature") ?? undefined,
secret,
);
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw);
if (event.event !== "format.run.terminal") return new Response(null, { status: 204 });
// Dedupe on request_id — it repeats across retries of the same run.
await recordTerminalRun(event.request_id, event);
return new Response(null, { status: 204 }); // fast 2xx, work afterwards
}verifyWebhook from @sume-com/client runs on Node.js. For the arguments and
header names, refer to Verifying webhooks. You can also write the check
yourself, in a different language or at a gateway in front of your app. That
check is a dozen lines against a published scheme:
Run webhooks.
Read your signing secret on the Webhooks tab of the dashboard
(/dashboard/webhooks). Or, get it from GET /v1/webhooks/signing-secret with
any API key that has account:read. Sume derives the secret for your
workspace. Thus, a valid signature proves that Sume signed the delivery for you,
not for anyone who has a shared secret. Store the secret as
SUME_COM_WEBHOOK_SIGNING_SECRET, the name that Sume's delivery worker uses.
Store it in the same way as the API key.
Four items frequently cause problems for integrators here:
- Verify against the raw body. Some frameworks parse JSON for you and give you an object. Such a framework already destroyed the signed bytes. In Express, mount
express.raw({ type: "application/json" })on this route only. - Return
2xxfast, then work. The delivery attempt budget is 10 seconds. If a receiver renders video before it responds, Sume retries the delivery while the receiver works. - Dedupe on
request_id. Retries send the same value again. Ten attempts against an unreliable endpoint must not become ten rows in your database. 3xxis not a delivery. Sume does not follow redirects. Register the final URL, not a redirector, and not an HTTP URL. Sume rejects non-HTTPS, localhost, and private-range URLs at submit with400 invalid_request, and checks them again at delivery time.
Job webhooks are a different surface
If you also call POST /v1/models/... directly, those calls emit
generation-job webhooks (job.completed and related events) with job_id.
Webhooks gives the details. These webhooks have
different events, a different payload, and a different lifecycle.
The signature scheme is the same, so one verifier covers both. But route on
event, and never assume that a body has run_id. A single receiver for both
must first switch on the event name. The receiver must send a 204 for any
event that it does not know. Thus, a new event type does not cause a 500 and a
retry storm.
5. Map artifacts into your UI
A terminal completed receipt has three fields that contain media:
| Field | Use it for |
|---|---|
primary_output_url | The one item to show. It is null when the Format produced no single primary file. |
artifacts[] | All the items that the run generated: { id, type, url, content_type, size_bytes, width, height, duration_ms, checksum_sha256 }. |
output | The Format's structured result, projected onto output_schema. Media in the result points to the same URLs. Refer to Structured output. |
Every URL is a durable media.sume.com HTTPS URL. These URLs do not
expire. Thus, an embed is practical. You can store the URL with your record
and render it forever, with no refresh step.
Design your integration for these two results:
- A durable URL is a public URL. Anyone who has the URL can fetch it. The URL will go into your logs, your error reports, and your customer's browser history. If your product must never let customer A see customer B's output, proxy the bytes through your own authenticated route. Or, at receipt time, copy the bytes into your own storage and serve them from there.
- Copy, or link, but decide. A link is free and instant. A copy costs you storage, but the copy stays available if you stop the use of Sume. If you want that guarantee, copy on the webhook, before you mark the record ready.
async function attachOutputs(orderId: string, receipt: FormatRunReceipt) {
const videos = receipt.artifacts.filter(a => a.type === "video");
await db.orders.update(orderId, {
previewUrl: receipt.primary_output_url,
assets: videos.map(a => ({
sumeArtifactId: a.id,
url: a.url,
contentType: a.content_type,
durationMs: a.duration_ms,
})),
});
}artifacts[] is empty until the run is terminal. Sume fills it from the same
job ledger that fills output. Thus, the two fields always agree.
6. Failure taxonomy
Runs fail in four different places. Your UI must have a different message for each place. If you show only "something went wrong" for all four, you will quickly get a support ticket that you cannot answer.
At submit — nothing ran, nothing was charged
| Code | Status | What it means for your integration |
|---|---|---|
insufficient_scope | 403 | Your key does not have formats:read / formats:write, or it is a service-account key. Correct your key, not your request. |
format_not_found | 404 | Unknown handle, unknown slug, or a Format that this key does not own. A Format by Sume also returns this code at an account handle. Such a Format answers at sume/{slug}. |
format_not_forkable | 409 | You addressed a built-in capability, not a Format. Call one of the Formats by Sume, or one of your own Formats. |
format_api_trigger_disabled | 409 | The API trigger is off for that Format. |
format_inactive | 409 | The Format is inactive. |
format_run_in_progress | 409 | Occurs only with on_active_run: "reject". Retry later, or show "already running". |
idempotency_conflict | 409 | Same key, different body. Your key derivation is not stable. Correct it before you retry. |
invalid_request | 400 | Includes a webhook_url that is not a public HTTPS URL. |
A 4xx here is a bug in your call, not a transient error. If you retry an
insufficient_scope forever, you make a frequent and expensive mistake.
At run — a run existed, and did not produce a result
status is failed, and the receipt has error and output_error. Important
codes:
error.code | Meaning |
|---|---|
unattended_blocked | The run hit a gate that it could not pass without a human. Examples: no avatar that matches, or a missing input that the run asks about in chat. Sume writes the message so that you can show it. |
format_run_failed | The generic failure. Read error.message. A run that tried to use more credits than its cap also gets this code. Thus, if the runs of a plan tier fail again and again, first compare them with usage.cap.limit_credits. |
To retry a failed run, use a new idempotency key. The old key is bound to the run that failed. If you use the old key again, you get the same failed receipt.
API runs are unattended. A Format for interactive chat can pause to ask a
human for approval. Over the API, those approvals are pre-granted, and the run
continues in its spend cap. Thus, completed is a real result. You will never
get a half-finished run that shows as done.
Terminal, but not a failure
status | Handle it as |
|---|---|
canceled | Someone called POST /v1/format-runs/{id}/cancel. Sume sends no webhook. Use the cancel response and poll status_url. |
skipped | You passed on_active_run: "skip", and a run was already in flight. A skipped run never delivers a webhook. The create response already told you, with skip_reason populated. Read the status from that response. Do not wait for a POST. The Format default is allow (concurrent runs). The Action default is skip. Do not copy Action examples into Format calls. Refer to Calling a Format. |
At delivery — the run is fine, your endpoint was not
A delivery result never changes the run. After ten refused attempts, you have a
failed delivery and a run that is still completed. Fetch the run from
result_url.
One delivery case needs code. A receipt over 1 MiB arrives with
payload: null and error.code of payload_too_large. That delivery gives
the result_url, and you fetch the receipt from it. If a handler assumes that
payload is an object, it will throw on your largest, most valuable runs.
const receipt = event.payload ?? (await fetchRun(event.error.result_url));Checklist before you ship
-
SUME_API_KEYis server-only and is not in any client bundle. - Your run endpoint authorizes your own customer before it calls Sume.
- You derive
Idempotency-Keyfrom stable identifiers, and you do not generate it per request. -
generation_spend_cap_creditsis set per plan tier. - The webhook receiver verifies
sume-v1against the raw body and returns2xxin less than a second. - Your receiver dedupes deliveries on
request_id. - For
payload: null(oversized receipt), your handler usesresult_url. - You set
SUME_COM_WEBHOOK_SIGNING_SECRETfrom the value on/dashboard/webhooks, and its fingerprint matchesx-sume-webhook-secret-fingerprinton a delivery. - Poll on
status_urlstays wired as a backup (webhooks are live onapi.sume.com). - Every error code above maps to a message that your support team can use.
Next
- SDKs —
@sume-com/clientandsume-client, from install to first job - Calling a Format — the invoke contract
- Bulk runs — a server-side queue of those runs
- Structured output — schemas, the projection, failure modes
- Runs and results — the receipt, field by field
- Run webhooks — full details of delivery, signatures, and retries
- Webhooks — generation-job webhooks, the other surface