Embed a Format in your product
You have a product with your own customers. You want a button in your UI that produces a Sume-generated video or image for the customer who clicked it. This page is the end-to-end recipe for that.
The shape is always the same:
Your customer never talks to Sume. Your server holds one Sume API key, runs Formats on their behalf, and maps the results back onto your own records.
Read Calling a Format for the invoke contract itself and Structured output for shaping the result. This page is about the integration around them.
0. The whole flow
With @sume-com/sdk (0.2.0+), the happy path is subscribeFormatRun:
one call creates the Format run and waits for the terminal receipt. Prefer a webhook
when delivery is on (§4). There is no SSE stream yet — events_url is null — so
onStatus is status polling, not a log feed.
The SDK is a convenience, not a requirement — every call here is one HTTP request you can
make with fetch, and the API reference remains the source of truth for
fields. What it buys you is the loops nobody enjoys writing: subscribeFormatRun /
waitForRun and verifyWebhook.
Once webhook delivery is on for your environment you want the push path instead — §4 below, and Waiting for runs for the tradeoff.
1. Key custody
One Sume account, one server-side key, many of your customers. Sume has no per-end-user credential to hand out, and there is no browser-safe key.
| Rule | Why |
|---|---|
The key lives in your server's environment, never in client JavaScript, a mobile bundle, or a NEXT_PUBLIC_* variable. | A Sume key spends your credits. Anyone holding it can run any Format you own, up to your caps. |
| Never proxy the key. Proxy the call. | A "pass-through" endpoint that forwards the browser's payload with your key attached is the same leak, one hop later. Your endpoint should accept your customer's identifiers and construct the Sume request itself. |
| Give your endpoint your own authorization check. | Sume authenticates you, not your customer. Deciding that this customer may run that Format is your product's job. |
| Rotate by creating a new key and retiring the old one. | Scopes cannot be added to an existing key — see below. |
Create the key at API Keys with the
formats:read and formats:write scopes.
Keys minted before Format API triggers shipped do not carry those scopes,
and scopes cannot be added afterward. An old key fails every run with
403 insufficient_scope. Create a new one. Service-account keys cannot create
Format runs at all — they fail with details.reason of
service_account_format_runs_unsupported.
The client sends x-api-key; do not add an Authorization header of your own, because
the API rejects a request that carries both credentials with 401 unauthorized. Without
the SDK this is a plain POST to
https://api.sume.com/v1/formats/{handle}/{slug}/runs — see
Calling a Format.
Idempotency-Key
Your customers will double-click. Your job queue will redeliver. Both turn into two paid runs unless the key is derived from the thing being made, not from the moment of asking.
| Do | Do not |
|---|---|
| Hash your own stable identifiers — tenant id, order id, Format slug, and a version you bump when you deliberately want a re-run. | uuidv4() per request. It makes the header decorative. |
| Namespace by customer. | A key built only from the order id — two tenants with colliding ids share a run. |
Store the returned run_id against your record before you return to the browser. | Rely on re-deriving the key later to find the run. You can, but a stored id is one lookup instead of one replay. |
Replay semantics, exactly:
| Replay | Result |
|---|---|
| Same key, same body | 200 with the original run receipt and idempotency_hit: true. No second run, no second charge. |
Same key, different body — including a different instruction | 409 idempotency_conflict. Nothing runs. |
| No key | Every call starts a new paid run. |
3. Pick a spend cap per run
Every Format carries a generation spend cap. A run can never spend past its own effective cap, and the Format's cap is what a run inherits when it names none.
generation_spend_cap_usd on the run request names that run's own ceiling, up
to the platform maximum of $500 — a number above the Format's own cap is
honored, and anything above $500 is a 400.
That makes the cap the natural place to express your own plan tiers:
Read the Format's own cap from
PublicFormat.generation_spend_cap_usd_micros — always a number, defaulting to
$400 for a Format that never named one. The effective cap for a given run comes
back on the receipt as usage.generation_spend_cap_usd_micros.
Caps bound generation spend. The terminal receipt reports what the run
actually spent against that ceiling as usage.billable_amount_usd_micros, which
is enough to show a per-run cost in your own UI — but it excludes the agent's own
LLM turn, so it is not the run's total cost and it is not an invoice. Bill your
customer from your own records and reconcile against
GET /v1/usage.
4. Receive the result
A Format run is asynchronous. You have two ways to learn it finished, and they carry the identical receipt.
| Webhook | Poll | |
|---|---|---|
| You do | Register communication.webhook_url, verify the signature, return 2xx. | Loop on status_url until terminal. |
| Available | Live on api.dev.sume.com. Accepted but not delivered on api.sume.com yet. | Everywhere, today. |
| Costs you | One public HTTPS endpoint. | One timer per in-flight run. |
Build the webhook receiver now — the contract is final. On development, a URL you
supply starts receiving signed POSTs today. On production, keep the poll path
(or subscribeFormatRun) until delivery is enabled; the same
receiver then works without code changes. Full contract:
Run webhooks.
Verify every delivery
Sume signs the raw body with HMAC-SHA256 over <timestamp>.<raw_body> and sends
sume-v1=<hex> in x-sume-webhook-signature. Verify before you parse.
verifyWebhook is async because it runs on WebCrypto, which is what keeps it usable from
Workers and Deno as well as Node. Options and header names:
Verifying webhooks. Writing the check by hand — in another language, or
at a gateway in front of your app — is a dozen lines against a published scheme:
Run webhooks.
Read your signing secret on the Webhooks tab of the dashboard
(/dashboard/webhooks), or from GET /v1/webhooks/signing-secret with any API key
carrying account:read. It is derived for your workspace, so a valid signature proves
the delivery was signed for you rather than for anyone holding a shared secret. Store it
as SUME_COM_WEBHOOK_SIGNING_SECRET, the same name Sume's delivery worker uses, the
same way you store the API key.
Four things that bite integrators here:
- Verify against the raw body. A framework that parses JSON for you and
hands you an object has already destroyed the bytes that were signed. In
Express, mount
express.raw({ type: "application/json" })on this route only. - Return
2xxfast, then work. The delivery attempt budget is 10 seconds. A receiver that renders video before responding gets retried while it works. - Dedupe on
request_id. Retries repeat it. Ten attempts against a flaky endpoint must not become ten rows in your database. 3xxis not a delivery. Redirects are not followed. Register the final URL, not a redirector, and not an HTTP one — non-HTTPS, localhost, and private-range URLs are rejected at submit with400 invalid_requestand re-checked at delivery time.
Job webhooks are a different surface
If you also call POST /v1/models/... directly, those emit generation-job
webhooks (job.completed and friends) with job_id, described in
Webhooks. Different events, different payload, different
lifecycle.
The signature scheme is identical, so one verifier covers both — but route on
event and never assume a body has run_id. A single receiver handling both
should switch on the event name first and treat anything unrecognized as a
204, so a new event type does not become a 500 and a retry storm.
5. Map artifacts into your UI
A terminal completed receipt carries three media-bearing fields:
| Field | Use it for |
|---|---|
primary_output_url | The one thing to show. null when the Format produced no single primary file. |
artifacts[] | Everything 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 inside it points at the same URLs. See Structured output. |
Every URL is a durable media.sume.com HTTPS URL. They do not expire, which
is what makes embedding practical — you can store the URL against your record
and render it forever without a refresh dance.
Two consequences worth designing around:
- A durable URL is a public URL. Anyone who has it can fetch it. It will end up in your logs, your error reports, and your customer's browser history. If your product's model is that customer A must never see customer B's output, proxy the bytes through your own authenticated route, or copy them into your own storage at receipt time and serve from there.
- Copy, or link, but decide. Linking is free and instant. Copying costs you storage but survives you ever leaving Sume. Copy on the webhook, before you mark the record ready, if you want that guarantee.
artifacts[] is empty until the run is terminal, and it is drawn from the same
job ledger that fills output — the two always agree.
6. Failure taxonomy
Runs fail in four distinguishable places. Your UI needs a different message for each; collapsing them into "something went wrong" is the fastest way to a support ticket you cannot answer.
At submit — nothing ran, nothing was charged
| Code | Status | What it means for your integration |
|---|---|---|
insufficient_scope | 403 | Your key lacks formats:read / formats:write, or it is a service-account key. Fix your key, not your request. |
format_not_found | 404 | Unknown handle, unknown slug, or a Format this key does not own. Also what a Format by Sume returns at an account handle — it answers at sume/{slug}. |
format_not_forkable | 409 | You addressed a built-in capability rather than a Format. Call one of the Formats by Sume, or your own. |
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 | Only with on_active_run: "reject". Retry later or surface "already running". |
idempotency_conflict | 409 | Same key, different body. Your key derivation is unstable — fix that before retrying. |
invalid_request | 400 | Includes a webhook_url that is not a public HTTPS URL. |
Treat 4xx here as a bug in your call, not a transient. Retrying an
insufficient_scope forever is a common and expensive mistake.
At run — a run existed, and did not produce a result
status is failed, and the receipt carries error plus output_error.
Notably:
error.code | Meaning |
|---|---|
unattended_blocked | The run hit a gate it could not satisfy without a human — no matching avatar, a missing input it would have asked about in chat. The message is written to be shown. |
format_run_failed | The generic failure. Read error.message. A run that wanted to spend past its cap lands here too, so a plan tier whose runs keep failing is the first thing to check against usage.generation_spend_cap_usd_micros. |
Retry a run failure with a new idempotency key — the old key is bound to the run that already failed, and reusing it returns that same failed receipt.
API runs are unattended. A Format written for interactive chat may pause to
ask a human for approval; over the API those approvals are pre-granted and the
run carries on within its spend cap. So completed is a real result — you will
never be handed a half-finished run labelled done.
Terminal, but not a failure
status | Handle it as |
|---|---|
canceled | Someone called POST /v1/format-runs/{id}/cancel. 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 off the response you got back rather than waiting for a POST. Format default is allow (concurrent runs); Action default is skip — do not copy Action examples into Format calls. See Calling a Format. |
At delivery — the run is fine, your endpoint was not
A delivery outcome never changes the run. Ten refused attempts leave you with a
failed delivery and a run that is still completed. Fetch it from
result_url.
The one delivery case that needs code: a receipt over 1 MiB arrives with
payload: null and error.code of payload_too_large, carrying the
result_url to fetch instead. A handler that assumes payload is an object
will throw on your largest, most valuable runs.
Checklist before you ship
-
SUME_API_KEYis server-only and absent from every client bundle. - Your run endpoint authorizes your own customer before it calls Sume.
-
Idempotency-Keyis derived from stable identifiers, not generated per request. -
generation_spend_cap_usdis set per plan tier. - The webhook receiver verifies
sume-v1against the raw body and returns2xxin under a second. - Deliveries are deduped on
request_id. -
payload: null(oversized receipt) falls back toresult_url. -
SUME_COM_WEBHOOK_SIGNING_SECRETis set from the value on/dashboard/webhooks, and its fingerprint matchesx-sume-webhook-secret-fingerprinton a delivery. - Poll on
status_urlremains wired as a backup (webhooks are live onapi.sume.com). - Every error code above maps to a message your support team can act on.
Next
- TypeScript SDK — the client used above, install to first run
- 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 — delivery, signing, and retries in full
- Webhooks — generation-job webhooks, the other surface