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:
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 keyA 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.
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.jsonrun-body.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:
| Key | Why it is there |
|---|---|
instruction | Decisions: use the script as written, the framing, and what not to add. It is prose, much shorter than 4000 characters. |
input | Data: 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_schema | full_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_key | Makes 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_credits | The ceiling for this run, in credits. A production live-commerce run needs a much larger cap than a single-scene retry. |
communication.webhook_url | The 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.
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.
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.
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.
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.jsonbulk-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.
{
"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:
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}'
doneThe 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.
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.
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