---
title: Embed a Format in your product
description: 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
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:

```text
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, runs
Formats on their behalf, and maps the results back onto your own records.

Read [Calling a Format](/formats/call) for the invoke contract itself and
[Structured output](/formats/structured-output) for shaping the result. This
page is about the integration around them.

## 0. The whole flow

With [`@sume-com/sdk`](/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.

```ts
import { createSumeClient, subscribeFormatRun } from "@sume-com/sdk";

const client = createSumeClient({ apiKey: process.env.SUME_API_KEY! });

const run = await subscribeFormatRun({
  client,
  // 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.
  path: { handle: "acme", slug: "product-promo" },
  idempotencyKey: runKey(customer, order),
  body: {
    input: { product_url: order.productUrl },
    generation_spend_cap_usd: 3,
  },
  onStatus: (status, snapshot) => console.log(status, snapshot.next_action),
});

if (run.status === "completed") {
  await attachOutputs(order.id, run); // run.primary_output_url, run.artifacts
}
```

The SDK is a convenience, not a requirement — every call here is one HTTP request you can
make with `fetch`, and the [API reference](/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](/sdk/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](https://www.sume.com/dashboard/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`.

```ts
// server-only module. Importing this from a client component is the bug.
import { createSumeClient, createFormatRunByVanityPath } from "@sume-com/sdk";

if (!process.env.SUME_API_KEY) throw new Error("SUME_API_KEY is not configured");

const client = createSumeClient({ apiKey: process.env.SUME_API_KEY });

export async function startRunForCustomer(customer: Customer, order: Order) {
  // 202 on a fresh run, 200 on an idempotent replay. Both carry the receipt.
  const { data, error } = await createFormatRunByVanityPath({
    client,
    path: { handle: "acme", slug: "product-promo" },
    headers: { "idempotency-key": runKey(customer, order) },
    body: {
      input: { product_url: order.productUrl },
      generation_spend_cap_usd: spendCapForPlan(customer.plan),
      communication: {
        mode: "webhook",
        webhook_url: "https://acme.example.com/hooks/sume",
      },
    },
  });
  if (error) throw new Error(`Sume rejected the run: ${JSON.stringify(error)}`);
  return data!.data;
}
```

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](/formats/call).

## 2. Derive `Idempotency-Key` per customer

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.

```ts
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 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:

```ts
function spendCapForPlan(plan: Plan) {
  switch (plan) {
    case "free":
      return 0.5;
    case "pro":
      return 3;
    case "enterprise":
      return undefined; // inherit the Format's own cap
  }
}
```

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`](/dashboard/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`](/sdk/runs)) until delivery is enabled; the same
receiver then works without code changes. Full contract:
[Run webhooks](/agents/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.

```ts
import { verifyWebhook } from "@sume-com/sdk";

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 = await verifyWebhook({ body: raw, headers: req.headers, 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` 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](/sdk/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](/agents/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 `2xx` fast, 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.
- **`3xx` is 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 with `400 invalid_request` and
  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](/workflows/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](/formats/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.

```ts
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, 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](/formats/call#request-body). |

### 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.

```ts
const receipt = event.payload ?? (await fetchRun(event.error.result_url));
```

## Checklist before you ship

- [ ] `SUME_API_KEY` is server-only and absent from every client bundle.
- [ ] Your run endpoint authorizes your own customer before it calls Sume.
- [ ] `Idempotency-Key` is derived from stable identifiers, not generated per request.
- [ ] `generation_spend_cap_usd` is set per plan tier.
- [ ] The webhook receiver verifies `sume-v1` against the raw body and returns `2xx` in under a second.
- [ ] Deliveries are deduped on `request_id`.
- [ ] `payload: null` (oversized receipt) falls back to `result_url`.
- [ ] `SUME_COM_WEBHOOK_SIGNING_SECRET` is set from the value on `/dashboard/webhooks`, and its fingerprint matches `x-sume-webhook-secret-fingerprint` on a delivery.
- [ ] Poll on `status_url` remains wired as a backup (webhooks are live on `api.sume.com`).
- [ ] Every error code above maps to a message your support team can act on.

## Next

- [TypeScript SDK](/sdk) — the client used above, install to first run
- [Calling a Format](/formats/call) — the invoke contract
- [Bulk runs](/formats/bulk-runs) — a server-side queue of those runs
- [Structured output](/formats/structured-output) — schemas, the projection, failure modes
- [Runs and results](/formats/runs) — the receipt, field by field
- [Run webhooks](/agents/run-webhooks) — delivery, signing, and retries in full
- [Webhooks](/workflows/webhooks) — generation-job webhooks, the other surface
