---
title: 쿡북
description: 프로덕션 트래픽과 같은 모양의 복사해 쓰는 레시피. typed output과 웹훅이 있는 라이브 커머스 run, Node와 Python 웹훅 수신기, 같은 스레드에서의 씬 재시도, 시트 기반 배치, 대기 루프.
---

여기 있는 레시피는 모두 실제 프로덕션 연동이 보내는 모양이며, 고객 데이터만 자리표시자로
바꿨습니다. `acme/live-commerce`, URL, 문구를 여러분 것으로 바꾸고 구조는 그대로 두세요.

한 번만 설정합니다.

```bash
export SUME_API_KEY="sume_live_..."      # formats:read + formats:write를 가진 워크스페이스 키
export SUME_API="https://api.sume.com"   # 개발 키라면 https://api.dev.sume.com
```

## typed output과 웹훅이 있는 라이브 커머스 run

가장 흔한 프로덕션 호출입니다. 제품 페이지, 호스트 이미지, 태그가 붙은 대본, 완성본과 각
씬을 이름 짓는 스키마, run당 cap, 그리고 웹훅. 본문은 파일에 두세요. 실제 대본은 셸 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": "Intro/Mid/Fin 대본을 축약하거나 문장을 추가하지 말고 그대로 사용. 한국어 쇼호스트, 세로 9:16, 30fps, 무BGM, 무자막. 카드 타이포는 프레임 상단 40% 안에서.",
  "input": {
    "sheet_no": 45,
    "product_url": "https://shop.example.com/p/4438469916",
    "product_name": "Aurora 프렌치테리 맨투맨",
    "brand_name": "Aurora",
    "on_card_name": "Aurora 데일리 맨투맨",
    "highlights": [
      "부드러운 기모 프렌치테리",
      "S~XL 사이즈",
      "31,000원 → 24,810원 (20% 할인)"
    ],
    "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" }
}
```

각 키가 하는 일:

| 키 | 있는 이유 |
|---|---|
| `instruction` | 결정 사항: 대본을 그대로 쓸 것, 프레이밍, 넣지 말 것. 4000자 안쪽의 산문. |
| `input` | 데이터: 레시피는 아는 키(`product_url`, `host_image_url`, `vo_language`, `script`, `price`)를 읽고, 나머지는 맥락으로 따라갑니다. 여러분의 장부(`sheet_no`)를 넣어도 되지만 `output`으로 돌아오지는 않습니다. |
| `output_schema` | `full_video`가 완성본이고, `scenes[]`가 재시도할 수 있는 안정적인 `id`를 가진 클립 목록입니다. `SumeMediaFile#`은 내장 미디어 형태입니다. 모든 속성은 required이며, optional은 nullable로 표현합니다. |
| `primary_output_key` | `primary_output_url`을 완성본으로 만들고, `scenes`는 채웠지만 `full_video`는 못 채운 run을 거짓 성공이 아니라 `failed` receipt로 바꿉니다. |
| `generation_spend_cap_usd` | 이 run의 천장. 프로덕션 라이브 커머스 run은 $120 안팎입니다. |
| `communication.webhook_url` | run이 끝나면 서명된 POST 한 번. 아무것도 폴링하지 않습니다. `result_url`은 백업으로 두세요. |

`202`의 `data.id`와 `data.thread_id`를 여러분의 행에 저장하세요. 앞의 것은 receipt용, 뒤의 것은
재시도를 묶는 용도입니다.

웹훅이 도착하면 `payload.output.full_video.url`이 방송이고 `payload.output.scenes[]`가
클립입니다. `status: "stand-in"`이나 `"failed"`인 씬이 아래 재시도 레시피가 고칠 대상입니다.

## 웹훅 수신기

수신기는 네 가지를 순서대로 합니다. 원본 바이트에 대해 서명을 검증하고, `2xx`를 빨리 답하고,
`request_id`로 중복을 제거하고, 그다음에야 `outcome`에 따라 행동합니다. 아래 두 버전 모두 너무
큰 receipt(`payload: null`)를 처리합니다.

### Node

Web `Request` API 위의 프레임워크 중립 코드입니다. Next.js route handler, Hono, Workers, Deno
어디서든. `@sume-com/sdk`의 `verifyWebhook`을 씁니다.

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

const SECRET = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!; // 대시보드 Webhooks 탭

export async function POST(request: Request) {
  const raw = await request.text(); // 원본 문자열. 다시 직렬화한 객체는 절대 안 됨

  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 });

  // 중복 제거: 재시도는 request_id를 반복합니다. insert-or-ignore 후 처리 여부를 결정하세요.
  const fresh = await db.webhookEvents.insertIfAbsent({ id: event.request_id, body: raw });
  if (!fresh) return new Response(null, { status: 204 });

  // 지금 답하세요. run은 이미 종료됐고, 아래 작업은 오래 걸려도 됩니다.
  queueMicrotask(() => handle(event).catch(console.error));
  return new Response(null, { status: 204 });
}

async function handle(event: any) {
  // 1 MiB를 넘으면 receipt가 인라인되지 않습니다. 대신 가져오세요.
  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":
      // artifacts[]에는 실제 미디어가 있지만 output은 null: 보통 Format이 만들지 않는
      // 것을 요구하는 스키마입니다. 미디어를 보여 주고 output_error를 로그에 남기세요.
      return markNeedsReview(receipt.id, receipt.artifacts, receipt.output_error);
    case "error":
      return markFailed(receipt.id, receipt.error, receipt.artifacts);
  }
}
```

SDK를 쓸 수 없다면 검증은 열 줄 남짓입니다. 시크릿으로 `${timestamp}.${raw}`의 HMAC-SHA256을
구해 hex로 만들고, 5분 넘게 어긋난 timestamp를 거부한 뒤, `x-sume-webhook-signature`의
`sume-v1=` 뒤 값과 상수 시간으로 비교합니다. 전체 함수는
[Run 웹훅](/agents/run-webhooks#서명)에 있습니다.

### Python

FastAPI이며, 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):  # 여러분의 insert-or-ignore
        return Response(status_code=204)

    enqueue(handle, event)  # 먼저 답하고, 작업은 나중에
    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"])
```

첫 수신기마다 걸리는 것이 두 가지입니다. JSON을 대신 파싱해 주는 프레임워크는 서명된 바이트를
이미 망가뜨렸으므로 이 라우트에서는 원본 본문을 읽으세요. 그리고 응답 전에 영상을 렌더링하는
수신기는 10초 시도 예산을 태우고 작업 중에 재시도를 받으니, 기록하고 답한 뒤 처리하세요.

실제 run 없이 테스트하려면 `POST /v1/webhooks/test-deliveries`(또는 대시보드의 **Send test**)가
`webhook.test` 페이로드를 URL로 쏩니다. 실제 전달을 다시 보내려면
`POST /v1/format-runs/{run_id}/webhook/redeliver`를 쓰세요.

## 같은 스레드에서 씬 하나 재시도하기

run은 대화의 한 턴입니다. 클립 하나를 다시 만들려면 `previous_run_id`로 그 대화를 이어 가며
씬을 지목하세요. 보이스 트랙, 다른 클립, 대본은 그대로이고 전체 씬 목록이 다시 조립되어
돌아옵니다.

```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": "지정한 씬만 다시 만드세요. 다른 씬과 보이스 트랙은 바꾸지 마세요. 대본은 그대로. 새 테이크만.",
    "input": { "scene_id": "sc_7" },
    "output_schema": { "…": "첫 run과 동일" },
    "primary_output_key": "full_video",
    "generation_spend_cap_usd": 8,
    "communication": { "webhook_url": "https://acme.example.com/hooks/sume" }
  }'
```

운영자의 메모(어느 씬이, 무엇이 잘못됐고, 어떻게 바뀌어야 하는지)는 `instruction`에 넣고,
`input.scene_id`는 기계가 읽는 포인터로 유지하세요. 두 씬을 한 번에 하려면
`"scene_ids": ["sc_7", "sc_9"]`입니다.

돌아오는 것은 같은 `thread_id` 위의 새 run(`arun_…`, 새 receipt, 자체 웹훅)입니다.
`output.scenes[]`는 다시 전체 목록입니다. 재시도한 씬은 새 URL을 갖고, 나머지는 기존 URL을
유지하며, `full_video`는 새 URL로 다시 조립됩니다. 씬 하나 재시도의 예산은 생성 run의 일부로
잡으세요(측정된 프로덕션 재시도는 첫 run 비용의 약 20분의 1이었습니다). cap은 항상 보내세요.

재시도는 재인코딩이 아니라 새 테이크입니다. 그 씬의 생성 요소는 전부 다시 굴려집니다. 외관이
바뀌어야 하면 → 씬 재시도. 문구, 호스트, 제품이 바뀌면 → 새 제작, 새 씬 id.

이전 run이 남긴 것이 없으면 `400 previous_run_not_resumable`, 아직 실행 중이면
`409 previous_run_not_terminal`, 다른 Format을 주소로 쓰면 `400 previous_run_format_mismatch`로
거부됩니다. [실행과 결과](/formats/runs)를 참고하세요.

## 시트를 bulk run으로 배치하기

방송 시트의 한 행이 항목 하나가 되고, 시트 전체가 `POST …/bulk-runs` 하나가 됩니다. 큐는
`concurrency`개의 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`은 키 두 개입니다. 각 항목은 단일 run의 본문 그대로이며, 이 페이지의 첫
레시피를 행마다 하나씩 넣습니다. 완성된 대본이 없는 행은 클라이언트에서 건너뛰세요. 빈 항목은
없습니다.

```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`는 첫 `concurrency`개 항목이 이미 `running`인 큐(`frq_…`)를 돌려줍니다. 시트 행 ↔
`index` 매핑은 직접 관리하세요. `items[i].index`는 제출한 위치입니다.

진행 상황은 자식이 아니라 큐를 폴링하세요.

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

# 이제 모든 항목이 종료 상태입니다. 각 자식 receipt를 읽고, 끝났다고 하기 전에 counts.failed를 보세요.
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
```

배치 모양이 분명하게 보여 주는 세 가지. 큐에는 웹훅이 없고 `communication.webhook_url`은
항목별입니다. 큐의 `completed`는 모든 항목이 종료됐다는 뜻이지 모두 성공했다는 뜻이 아니므로
`counts.failed`와 각 자식의 `output_error`로 분기하세요. 그리고 이미 쓴 `Idempotency-Key`는
*옛* 큐와 함께 `202`를 돌려주니, 배치마다 새 키를 만드세요. 전체 계약:
[대량 실행](/formats/bulk-runs).

## 웹훅 없이 run 기다리기

엔드포인트를 노출할 수 없을 때(스크립트, CI job, 일회성 작업)는 백오프로 폴링하고 종료
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}'
```

TypeScript에서는 `subscribeFormatRun`이 생성과 루프를 한 호출로 합칩니다. 어떤 종료
status에서든 resolve하므로, 실패한 run은 예외가 아니라 분기할 결과입니다.

```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, // 폴링마다 phase timeline도 읽기
  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);
```

기본 timeout은 20분입니다. 긴 영상이면 올리고, receipt의 `expires_at`을 정직한 천장으로
쓰세요. timeout은 run을 취소하지 않습니다. run은 계속 실행되고 청구되니 run id를 보관했다가
나중에 다시 읽으세요.

## 호출 전에 Format 읽어 보기

설정 화면이나 사전 점검에 유용합니다. 쥐고 있는 키로 주소가 풀리는지, Format이 무엇을
받는지, 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}'
```

맞다고 믿는 키인데 여기서 `404 format_not_found`가 나오면 거의 항상 다른 키입니다. 팀 Format은
팀 워크스페이스에서 만든 키에만 답합니다. 키 소유자가 그 팀의 멤버라면 같은 뜻을 더 친절하게
`403 workspace_key_required`로 말해 줍니다.

## 다음

- [Run 만들기](/formats/call): 본문의 모든 필드
- [실행과 결과](/formats/runs): receipt, 폴링과 웹훅 규칙
- [오류와 비용](/formats/errors): 각 코드의 의미와 대응
- [제품에 Format 임베드](/cookbooks/embed-a-format): 멀티 테넌트 제품을 위한 키 보관, 비용 티어, artifact 처리
