---
title: Run webhooks
description: Receive one signed POST when an Action, Format, or Agent Completion run completes or fails.
---

Every run surface accepts a `communication.webhook_url`. When the run
**completes** or **fails**, Sume sends **one** signed POST to that URL carrying
the same receipt the poll endpoints return. Delivery matches fal’s webhook
shape: envelope `status` is `OK` or `ERROR` for those outcomes — **not** for
cancel.

This is the alternative to a poll loop. You still get `status_url` and
`result_url`, and polling remains supported — a webhook just saves you from
running a loop per run.

## Availability

| Environment                        | Delivery                           |
| ---------------------------------- | ---------------------------------- |
| Development — `api.dev.sume.com`   | **Live.** Your endpoint is called. |
| Production — `api.sume.com`        | **Live.** Your endpoint is called. |

Supplying `communication.webhook_url` arms delivery in both environments. Polling
`status_url` / `result_url` remains supported as a backup. **Any run whose caller
already supplied a `webhook_url` (including older runs that reach a terminal
state after enablement) can receive a POST** — register only endpoints you still
want traffic on.

This page covers **run** webhooks. Generation-**job** webhooks (`job.completed`
and friends, from `POST /v1/models/...`) are a separate surface with a separate
event set — see [Webhooks](/workflows/webhooks). The signature scheme is
identical, so one verifier covers both.

## Ask for a webhook

Send `communication.webhook_url` when you start the run. It works the same on
all three surfaces.

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

| Field | Notes |
|---|---|
| `communication.webhook_url` | Public HTTPS URL, max 2048 characters. Localhost, private-network, and non-HTTPS URLs are rejected with `400 invalid_request`. |
| `communication.callback_url` | Accepted alias for `webhook_url`. Identical behavior. Send one or the other. |
| `communication.mode` | `async` (default) or `webhook`. The URL is what arms delivery; `mode` is descriptive. |
| Top-level `webhook_url` / `callback_url` / `mode` | fal-shaped aliases. Normalized into `communication.*`. Same value on both layers is fine; conflicting values return `400 invalid_request`. |

The URL is re-validated as a public HTTPS URL at delivery time, not only when
you submit. Redirects are not followed — a `3xx` is not a delivery.

## Events

One terminal event per run family. The outcome lives in `status` and
`payload.status`, not in the event name.

| Surface | Event | Receipt `object` |
|---|---|---|
| Action runs | `action.run.terminal` | `action.run` |
| Format runs | `format.run.terminal` | `format.run` |
| Agent Completions | `agent.run.terminal` | `agent.run` |

A partner embedding only Formats can route on
`event === "format.run.terminal"` without inspecting the body.

### One per turn, not one per artifact

A run is one **agent turn**, so its terminal event fires exactly once — however
many clips, images, or intermediate files that turn produced. There is no
per-artifact run event, and none is planned.

That matters for [continued runs](/formats/runs#continue-a-run): continuing a
run starts a **new** run, which delivers its own single terminal webhook under
the new run id. The original run's webhook already fired and will not fire
again.

If you want progress *inside* a turn, that is the generation-**job** layer
([Webhooks](/workflows/webhooks)), which fires per job as each one completes.
Job events name a job, not a step of your recipe.

## Payload

```json
{
  "event": "format.run.terminal",
  "request_id": "run_01J...",
  "run_id": "run_01J...",
  "object": "format.run",
  "status": "OK",
  "outcome": "ok",
  "created_at": "2026-08-05T09:00:00.000Z",
  "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
}
```

| Field | Notes |
|---|---|
| `event` | See the table above. |
| `request_id` | Equals `run_id`. Stable across retries — use it to dedupe. |
| `run_id` | The run this delivery is about. |
| `object` | The receipt's own `object`. |
| `status` | `OK` when the run completed, `ERROR` when it failed. Binary — see `outcome`. |
| `outcome` | `ok`, `degraded`, or `error`. **Branch on this** when the question is "did I get usable output". |
| `created_at` | When this delivery body was built. Use it to order deliveries — `request_id` cannot, since it is stable across retries. |
| `payload` | The run receipt. `null` only on overflow — see below. |
| `error` | `null` when `status` is `OK`; otherwise `{ code, message }`. |

### `OK` does not always mean you got output

A run can complete, bill you, and produce real media in `artifacts[]` while still failing to
project that media into your `output_schema`. `status` is `OK` on that path — the run genuinely
completed — but `output` is `null` and `output_error` says why. That is what `outcome: "degraded"`
names.

```ts
switch (event.outcome) {
  case "ok":
    return ship(event.payload.output);
  case "degraded":
    // Real artifacts, no structured output. Usually a schema that asks for a
    // field the Format never produces.
    return reviewManually(event.payload.artifacts, event.payload.output_error);
  case "error":
    return retryOrAlert(event.payload?.error ?? event.error);
}
```

A handler written against `status` alone keeps working; it just cannot tell `ok` from `degraded`.

### Two different `request_id`s

The envelope's `request_id` is the **run id** — it is the dedupe key, and it is deliberately
stable across retries. The receipt nested at `payload.request_id` is a *correlation* id for the
call that produced it, and it is the run id here but an HTTP `req_…` id when you read the same
receipt from `GET /v1/format-runs/{run_id}`. Dedupe on the envelope's `request_id` (or `run_id`,
which equals it) and ignore the nested one.

`usage.billable_amount_usd_micros` carries the generation spend attributed to the
run, and `usage` is `null` when that could not be read — see
[Runs and results](/agents/actions/runs#the-run-receipt). `GET /v1/usage` stays
the authoritative billing record.

### `payload` is the receipt

`payload` is byte-identical to the `data` object of
`GET /v1/{family}-runs/{run_id}` for the same run. The poll response wraps it in
`{ "data": ... }`; the webhook does not.

```ts
// One handler, two transports.
handleRun(webhookBody.payload);
handleRun((await fetchRun(runId)).data);
```

It is built by the same code path the poll endpoint calls, so it cannot drift.

### Failure, cancelation, and skips

A **failed** run arrives with `status: "ERROR"` and a populated `error`.
`payload` is still the full receipt — a failed run's receipt carries
`artifacts` and `output_error`, and you usually want them.

```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` mirrors `payload.error.code` when the receipt carries one —
typically a specific reason such as `output_schema_unsatisfied`, otherwise the
family's generic `action_run_failed` / `format_run_failed` / `agent_run_failed`.

**A `canceled` run does not deliver a webhook.** Cancel is a separate API path
(same idea as fal’s queue cancel — no cancel webhook status). After you
`POST …/cancel`, trust the cancel response and poll `status_url` until
`payload.status` is `canceled`; do not wait for a POST.

**A `skipped` run never delivers a webhook.** `on_active_run: "skip"` records a
terminal run immediately, without ever starting work, so there is no completion
to notify you about — the create response already told you. Read `status` on the
response you got back rather than waiting for a POST that will not arrive.
Defaults differ by surface: **Format** defaults to `allow` (concurrency);
**Action** defaults to `skip` — see [Calling a Format](/formats/call#request-body)
vs [Action API trigger](/agents/actions/api-trigger#request-body).

### Oversized receipts

A receipt over **1 MiB** cannot be delivered inline. Sume sends the envelope with
`payload: null` and an error telling you where to fetch it:

```json
{
  "status": "OK",
  "outcome": "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` still reports the run's real outcome. A run that succeeded and was too
large to ship did not fail.

## Signature

Sume signs the raw JSON body with HMAC-SHA256 over `<timestamp>.<raw_body>`.

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

Verify against the **raw** request body, before any JSON parse or re-serialize.
Reject a timestamp outside your replay window — five minutes is a reasonable
default.

Read your signing secret on the **Webhooks** tab of the dashboard
(`/dashboard/webhooks`), or from `GET /v1/webhooks/signing-secret` with any API
key carrying `account:read`. It is derived for your workspace — nobody else's
secret verifies a delivery signed for you. Store it as
`SUME_COM_WEBHOOK_SIGNING_SECRET` (the same name the delivery worker uses when
signing).

Every delivery carries `x-sume-webhook-secret-fingerprint`, and
`webhook_delivery.signing_secret_fingerprint` on the run receipt repeats it.
Compare it with the fingerprint shown next to the secret in the dashboard to
confirm both sides hold the same one, without sending the secret anywhere.

In TypeScript, [`@sume-com/sdk`](/sdk) ships this check — see
[Verifying webhooks](/sdk/webhooks):

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

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

The scheme in full, for a receiver that cannot use it — another language, or a
gateway in front of your app:

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

This is the same verifier that validates generation-job webhooks. Write it once.

## Delivery behavior

| Property | Value |
|---|---|
| When | Once per run, when it completes or fails. |
| Success | Any `2xx`. |
| Retries | Up to 10 attempts total, then `webhook_delivery.status` is `exhausted`. |
| Backoff | `min(max(30s × 2^(attempt−1) with jitter, Retry-After), 1h)`. Honour `Retry-After` on 429/503. |
| Timeout | 10s per attempt. |
| Redirects | Not followed. A `3xx` is a failed attempt. |

Return `2xx` quickly, after durably recording the event — do your processing
afterward. A slow endpoint burns the 10-second attempt budget and gets retried.

Dedupe on `request_id`. It is the same value on every retry of the same run.

A delivery outcome never changes the run itself. An endpoint that refuses all ten
attempts leaves you with a failed *delivery* and a run that is still `completed`;
fetch it from `result_url`.

## Send test and Redeliver

Send test lives once on `/dashboard/webhooks` (also `POST /v1/webhooks/test-deliveries`,
`account:write`). It fires a dummy `webhook.test` payload. It is **not** a
replay of a real Format run.

To replay a real terminal call, use **Redeliver** on that delivery row, or:

```http
POST /v1/format-runs/{run_id}/webhook/redeliver
```

Requires `formats:write`. Empty body. Re-POSTs the current `format.run.terminal`
receipt with a fresh timestamp and signature. This still works after automatic
attempts are exhausted — it does not consume one of the automatic 10.

`409 webhook_not_configured` if the run had no `webhook_url`. `409 run_not_terminal`
if it is still running. A run you cannot see is `404 format_run_not_found`.
Missing `formats:write` is `403 insufficient_scope`, never 404.

Dedupe on `request_id` / `run_id`. Redeliver does not send to a different URL.

Job redeliver is documented on [Webhooks](/workflows/webhooks).

## Next

- [Verifying webhooks](/sdk/webhooks) — `verifyWebhook` and `SUME_COM_WEBHOOK_SIGNING_SECRET`
- [Runs and results](/agents/actions/runs) — the receipt, field by field
- [Advanced: run a schedule via API](/agents/actions/api-trigger)
- [Calling a Format](/formats/call)
- [Embed a Format in your product](/cookbooks/embed-a-format) — the whole partner integration, end to end
- [Webhooks](/workflows/webhooks) — generation-job webhooks, the other surface
