Format webhook outcome: ok, degraded or error, and what to do
The format.run.terminal webhook has three outcomes. Verify the sume-v1 signature, then branch: ok uses output, degraded uses artifacts, error is a failed run.

The format.run.terminal webhook carries an outcome field with three values: ok, degraded and error. Branch on it to answer one question, whether you got usable output. ok means the run completed with output. degraded means it completed and was billed, real media is in artifacts[], but output is null because the projection did not match your schema. error means the run did not complete.
The fields to branch on
The webhook body wraps the same receipt that GET /v1/format-runs/{run_id} returns, as payload. One handler can serve both transports.
| Field | Values | Use |
|---|---|---|
event | format.run.terminal | Route on it |
status | OK or ERROR | Coarse result |
outcome | ok, degraded, error | Did I get usable output |
run_id, request_id | Equal, stable across retries | Dedupe key |
created_at | Time Sume built this body | Order deliveries; request_id repeats on retries |
payload | The receipt, or null over 1 MiB | If null, fetch error.result_url |
Verify first, then branch
Each delivery has x-sume-webhook-timestamp, x-sume-webhook-signature (sume-v1=<hex>) and a secret fingerprint header. The signature is HMAC-SHA256 over <timestamp>.<raw_body>. Check it against the raw bytes before you parse, and reject timestamps outside five minutes. The function below refuses an empty secret, so a missing environment variable fails loudly and not silently.
import hashlib, hmac, json, time
def verify(raw: bytes, headers: dict, secret: str) -> dict:
if not secret:
raise ValueError("signing secret is empty")
ts = headers["x-sume-webhook-timestamp"]
if abs(time.time() - int(ts)) > 300:
raise ValueError("timestamp outside five minute window")
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest("sume-v1=" + mac, headers["x-sume-webhook-signature"]):
raise ValueError("bad signature")
return json.loads(raw)
def route(event: dict) -> str:
if event["outcome"] == "ok":
return "use output"
if event["outcome"] == "degraded":
return "use artifacts, output is null"
return "failed: " + str((event.get("error") or {}).get("code"))
Delivery details that affect your handler
Answer with a 2xx within 10 seconds, after you have stored the event; do the work afterward. Sume retries up to 10 times, with a backoff of 30 s x 2^(attempt-1) and jitter, and a maximum of one hour. A 3xx is a failed attempt because Sume does not follow redirects.
A canceled or skipped run never delivers. A failed delivery does not change the run: after ten refused attempts you have an exhausted delivery and a run that is still completed, so fetch it from result_url. After you repair your receiver, POST /v1/format-runs/{run_id}/webhook/redeliver replays the current receipt without using one of the ten attempts.
Testing the handler
You can test a receiver without waiting for a new run. After a run is terminal, call POST /v1/format-runs/{run_id}/webhook/redeliver with formats:write and an empty body. It re-POSTs the current receipt with a new timestamp and signature and does not use one of the ten automatic attempts. It returns 409 webhook_not_configured if the run had no URL and 409 run_not_terminal while the run is still going.
Check the webhook_delivery block on the receipt to see what happened: status is one of not_armed, pending, retrying, delivered, failed or exhausted, with attempts, last_status_code and last_error. Store the event before you answer, and dedupe on request_id, since each retry repeats it. The signing secret is on the dashboard's Webhooks tab and at GET /v1/webhooks/signing-secret for a key with account:read.
One more rule for the handler: do not branch on status alone. status is OK for a completed run, so a degraded run also reads OK, and only outcome separates a run with usable output from a run with media and a null output. Log the outcome with the run id so that you can count how often your schema fails to match.
Sources
Related posts
More in Formats
- Restyle last year's holiday clip with the Sume restyle Format
Reuse a holiday video you own with a new look. The Sume restyle Format keeps motion and cuts and changes the style; it does not swap a person or product.
- Same idempotency key, new body: Sume Format run 409 vs 200 replay
Same Idempotency-Key and body on a Sume Format run gives 200 with idempotency_hit true. A changed body or attachments gives 409. Bulk replays stay 202.
- Before-after video for cleaning and organizing products, Q4
The Sume before-after Format builds a transformation video with matched framing and a clear reveal. Brief it with a real before photo and call it by API.
- Sume Format output_error codes: which to retry and which to fix
output_extraction_failed is transient: re-read the run, then retry with a new key. output_schema_unsatisfied means the schema or recipe is wrong. Six codes.
Written by Sume