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.

5 min readSume
All posts

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.

Webhook fields, from the Runs and results docs, as of 2026-10-09
FieldValuesUse
eventformat.run.terminalRoute on it
statusOK or ERRORCoarse result
outcomeok, degraded, errorDid I get usable output
run_id, request_idEqual, stable across retriesDedupe key
created_atTime Sume built this bodyOrder deliveries; request_id repeats on retries
payloadThe receipt, or null over 1 MiBIf 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

All Formats posts

Written by Sume