---
title: Run 웹훅
description: Action, Format, Agent Completion 실행이 완료되거나 실패했을 때 서명된 POST를 한 번 받는 방법을 살펴보세요.
---

모든 실행 표면은 `communication.webhook_url`을 받습니다. 실행이 **완료**되거나
**실패**하면 Sume가 그 URL로 서명된 POST를 **한 번** 보내며, 폴링 엔드포인트가
반환하는 것과 같은 영수증을 담습니다. 전달은 fal 웹훅 형태와 같습니다. 봉투
`status`는 그 결과에 대해 `OK` 또는 `ERROR`이며, **취소에는 웹훅이 없습니다**.

폴링 루프의 대안입니다. `status_url`과 `result_url`은 그대로 받고 폴링도 계속
지원합니다. 웹훅은 실행마다 루프를 돌리는 수고를 덜어 줄 뿐입니다.

## 제공 여부

| 환경                                | 전달                                   |
| ---------------------------------- | ------------------------------------- |
| 개발 — `api.dev.sume.com`           | **동작합니다.** 엔드포인트가 호출됩니다. |
| 프로덕션 — `api.sume.com`           | **동작합니다.** 엔드포인트가 호출됩니다. |

`communication.webhook_url`을 주면 두 환경 모두에서 전달이 무장됩니다.
`status_url` / `result_url` 폴링은 백업으로 계속 지원됩니다. **이미
`webhook_url`을 제공한 실행(활성화 이후 종료에 도달한 예전 실행 포함)은 POST를
받을 수 있으므로**, 지금도 트래픽을 받고 싶은 엔드포인트만 등록하세요.

이 페이지는 **run** 웹훅을 다룹니다. 생성 **Job** 웹훅(`POST /v1/models/...`에서
오는 `job.completed` 등)은 별도 이벤트 집합을 가진 별개 표면입니다.
[웹훅](/workflows/webhooks)에서 살펴보세요. 서명 스킴은 같으므로 검증기 하나로
둘 다 처리할 수 있습니다.

## 웹훅 요청하기

실행을 시작할 때 `communication.webhook_url`을 보내세요. 세 표면 모두 같은
방식으로 동작합니다.

<!-- api-call-example:format-vanity-run -->

| 필드 | 설명 |
|---|---|
| `communication.webhook_url` | 공개 HTTPS URL이며 최대 2048자입니다. localhost, 사설 네트워크, HTTPS가 아닌 URL은 `400 invalid_request`로 거부됩니다. |
| `communication.callback_url` | `webhook_url`의 별칭입니다. 동작은 같습니다. 둘 중 하나만 보내세요. |
| `communication.mode` | `async`(기본) 또는 `webhook`입니다. 실제로 전달을 켜는 것은 URL이고, `mode`는 설명용입니다. |
| 최상위 `webhook_url` / `callback_url` / `mode` | fal 형태의 별칭입니다. `communication.*`로 정규화됩니다. 양쪽이 같으면 허용되고, 값이 다르면 `400 invalid_request`입니다. |

URL은 제출할 때만이 아니라 전달 시점에도 공개 HTTPS URL인지 다시 검증합니다.
리다이렉트는 따라가지 않으므로 `3xx`는 전달이 아닙니다.

## 이벤트

실행 계열마다 종료 이벤트가 하나씩 있습니다. 결과는 이벤트 이름이 아니라
`status`와 `payload.status`에 담깁니다.

| 표면 | 이벤트 | 영수증 `object` |
|---|---|---|
| Action 실행 | `action.run.terminal` | `action.run` |
| Format 실행 | `format.run.terminal` | `format.run` |
| Agent Completions | `agent.run.terminal` | `agent.run` |

Format만 임베드한 파트너라면 본문을 들여다보지 않고
`event === "format.run.terminal"`로 분기할 수 있습니다.

## 페이로드

```json
{
  "event": "format.run.terminal",
  "request_id": "run_01J...",
  "run_id": "run_01J...",
  "object": "format.run",
  "status": "OK",
  "payload": {
    "id": "run_01J...",
    "object": "format.run",
    "status": "completed",
    "format": { "id": "skl_...", "slug": "product-promo", "title": "Product promo", "version": 3 },
    "output": { "text": "...", "videos": [] },
    "primary_output_url": "https://media.sume.com/artifacts/artf_.../out.mp4",
    "artifacts": [],
    "usage": { "currency": "USD", "billable_amount_usd_micros": 240000, "generation_spend_cap_usd_micros": 1000000 },
    "status_url": "https://api.sume.com/v1/format-runs/run_01J.../status",
    "result_url": "https://api.sume.com/v1/format-runs/run_01J.../result",
    "cancel_url": "https://api.sume.com/v1/format-runs/run_01J.../cancel",
    "request_id": "run_01J..."
  },
  "error": null
}
```

| 필드 | 설명 |
|---|---|
| `event` | 위 표를 참고하세요. |
| `request_id` | `run_id`와 같습니다. 재시도에도 값이 유지되니 중복 제거에 사용하세요. |
| `run_id` | 이 전달이 다루는 실행입니다. |
| `object` | 영수증 자체의 `object`입니다. |
| `status` | 실행이 완료됐으면 `OK`, 실패했으면 `ERROR`입니다. |
| `payload` | 실행 영수증입니다. 크기 초과일 때만 `null`입니다. 아래를 참고하세요. |
| `error` | `status`가 `OK`면 `null`이고, 그 외에는 `{ code, message }`입니다. |

`usage.billable_amount_usd_micros`는 실행에 귀속된 생성 지출을 담고, 이를 읽지
못하면 `usage`는 `null`입니다.
[실행과 결과](/agents/actions/runs#the-run-receipt)에서 살펴보세요. 권위 있는
과금 기록은 여전히 `GET /v1/usage`입니다.

### `payload`가 곧 영수증입니다

`payload`는 같은 실행에 대한 `GET /v1/{family}-runs/{run_id}`의 `data` 객체와
바이트 단위로 동일합니다. 폴링 응답은 이를 `{ "data": ... }`로 감싸지만 웹훅은
감싸지 않습니다.

```ts
// 핸들러 하나, 전송 방식 둘.
handleRun(webhookBody.payload);
handleRun((await fetchRun(runId)).data);
```

폴링 엔드포인트가 호출하는 것과 같은 코드 경로로 만들어지므로 어긋날 수
없습니다.

### 실패, 취소, 건너뜀

**실패한** 실행은 `status: "ERROR"`와 값이 채워진 `error`로 도착합니다.
`payload`는 여전히 전체 영수증입니다. 실패한 실행의 영수증에도 `artifacts`와
`output_error`가 담겨 있고, 보통 그것들이 필요합니다.

```json
{
  "event": "format.run.terminal",
  "request_id": "run_01J...",
  "run_id": "run_01J...",
  "object": "format.run",
  "status": "ERROR",
  "payload": { "id": "run_01J...", "status": "failed", "error": { "code": "format_run_failed", "message": "..." } },
  "error": { "code": "format_run_failed", "message": "..." }
}
```

영수증에 오류가 있으면 `error.code`가 `payload.error.code`를 그대로 반영합니다.
보통은 `output_schema_unsatisfied` 같은 구체 사유이고, 없으면 계열별 일반
코드인 `action_run_failed` / `format_run_failed` / `agent_run_failed`입니다.

**`canceled` 실행은 웹훅을 보내지 않습니다.** 취소는 별도 API 경로입니다(fal
큐 취소와 같은 개념 — 취소용 웹훅 상태가 없습니다). `POST …/cancel` 뒤에는
취소 응답을 신뢰하고 `status_url`을 폴링해 `payload.status`가 `canceled`가 될
때까지 기다리세요. POST를 기다리지 마세요.

**`skipped` 실행은 웹훅을 보내지 않습니다.** `on_active_run: "skip"`은 작업을
시작하지도 않고 곧바로 종료 실행을 기록하므로 알릴 완료 자체가 없습니다. 생성
응답이 이미 알려줬습니다. 오지 않을 POST를 기다리지 말고 받은 응답의 `status`를
읽으세요.

### 너무 큰 영수증

**1 MiB**를 넘는 영수증은 본문에 담아 전달할 수 없습니다. Sume는 `payload: null`과
함께 어디서 가져오면 되는지 알려주는 오류를 담아 봉투를 보냅니다.

```json
{
  "status": "OK",
  "payload": null,
  "error": {
    "code": "payload_too_large",
    "message": "Run receipt exceeded the 1048576-byte webhook body limit. Fetch the receipt from result_url instead.",
    "result_url": "https://api.sume.com/v1/format-runs/run_01J.../result"
  }
}
```

`status`는 여전히 실행의 실제 결과를 보고합니다. 성공했지만 크기가 커서 보내지
못한 실행이 실패한 것은 아닙니다.

## 서명

Sume는 원본 JSON 본문에 `<timestamp>.<raw_body>`에 대한 HMAC-SHA256 서명을
붙입니다.

```text
content-type: application/json
x-sume-webhook-timestamp: 1785000000
x-sume-webhook-signature: sume-v1=<hex_signature>
```

JSON을 파싱하거나 다시 직렬화하기 전에 **원본** 요청 본문으로 검증하세요.
재전송 허용 시간을 벗어난 타임스탬프는 거부하세요. 5분이 무난한 기본값입니다.

서명 시크릿은 대시보드의 **웹훅** 탭(`/dashboard/webhooks`)에서 직접 확인하거나,
`account:read` 범위를 가진 API 키로 `GET /v1/webhooks/signing-secret`을 호출해
받을 수 있습니다. 워크스페이스별로 파생된 값이므로, 다른 사람의 시크릿으로는
여러분에게 서명된 전송을 검증할 수 없습니다. 전달 워커가 서명할 때 쓰는 것과 같은
이름인 `SUME_COM_WEBHOOK_SIGNING_SECRET`으로 저장하세요.

모든 전송에는 `x-sume-webhook-secret-fingerprint` 헤더가 실리고, Run 영수증의
`webhook_delivery.signing_secret_fingerprint`에도 같은 값이 담깁니다. 대시보드에서
시크릿 옆에 표시된 지문과 비교하면 시크릿을 어디에도 보내지 않고 양쪽이 같은 값을
쓰고 있는지 확인할 수 있습니다.

TypeScript에서는 [`@sume-com/sdk`](/sdk)가 이 검증을 제공합니다.
[웹훅 검증](/sdk/webhooks)에서 살펴보세요.

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

const ok = await verifyWebhook({ body: rawBody, headers: request.headers, secret });
```

다른 언어나 앱 앞단 게이트웨이처럼 SDK를 쓸 수 없는 수신기를 위해 스킴 전체를
싣습니다.

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

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

  const digest = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  const expected = `sume-v1=${digest}`;
  const actual = Buffer.from(signatureHeader);
  const expectedBuffer = Buffer.from(expected);
  if (actual.length !== expectedBuffer.length) return false;

  return crypto.timingSafeEqual(actual, expectedBuffer);
}
```

생성 Job 웹훅을 검증하는 것과 같은 검증기입니다. 한 번만 작성하세요.

## 전달 동작

| 속성 | 값 |
|---|---|
| 시점 | 실행당 한 번, 완료되거나 실패할 때입니다. |
| 성공 | 모든 `2xx`입니다. |
| 재시도 | 최대 10회이며, 그다음 `webhook_delivery.status`는 `exhausted`입니다. |
| 백오프 | `min(max(지터를 더한 30s × 2^(attempt−1), Retry-After), 1h)`입니다. 429/503의 `Retry-After`를 존중합니다. |
| 타임아웃 | 시도당 10초입니다. |
| 리다이렉트 | 따라가지 않습니다. `3xx`는 실패한 시도입니다. |

이벤트를 내구성 있게 기록한 뒤 `2xx`를 빠르게 반환하고, 처리는 그다음에 하세요.
느린 엔드포인트는 10초 예산을 소진하고 재시도를 부릅니다.

`request_id`로 중복을 제거하세요. 같은 실행의 재시도에서는 값이 같습니다.

전달 결과는 실행 자체를 바꾸지 않습니다. 열 번의 시도를 모두 거부한
엔드포인트가 남기는 것은 실패한 *전달*이고, 실행은 여전히 `completed`입니다.
`result_url`에서 가져오세요.

## 다음

- [실행과 결과](/agents/actions/runs) — 영수증을 필드별로 살펴봅니다
- [고급: API로 스케줄 실행하기](/agents/actions/api-trigger)
- [Format 호출하기](/formats/call)
- [제품에 Format 임베드하기](/cookbooks/embed-a-format) — 파트너 연동 전체입니다
- [웹훅](/workflows/webhooks) — 다른 표면인 생성 Job 웹훅입니다
