---
title: 제품에 Format 임베드하기
description: 고객을 대신해 자체 대시보드에서 Sume Format을 실행하세요 — 키 보관, 멱등성, 웹훅, spend caps, artifact, 실패입니다.
---

자체 고객이 있는 제품이 있습니다. *당신의* UI에 버튼을 두고, 클릭한 고객을 위해
Sume가 만든 비디오나 이미지를 만들고 싶습니다. 이 페이지는 그 end-to-end
레시피입니다.

형태는 항상 같습니다.

```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)                       │
      └────────────────────────────────────────────────────────────┘
```

고객은 Sume와 대화하지 않습니다. 서버가 Sume API 키 하나를 들고, 고객을 대신해
Format을 실행하며, 결과를 자체 레코드에 매핑합니다.

invoke 계약 자체는 [Format 호출하기](/formats/call), 결과 형태는
[구조화 출력](/formats/structured-output)을 참고하세요. 이 페이지는 그 주변
연동입니다.

## 1. 키 보관

**Sume 계정 하나, 서버 측 키 하나, 고객은 많습니다.** Sume에는 최종 사용자별
자격 증명이 없고, 브라우저에 안전한 키도 없습니다.

| Rule | Why |
|---|---|
| 키는 서버 환경에 두고, 클라이언트 JavaScript, 모바일 번들, `NEXT_PUBLIC_*` 변수에 두지 마세요. | Sume 키는 *당신의* 크레딧을 씁니다. 키를 가진 누구나 소유한 Format을 cap까지 실행할 수 있습니다. |
| 키를 프록시하지 마세요. *호출*을 프록시하세요. | 브라우저 페이로드에 키를 붙여 전달하는 "패스스루" 엔드포인트는 한 홉 뒤의 같은 유출입니다. 엔드포인트는 고객 식별자를 받아 Sume 요청을 스스로 구성해야 합니다. |
| 엔드포인트에 자체 인가 검사를 두세요. | Sume는 당신을 인증하지, 고객을 인증하지 않습니다. *이* 고객이 *그* Format을 실행해도 되는지는 제품의 일입니다. |
| 새 키를 만들고 이전 키를 폐기해 교체하세요. | 기존 키에 스코프를 추가할 수 없습니다 — 아래를 참고하세요. |

[API Keys](https://www.sume.com/dashboard/api-keys)에서 `formats:read`와
`formats:write` 스코프로 키를 만드세요.

**Format API 트리거가 출시되기 전에 만든 키에는 그 스코프가 없고**, 이후에도
추가할 수 없습니다. 이전 키는 모든 run에서 `403 insufficient_scope`로 실패합니다.
새 키를 만드세요. 서비스 계정 키로는 Format run을 아예 만들 수 없으며 —
`details.reason`이 `service_account_format_runs_unsupported`로 실패합니다.

```ts
// server-only module. Importing this from a client component is the bug.
const SUME_API_KEY = process.env.SUME_API_KEY;
if (!SUME_API_KEY) throw new Error("SUME_API_KEY is not configured");

export async function startRunForCustomer(customer: Customer, order: Order) {
  const response = await fetch(
    "https://api.sume.com/v1/formats/acme/product-promo/runs",
    {
      method: "POST",
      headers: {
        authorization: `Bearer ${SUME_API_KEY}`,
        "content-type": "application/json",
        "idempotency-key": runKey(customer, order),
      },
      body: JSON.stringify({
        input: { product_url: order.productUrl },
        generation_spend_cap_usd: spendCapForPlan(customer.plan),
        communication: {
          mode: "webhook",
          webhook_url: "https://acme.example.com/hooks/sume",
        },
      }),
    },
  );
  // 202 on a fresh run, 200 on an idempotent replay. Both carry the receipt.
  const { data } = await response.json();
  return data;
}
```

## 2. 고객마다 `Idempotency-Key` 유도하기

고객은 더블클릭합니다. Job 큐는 재전달합니다. 키가 요청 시점이 아니라 만들고 있는
것에서 유도되지 않으면, 둘 다 유료 run 두 번이 됩니다.

```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 |
|---|---|
| 자체 안정 식별자 — tenant id, order id, Format slug, 의도적으로 재실행할 때 올리는 버전 — 을 해시하세요. | 요청마다 `uuidv4()`. 헤더를 장식으로 만듭니다. |
| 고객으로 네임스페이스를 두세요. | order id만으로 만든 키 — id가 충돌하는 두 테넌트가 run을 공유합니다. |
| 브라우저에 응답하기 전에 반환된 `run_id`를 레코드에 저장하세요. | 나중에 키를 다시 유도해 run을 찾는 데만 의존하세요. 가능하지만, 저장된 id는 재전송 한 번이 아니라 조회 한 번입니다. |

재전송 의미, 정확히:

| Replay | Result |
|---|---|
| 같은 키, 같은 본문 | **원래** run receipt와 `idempotency_hit: true`인 `200`. 두 번째 run도, 두 번째 청구도 없습니다. |
| 같은 키, 다른 본문 — 다른 `instruction` 포함 | `409 idempotency_conflict`. 아무것도 실행되지 않습니다. |
| 키 없음 | 매 호출이 새 유료 run을 시작합니다. |

## 3. run마다 spend cap 고르기

모든 Format에는 generation spend cap이 있습니다. run은 자신의 유효 cap을 넘길 수
없고, Format의 cap은 run이 따로 지정하지 않았을 때 물려받는 값입니다.

run 요청의 `generation_spend_cap_usd`는 플랫폼 최대치 $500까지 그 run만의 천장을
지정합니다 — Format 자체의 cap보다 큰 값도 적용되며, $500을 넘으면 `400`입니다.

그래서 cap은 자체 플랜 티어를 표현하기 자연스러운 자리입니다.

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

Format 자체의 cap은 `PublicFormat.generation_spend_cap_usd_micros`에서 읽으세요 —
항상 숫자이며, 한 번도 지정하지 않은 Format은 $400이 기본입니다. 해당 run의 유효
cap은 receipt의 `usage.generation_spend_cap_usd_micros`로 돌아옵니다.

cap은 *generation* spend를 묶습니다. 종료 receipt는 그 천장에 대해 run이 실제로
쓴 금액을 `usage.billable_amount_usd_micros`로 보고하며, 자체 UI에 run당 비용을
보여 주기에는 충분합니다 — 다만 Agent 자체의 LLM 턴은 제외되므로 run의 총비용도
청구서도 아닙니다. 고객 청구는 자체 기록에서 하고
[`GET /v1/usage`](/dashboard/usage)로 대조하세요.

## 4. 결과 받기

Format run은 비동기입니다. 끝났음을 아는 방법은 두 가지이며, 동일한 receipt를
담습니다.

| | Webhook | Poll |
|---|---|---|
| You do | `communication.webhook_url`을 등록하고, 서명을 검증한 뒤 `2xx`를 반환하세요. | 종료될 때까지 `status_url`을 루프하세요. |
| Available | 환경별로 롤아웃 중이며, **`api.sume.com`에는 아직 없습니다**. | 어디에나, 오늘. |
| Costs you | 공개 HTTPS 엔드포인트 하나. | 진행 중인 run마다 타이머 하나. |

웹훅 수신기를 지금 만드세요 — 계약은 최종이며, 오늘 넘긴 URL은 환경에 전달이
켜지는 날 POST를 받기 시작합니다. 그때까지는 폴링 경로를 폴백으로 유지하세요.
전체 계약: [Run 웹훅](/agents/run-webhooks).

### 모든 전달을 검증하세요

Sume는 `<timestamp>.<raw_body>`에 대한 HMAC-SHA256으로 raw body에 서명하고
`x-sume-webhook-signature`에 `sume-v1=<hex>`를 보냅니다. **파싱하기 전에**
검증하세요.

```ts
import crypto from "node:crypto";

export async function handleSumeWebhook(req: Request) {
  const raw = await req.text(); // raw string, not a re-serialized object
  const timestamp = req.headers.get("x-sume-webhook-timestamp") ?? "";
  const signature = req.headers.get("x-sume-webhook-signature") ?? "";

  if (!verifySumeWebhook({ raw, timestamp, signature, 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 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
}

function verifySumeWebhook({ raw, timestamp, signature, secret }: {
  raw: string; timestamp: string; signature: string; secret: string;
}) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;

  const expected = `sume-v1=${crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${raw}`)
    .digest("hex")}`;
  const actual = Buffer.from(signature);
  const want = Buffer.from(expected);
  return actual.length === want.length && crypto.timingSafeEqual(actual, want);
}
```

서명 시크릿은 대시보드의 **웹훅** 탭(`/dashboard/webhooks`)에서 확인하거나,
`account:read` 범위를 가진 API 키로 `GET /v1/webhooks/signing-secret`을 호출해
받으세요. 워크스페이스별로 파생된 값이므로, 서명이 유효하다는 것은 공용 시크릿을
가진 누군가가 아니라 여러분에게 서명되었다는 뜻입니다. 전달 워커가 서명할 때 쓰는
것과 같은 이름인 `SUME_COM_WEBHOOK_SIGNING_SECRET`으로, API 키와 같은 방식으로
저장하세요.

연동자를 잡는 네 가지:

- **raw body에 대해 검증하세요.** JSON을 파싱해 객체를 넘기는 프레임워크는 이미
  서명된 바이트를 파괴했습니다. Express에서는 이 라우트에만
  `express.raw({ type: "application/json" })`를 마운트하세요.
- **빠르게 `2xx`를 반환한 뒤 작업하세요.** 전달 시도 예산은 10초입니다. 응답
  전에 비디오를 렌더하는 수신기는 작업하는 동안 재시도됩니다.
- **`request_id`로 중복 제거하세요.** 재시도가 그것을 반복합니다. 불안정한
  엔드포인트에 대한 열 번의 시도가 데이터베이스에 열 행이 되면 안 됩니다.
- **`3xx`는 전달이 아닙니다.** 리다이렉트는 따르지 않습니다. 리다이렉터가 아니라
  최종 URL을 등록하고, HTTP도 안 됩니다 — 비 HTTPS, localhost, 사설 대역 URL은
  제출 시 `400 invalid_request`로 거절되고 전달 시에도 다시 검사됩니다.

### Job 웹훅은 다른 표면입니다

`POST /v1/models/...`를 직접 호출한다면, 그것들은 `job_id`가 있는 **generation-job**
웹훅(`job.completed` 등)을 내보냅니다. [웹훅](/workflows/webhooks)에 설명되어
있습니다. 이벤트도, 페이로드도, 수명주기도 다릅니다.

서명 방식은 같으므로 검증기 하나로 둘 다 커버합니다 — 다만 `event`로 라우팅하고
본문에 `run_id`가 있다고 가정하지 마세요. 둘을 처리하는 단일 수신기는 먼저 이벤트
이름으로 switch하고, 인식하지 못한 것은 `204`로 취급해 새 이벤트 유형이 500과
재시도 폭풍이 되지 않게 하세요.

## 5. artifact를 UI에 매핑하기

종료 `completed` receipt는 미디어를 담는 필드가 세 개입니다.

| Field | Use it for |
|---|---|
| `primary_output_url` | 보여줄 하나입니다. Format이 단일 primary 파일을 만들지 않으면 `null`입니다. |
| `artifacts[]` | run이 만든 모든 것: `{ id, type, url, content_type, size_bytes, width, height, duration_ms, checksum_sha256 }`. |
| `output` | `output_schema`에 투영된 Format의 구조화 결과입니다. 안의 미디어는 같은 URL을 가리킵니다. [구조화 출력](/formats/structured-output)을 참고하세요. |

모든 URL은 내구성 있는 `media.sume.com` HTTPS URL입니다. **만료되지 않으며**, 그래서
임베드가 실용적입니다 — 레코드에 URL을 저장하고 refresh 없이 영원히 렌더할 수
있습니다.

설계에 넣을 만한 결과 두 가지:

- **내구성 URL은 공개 URL입니다.** 가진 사람은 누구나 fetch할 수 있습니다.
  로그, 오류 리포트, 고객 브라우저 기록에 남습니다. 제품 모델이 고객 A가 고객
  B의 출력을 보면 안 되는 것이라면, 자체 인증 라우트로 바이트를 프록시하거나
  receipt 시점에 자체 스토리지로 복사해 거기서 서빙하세요.
- **복사할지, 링크할지 결정하세요.** 링크는 무료이고 즉시입니다. 복사는 스토리지가
  들지만 Sume를 떠나도 남습니다. 그 보장이 필요하면 웹훅에서, 레코드를 ready로
  표시하기 전에 복사하세요.

```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[]`는 run이 종료될 때까지 비어 있으며, `output`을 채우는 같은 job
원장에서 가져옵니다 — 둘은 항상 일치합니다.

## 6. 실패 taxonomy

run은 구분 가능한 네 곳에서 실패합니다. UI는 각각에 다른 메시지가 필요합니다.
"무언가 잘못됐습니다"로 합치면 답할 수 없는 지원 티켓이 가장 빨리 생깁니다.

### 제출 시 — 아무것도 실행되지 않았고, 청구도 없습니다

| Code | Status | What it means for your integration |
|---|---|---|
| `insufficient_scope` | 403 | 키에 `formats:read` / `formats:write`가 없거나 서비스 계정 키입니다. 요청이 아니라 키를 고치세요. |
| `format_not_found` | 404 | 알 수 없는 handle·slug이거나, 이 키가 소유하지 않은 Format입니다. vanity 경로에서 1st-party Format이 반환하는 값이기도 합니다. |
| `format_not_forkable` | 409 | Format 카드가 아니라 내장 기능을 주소로 잡았습니다. Formats by Sume 또는 직접 만든 Format을 호출하세요. |
| `format_api_trigger_disabled` | 409 | 그 Format의 API 트리거가 꺼져 있습니다. |
| `format_inactive` | 409 | Format이 비활성입니다. |
| `format_run_in_progress` | 409 | `on_active_run: "reject"`일 때만. 나중에 재시도하거나 "이미 실행 중"을 표시하세요. |
| `idempotency_conflict` | 409 | 같은 키, 다른 본문. 키 유도가 불안정합니다 — 재시도하기 전에 그것을 고치세요. |
| `invalid_request` | 400 | 공개 HTTPS URL이 아닌 `webhook_url`을 포함합니다. |

여기의 4xx는 일시적 오류가 아니라 호출의 버그로 취급하세요. `insufficient_scope`를
영원히 재시도하는 것은 흔하고 비싼 실수입니다.

### run 시 — run은 존재했고, 결과를 만들지 못했습니다

`status`는 `failed`이고, receipt는 `error`와 `output_error`를 담습니다. 특히:

| `error.code` | Meaning |
|---|---|
| `unattended_blocked` | 사람 없이 만족할 수 없는 게이트에 걸렸습니다 — 맞는 아바타 없음, 채팅이라면 물었을 누락된 입력. 메시지는 보여 주도록 쓰여 있습니다. |
| `format_run_failed` | 일반 실패입니다. `error.message`를 읽으세요. cap을 넘겨 쓰려던 run도 여기로 오므로, 계속 실패하는 플랜 티어는 먼저 `usage.generation_spend_cap_usd_micros`와 대조하세요. |

run 실패는 **새** 멱등성 키로 재시도하세요 — 이전 키는 이미 실패한 run에 묶여
있고, 재사용하면 같은 실패 receipt가 돌아옵니다.

**API run은 unattended입니다.** 대화형 채팅용 Format은 사람의 승인을 기다리며
멈출 수 있고, API에서는 그 승인이 미리 허용되어 spend cap 안에서 계속합니다.
그래서 `completed`는 실제 결과입니다 — 절반만 끝난 run이 완료로 표기되어 넘어오지
않습니다.

### 종료이지만 실패가 아님

| `status` | Handle it as |
|---|---|
| `canceled` | 누군가가 `POST /v1/format-runs/{id}/cancel`을 호출했습니다. **웹훅 없음** — 취소 응답과 `status_url` 폴링을 사용하세요. |
| `skipped` | `on_active_run: "skip"`을 보냈고 이미 진행 중인 run이 있었습니다. **skipped run은 웹훅을 전달하지 않습니다** — create 응답이 이미 `skip_reason`과 함께 알려 줍니다. POST를 기다리지 말고 받은 응답의 상태를 읽으세요. Format run은 기본적으로 동시 실행이 허용됩니다. |

### 전달 시 — run은 괜찮고, 엔드포인트가 아니었습니다

전달 결과는 run을 바꾸지 않습니다. 열 번 거절당해도 *전달*은 실패하고 run은 여전히
`completed`입니다. `result_url`에서 가져오세요.

코드가 필요한 전달 케이스 하나: **1 MiB**를 넘는 receipt는 `payload: null`과
`error.code`가 `payload_too_large`로 도착하며, 대신 가져올 `result_url`을 담습니다.
`payload`가 객체라고 가정하는 핸들러는 가장 크고 가치 있는 run에서 throw합니다.

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

## 출시 전 체크리스트

- [ ] `SUME_API_KEY`는 서버 전용이며 모든 클라이언트 번들에 없습니다.
- [ ] run 엔드포인트는 Sume를 호출하기 전에 자체 고객을 인가합니다.
- [ ] `Idempotency-Key`는 요청마다 생성하지 않고 안정 식별자에서 유도합니다.
- [ ] `generation_spend_cap_usd`는 플랜 티어마다 설정됩니다.
- [ ] 웹훅 수신기는 raw body에 대해 `sume-v1`을 검증하고 1초 안에 `2xx`를 반환합니다.
- [ ] 전달은 `request_id`로 중복 제거됩니다.
- [ ] `payload: null`(과도한 크기 receipt)은 `result_url`로 폴백합니다.
- [ ] 프로덕션에 전달이 아직 없으므로 `status_url` 폴링이 여전히 연결되어 있습니다.
- [ ] 위 모든 오류 코드가 지원팀이 조치할 수 있는 메시지에 매핑됩니다.

## 다음

- [Format 호출하기](/formats/call) — invoke 계약
- [대량 실행](/formats/bulk-runs) — 그 run들의 서버 측 큐
- [구조화 출력](/formats/structured-output) — 스키마, 투영, 실패 모드
- [실행과 결과](/formats/runs) — receipt, 필드별
- [Run 웹훅](/agents/run-webhooks) — 전달, 서명, 재시도 전체
- [웹훅](/workflows/webhooks) — generation-job 웹훅, 다른 표면
