---
title: Cookbook
description: Copy-paste recipes shaped like production traffic. A live-commerce run with typed output and a webhook, a webhook receiver in Node and Python, a scene retry on the same thread, a sheet-driven batch, and a wait loop.
---

Every recipe here is the shape a production integration actually sends, with the customer's
data swapped for placeholders. Replace `acme/live-commerce`, the URLs and the copy with yours;
keep the structure.

Set up once:

```bash
export SUME_API_KEY="sume_live_..."      # a workspace key with formats:read + formats:write
export SUME_API="https://api.sume.com"   # or https://api.dev.sume.com with a development key
```

## A live-commerce run with typed output and a webhook

The most common production call: a product page, a host image, a tagged script, a schema that
names the assembled cut and each scene, a per-run cap, and a webhook. Keep the body in a file.
Real scripts are too long for a shell heredoc.

```bash
curl -sS -X POST "$SUME_API/v1/formats/acme/live-commerce/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sheet-45-v1" \
  -d @run-body.json
```

`run-body.json`:

```json
{
  "instruction": "Use the Intro/Mid/Fin script as written; do not shorten or add sentences. Korean host, vertical 9:16, 30fps, no BGM, no captions. Keep card typography inside the top 40% of the frame.",
  "input": {
    "sheet_no": 45,
    "product_url": "https://shop.example.com/p/4438469916",
    "product_name": "Aurora French Terry Crewneck",
    "brand_name": "Aurora",
    "on_card_name": "Aurora daily crewneck",
    "highlights": [
      "Soft brushed french terry",
      "Sizes S to XL",
      "31,000 → 24,810 (20% off)"
    ],
    "product_image_urls": [
      "https://cdn.example.com/products/4438469916/still-800.jpg"
    ],
    "host_image_url": "https://cdn.example.com/hosts/chaerin.png",
    "vo_language": "ko",
    "price": { "currency": "KRW", "list": 31000, "sale": 24810, "discount_label": "20%" },
    "script": {
      "segments": [
        { "tag": "Intro", "text": "안녕하세요, …" },
        { "tag": "Mid", "text": "…" },
        { "tag": "Fin", "text": "…" }
      ]
    }
  },
  "output_schema": {
    "name": "acme/live-commerce/v1",
    "strict": true,
    "schema": {
      "type": "object",
      "additionalProperties": false,
      "required": ["full_video", "scenes"],
      "properties": {
        "full_video": { "$ref": "SumeMediaFile#" },
        "scenes": { "type": "array", "items": { "$ref": "#/$defs/scene" } }
      },
      "$defs": {
        "scene": {
          "type": "object",
          "additionalProperties": false,
          "required": ["id", "role", "status", "video"],
          "properties": {
            "id": { "type": "string" },
            "role": { "type": "string", "enum": ["talk", "broll", "under_banner"] },
            "status": { "type": "string", "enum": ["succeeded", "stand-in", "failed"] },
            "video": { "$ref": "SumeMediaFile#" }
          }
        }
      }
    }
  },
  "primary_output_key": "full_video",
  "generation_spend_cap_usd": 120,
  "communication": { "mode": "webhook", "webhook_url": "https://acme.example.com/hooks/sume" }
}
```

What each key is doing:

| Key | Why it is there |
|---|---|
| `instruction` | Decisions: use the script as written, framing, what not to add. Prose, well under 4000 characters. |
| `input` | Data: the recipe reads the keys it knows (`product_url`, `host_image_url`, `vo_language`, `script`, `price`); the rest rides along as context. Your own bookkeeping (`sheet_no`) is fine here, but it will not come back in `output`. |
| `output_schema` | `full_video` is the deliverable; `scenes[]` gives you every clip with a stable `id` you can retry. `SumeMediaFile#` is the built-in media shape. Every property is required; optional means nullable. |
| `primary_output_key` | Makes `primary_output_url` the assembled cut, and turns a run that filled `scenes` but not `full_video` into a `failed` receipt rather than a false success. |
| `generation_spend_cap_usd` | The ceiling for this run. Production live-commerce runs sit around $120. |
| `communication.webhook_url` | One signed POST when the run ends, so nothing polls. Keep `result_url` as the backup. |

Store `data.id` and `data.thread_id` from the `202` against your row: the first for the
receipt, the second to group retries.

When the webhook arrives, `payload.output.full_video.url` is the show and
`payload.output.scenes[]` the clips. A scene with `status: "stand-in"` or `"failed"` is what
the retry recipe below fixes.

## A webhook receiver

The receiver does four things in order: verify the signature against the raw bytes, answer
`2xx` fast, dedupe on `request_id`, and only then act on `outcome`. Both versions below handle
the oversized-receipt case (`payload: null`).

### Node

Framework-agnostic on the Web `Request` API: a Next.js route handler, Hono, Workers or Deno.
Uses `verifyWebhook` from `@sume-com/sdk`.

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

const SECRET = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!; // Webhooks tab of the dashboard

export async function POST(request: Request) {
  const raw = await request.text(); // the raw string, never a re-serialized object

  if (!(await verifyWebhook({ body: raw, headers: request.headers, secret: SECRET }))) {
    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: retries repeat request_id. Insert-or-ignore, then decide whether to process.
  const fresh = await db.webhookEvents.insertIfAbsent({ id: event.request_id, body: raw });
  if (!fresh) return new Response(null, { status: 204 });

  // Answer now. The run is already terminal, and the work below can take as long as it needs.
  queueMicrotask(() => handle(event).catch(console.error));
  return new Response(null, { status: 204 });
}

async function handle(event: any) {
  // Over 1 MiB the receipt is not inlined. Fetch it instead.
  const receipt =
    event.payload ??
    (await fetch(event.error.result_url, {
      headers: { Authorization: `Bearer ${process.env.SUME_API_KEY}` },
    })
      .then(r => r.json())
      .then(r => r.data));

  switch (event.outcome) {
    case "ok":
      return markReady(receipt.id, receipt.output, receipt.primary_output_url);
    case "degraded":
      // Real media in artifacts[], but output is null: usually a schema asking for
      // something the Format never produces. Show the media, log output_error.
      return markNeedsReview(receipt.id, receipt.artifacts, receipt.output_error);
    case "error":
      return markFailed(receipt.id, receipt.error, receipt.artifacts);
  }
}
```

If you cannot use the SDK, the check is a dozen lines: HMAC-SHA256 of `${timestamp}.${raw}`
with your secret, hex-encoded, compared in constant time against the value after `sume-v1=`
in `x-sume-webhook-signature`, after rejecting a timestamp more than five minutes off. The
full function is on [Run webhooks](/agents/run-webhooks#signature).

### Python

FastAPI, reading the raw body before any JSON parsing.

```python
import hashlib
import hmac
import json
import os
import time

from fastapi import FastAPI, Request, Response

SECRET = os.environ["SUME_COM_WEBHOOK_SIGNING_SECRET"].encode()
TOLERANCE_SECONDS = 300

app = FastAPI()


def verify(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not timestamp or not signature:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    digest = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sume-v1={digest}", signature)


@app.post("/hooks/sume")
async def sume_webhook(request: Request):
    raw = await request.body()
    if not verify(
        raw,
        request.headers.get("x-sume-webhook-timestamp"),
        request.headers.get("x-sume-webhook-signature"),
    ):
        return Response(status_code=401)

    event = json.loads(raw)
    if event.get("event") != "format.run.terminal":
        return Response(status_code=204)

    if not record_once(event["request_id"], raw):  # your insert-or-ignore
        return Response(status_code=204)

    enqueue(handle, event)  # answer first, work later
    return Response(status_code=204)


def handle(event: dict) -> None:
    receipt = event["payload"] or fetch_receipt(event["error"]["result_url"])
    outcome = event["outcome"]
    if outcome == "ok":
        mark_ready(receipt["id"], receipt["output"], receipt["primary_output_url"])
    elif outcome == "degraded":
        mark_needs_review(receipt["id"], receipt["artifacts"], receipt["output_error"])
    else:
        mark_failed(receipt["id"], receipt["error"], receipt["artifacts"])
```

Two things bite every first receiver. Frameworks that parse JSON for you have already
destroyed the bytes that were signed, so read the raw body on this route. And a receiver that
renders video before responding burns the 10-second attempt budget and gets retried while it
works, so record, answer, then process.

Test it without a real run: `POST /v1/webhooks/test-deliveries` (or **Send test** on the
dashboard) fires a `webhook.test` payload at your URL. Replay a real one with
`POST /v1/format-runs/{run_id}/webhook/redeliver`.

## Retry one scene on the same thread

A run is one turn of a conversation. To redo a clip, continue that conversation with
`previous_run_id` and name the scene. The voice track, the other clips and the script stay as
they were, and the whole scene list comes back re-assembled.

```bash
curl -sS -X POST "$SUME_API/v1/formats/acme/live-commerce/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sheet-45-v1-retry-sc_7" \
  -d '{
    "previous_run_id": "arun_e43e6c5cb2b74052",
    "instruction": "Retry the selected scene only. Keep every other scene and the voice track unchanged. Do not change the script. New take only.",
    "input": { "scene_id": "sc_7" },
    "output_schema": { "…": "identical to the first run" },
    "primary_output_key": "full_video",
    "generation_spend_cap_usd": 8,
    "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
  }'
```

For an operator's note, put it in `instruction` (which scene, what is wrong, how it should
change) and keep `input.scene_id` as the machine-readable pointer. Two scenes at once is
`"scene_ids": ["sc_7", "sc_9"]`.

What comes back is a new run (`arun_…`, new receipt, its own webhook) on the same
`thread_id`. `output.scenes[]` is the full list again: the retried scene has a new URL, the
others keep theirs, and `full_video` is re-assembled at a new URL. Budget a single-scene retry
at a fraction of the create (measured production retries ran at roughly a twentieth of the
first run's spend) and always send a cap.

A retry is a new take, not a re-encode: everything generative in that scene is re-rolled.
Looks change → retry the scene. Words, host or product change → new production, new scene
ids.

The continuation is refused with `400 previous_run_not_resumable` when the earlier run left
nothing behind, `409 previous_run_not_terminal` while it is still running, and
`400 previous_run_format_mismatch` if you address a different Format. See
[Continue a run](/formats/runs#continue-a-run).

## Batch a sheet with bulk runs

One row of a broadcast sheet becomes one item; the sheet becomes one `POST …/bulk-runs`. The
queue keeps `concurrency` runs in flight and starts the next as a slot frees.

```bash
curl -sS -X POST "$SUME_API/v1/formats/acme/live-commerce/bulk-runs" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sheet-2026-09-03-v1" \
  -d @bulk-body.json
```

`bulk-body.json` is two keys; each item is exactly the body of a single run, the first recipe
on this page, once per row. Skip rows with no finished script client-side; there is no empty
item.

```json
{
  "concurrency": 2,
  "items": [
    {
      "instruction": "…",
      "input": { "sheet_no": 45, "product_url": "…" },
      "output_schema": { "…": "…" },
      "primary_output_key": "full_video",
      "generation_spend_cap_usd": 120,
      "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
    },
    {
      "instruction": "…",
      "input": { "sheet_no": 46, "product_url": "…" },
      "output_schema": { "…": "…" },
      "primary_output_key": "full_video",
      "generation_spend_cap_usd": 120,
      "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
    }
  ]
}
```

`202` returns a queue (`frq_…`) with the first `concurrency` items already `running`. Keep
your own sheet-row ↔ `index` map: `items[i].index` is the position you submitted.

Poll the queue, not the children, for progress:

```bash
QUEUE_ID="frq_…"
while :; do
  Q=$(curl -sS "$SUME_API/v1/format-run-queues/$QUEUE_ID" -H "Authorization: Bearer $SUME_API_KEY")
  echo "$Q" | jq -c '.data.counts'
  [ "$(echo "$Q" | jq -r '.data.status')" = "completed" ] && break
  sleep 30
done

# Every item is terminal now. Read each child's receipt, and look at counts.failed before calling it done.
echo "$Q" | jq -r '.data.items[] | select(.run_id != null) | .run_id' | while read -r RUN_ID; do
  curl -sS "$SUME_API/v1/format-runs/$RUN_ID" -H "Authorization: Bearer $SUME_API_KEY" \
    | jq -c '{id: .data.id, status: .data.status, primary: .data.primary_output_url, output_error: .data.output_error.code}'
done
```

Three things the batch shape makes obvious. The queue has no webhook:
`communication.webhook_url` is per item. Queue `completed` means every item is terminal, not
that every item succeeded, so branch on `counts.failed` and each child's `output_error`. And a
spent `Idempotency-Key` returns `202` with the *old* queue, so mint a fresh one per batch. Full
contract: [Bulk runs](/formats/bulk-runs).

## Wait for a run without a webhook

When you cannot expose an endpoint (a script, a CI job, a one-off) poll with backoff and stop
on the terminal status.

```bash
RUN_ID=$(curl -sS -X POST "$SUME_API/v1/formats/acme/live-commerce/runs" \
  -H "Authorization: Bearer $SUME_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" -d @run-body.json | jq -r '.data.id')

SLEEP=5
while :; do
  RUN=$(curl -sS "$SUME_API/v1/format-runs/$RUN_ID" -H "Authorization: Bearer $SUME_API_KEY")
  STATUS=$(echo "$RUN" | jq -r '.data.status')
  [ "$STATUS" = "queued" ] || [ "$STATUS" = "processing" ] || break
  sleep "$SLEEP"; SLEEP=$(( SLEEP < 60 ? SLEEP * 2 : 60 ))
done

echo "$RUN" | jq '{status: .data.status, primary: .data.primary_output_url, error: .data.error}'
```

In TypeScript, `subscribeFormatRun` is the create and the loop in one call. It resolves on any
terminal status, so a failed run is a result to branch on, not an exception:

```ts
import { readFile } from "node:fs/promises";
import { createSumeClient, subscribeFormatRun } from "@sume-com/sdk";

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

const run = await subscribeFormatRun({
  client,
  path: { handle: "acme", slug: "live-commerce" },
  idempotencyKey: "sheet-45-v1",
  body: JSON.parse(await readFile("run-body.json", "utf8")),
  timeout: 45 * 60_000,
  timeline: true, // also read the phase timeline on every poll
  onStatus: (status, snapshot) => console.log(status, snapshot.timeline?.at(-1)?.phase),
});

if (run.status === "completed") console.log(run.primary_output_url);
else console.error(run.status, run.error, run.output_error);
```

Its default timeout is 20 minutes; raise it for long-form video, and use `expires_at` on the
receipt as the honest ceiling. A timeout does not cancel the run. It keeps running and
billing, so keep the run id and read it back later.

## Read a Format before you call it

Useful in a settings screen or a preflight: confirm the address resolves for the key you hold,
what the Format takes, and its cap.

```bash
curl -sS "$SUME_API/v1/formats/acme/live-commerce" \
  -H "Authorization: Bearer $SUME_API_KEY" \
  | jq '.data | {id, handle, slug, version, status, api_trigger_enabled, io, cap_usd: (.generation_spend_cap_usd_micros / 1000000), vanity_invoke_url}'
```

A `404 format_not_found` here with a key you believe is right almost always means the other
key: team Formats answer only to keys created in the team workspace. A
`403 workspace_key_required` means the same thing, said more helpfully, when your key's owner
is a member of that team.

## Next

- [Create a run](/formats/call): every field on the body
- [Runs and results](/formats/runs): the receipt, polling and webhook rules
- [Errors and spend](/formats/errors): what each code means and what to do
- [Embed a Format in your product](/cookbooks/embed-a-format): key custody, spend tiers and artifact handling for a multi-tenant product
