PricingAPI keys
  • Overview
  • Quickstart
  • Authentication
  • Overview
  • Format API
  • Create a run
  • Runs and results
  • Bulk runs
  • Editing a Format package
  • Structured output
  • Format catalog
  • Errors and spend
  • Cookbook
  • Embed a Format in your product
  • Waiting for runs and jobs
  • Agents
  • Agent Completions
  • Scheduled
  • Create a schedule
  • Advanced: run a schedule via API
  • Runs and results
  • Run webhooks
  • Safe automation
  • Best practices

Enterprise

Cookbook

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.

Each recipe on this page has the shape of a real production request. Placeholders replace the data of the customer. Replace acme/live-commerce, the URLs, and the copy with your values. Keep the structure.

Do this setup one time:

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

This is the most frequent production call. It has a product page, a host image, a tagged script, and a schema that names the assembled cut and each scene. It also has 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_credits": 8000,
  "communication": { "mode": "webhook", "webhook_url": "https://acme.example.com/hooks/sume" }
}

What each key does:

KeyWhy it is there
instructionDecisions: use the script as written, the framing, and what not to add. It is prose, much shorter than 4000 characters.
inputData: the recipe reads the keys that it knows (product_url, host_image_url, vo_language, script, price). The other keys go to the run as context. You can put your own reference data (sheet_no) here, but it will not come back in output.
output_schemafull_video is the deliverable. scenes[] gives you each clip with a stable id that you can retry. SumeMediaFile# is the built-in media shape. Each property is necessary. An optional property is a nullable property.
primary_output_keyMakes primary_output_url the assembled cut. If a run fills scenes but not full_video, the receipt is failed, not a false success.
generation_spend_cap_creditsThe ceiling for this run, in credits. A production live-commerce run needs a much larger cap than a single-scene retry.
communication.webhook_urlThe API sends one signed POST when the run ends, thus no poll is necessary. Keep result_url as the backup.

From the 202, store data.id and data.thread_id with your row. Use the first value to get the receipt. Use the second value to group retries.

When the webhook arrives, payload.output.full_video.url is the show and payload.output.scenes[] are the clips. The retry recipe below repairs a scene with status: "stand-in" or "failed".

A webhook receiver

The receiver does four steps in this sequence. First, it verifies the signature against the raw bytes. Then it answers 2xx quickly. Next, it dedupes on request_id. Only after these three steps, it acts on outcome. The two versions below handle the oversized-receipt case (payload: null).

Node

This version uses the Web Request API on Node.js, for example in a Next.js route handler or Hono. It uses verifyWebhook from @sume-com/client, which needs Node.js. On Workers or Deno, write the check below with WebCrypto.

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

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

  const ok = verifyWebhook(
    raw,
    request.headers.get("x-sume-webhook-timestamp") ?? undefined,
    request.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: 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, you can write the check in a dozen lines. First, reject a timestamp that is more than five minutes off. Then calculate the HMAC-SHA256 of ${timestamp}.${raw} with your secret, hex-encoded. Compare the result in constant time against the value after sume-v1= in x-sume-webhook-signature. The full function is on Run webhooks.

Python

This version uses FastAPI. It reads the raw body before it parses the JSON.

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 problems occur in each first receiver. First, a framework that parses JSON for you destroys the signed bytes. Thus, read the raw body on this route. Second, a receiver that renders video before it answers uses all of the 10-second attempt budget. Then the API retries the delivery while the receiver works. Thus, record and then answer before you process the event.

You can do a test of the receiver without a real run. POST /v1/webhooks/test-deliveries (or Send test on the dashboard) sends a webhook.test payload to your URL. To replay a real delivery, use POST /v1/format-runs/{run_id}/webhook/redeliver.

Retry one scene on the same thread

A run is one turn of a conversation. To do a clip again, continue that conversation with previous_run_id. Name the scene in the request. The voice track, the other clips, and the script do not change. The API returns the full scene list, 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_credits": 600,
    "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
  }'

Put the note of an operator in instruction (which scene, what is wrong, how it must change). Keep input.scene_id as the machine-readable pointer. For two scenes at the same time, use "scene_ids": ["sc_7", "sc_9"].

The API returns a new run (arun_…, a 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 other scenes keep their URLs, and the API re-assembles full_video at a new URL. Plan the cap for a single-scene retry as a fraction of the credits of the create. Measured production retries used approximately one twentieth of the credits of the first run. Always send a cap.

A retry is a new take, not a re-encode. The API generates all the generative parts of that scene again. If the look changes, retry the scene. If the words, the host, or the product change, start a new production with new scene ids.

The API refuses the continuation with 400 previous_run_not_resumable when the earlier run left nothing. It refuses with 409 previous_run_not_terminal while the earlier run is still in progress. It refuses with 400 previous_run_format_mismatch if you address a different Format. Refer to 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 progress. When a slot becomes free, the queue starts the next run.

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 has two keys. Each item is the same as the body of a single run (the first recipe on this page), one time for each row. On the client side, skip rows that do not have a finished script. 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_credits": 8000,
      "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_credits": 8000,
      "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
    }
  ]
}

202 returns a queue (frq_…). In this queue, the first concurrency items are already running. Keep your own sheet-row ↔ index map. items[i].index is the position that you submitted.

To get the progress, poll the queue, not the children:

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

The batch shape makes three things clear. The queue has no webhook, because communication.webhook_url is per item. Queue completed means that each item is terminal, not that each item succeeded. Thus, branch on counts.failed and on the output_error of each child. A used Idempotency-Key returns 202 with the old queue, thus mint a new key for each batch. For the full contract, refer to Bulk runs.

Wait for a run without a webhook

If you cannot expose an endpoint (a script, a CI job, a one-off), poll with backoff. Stop when the run gets to a 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}'

Waiting for runs and jobs has this loop in TypeScript and Python, with the create call. A failed run is a result that you branch on, not an exception. For long-form video, use expires_at on the receipt as the ceiling. Stopping the loop does not cancel the run: the run continues, and it continues to use credits. Keep the run id and read the run again later.

Read a Format before you call it

This call helps in a settings screen or a preflight. With it, you can make sure that the address resolves for the key that you hold. You can also see 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_credits: .generation_spend_cap_credits, vanity_invoke_url}'

You can get a 404 format_not_found here with a key that you think is right. This almost always means that you need the other key. Team Formats answer only to keys created in the team workspace. A 403 workspace_key_required tells you the same thing, with a more helpful message. You get it when the owner of your key is a member of that team.

Next

  • Create a run: all the fields of the body
  • Runs and results: the receipt, the poll rules, and the webhook rules
  • Errors and spend: what each code means and what to do
  • Embed a Format in your product: key custody, spend tiers, and how to handle artifacts for a multi-tenant product

On this page

  • A live-commerce run with typed output and a webhook
  • A webhook receiver
  • Retry one scene on the same thread
  • Batch a sheet with bulk runs
  • Wait for a run without a webhook
  • Read a Format before you call it
  • Next