Sume job webhooks: failed and canceled both say ERROR, branch on event
job.failed and job.canceled webhook payloads both use status ERROR with an error object. Dispatch on the event name, not status, in Python.

A Sume job webhook has three event names: job.completed, job.failed and job.canceled. The status field in the payload has only two values. A completed job sends status: "OK". The docs state that failed and canceled webhooks use status: "ERROR" and include an error object. A handler that checks status == "ERROR" therefore cannot tell a failure from a cancel.
Why it matters
A failure may need an alert, a refund check or a retry with changed input. A cancel is something you asked for, and it normally needs none of those. If you route both to your pager, every user-initiated cancel wakes someone. Branch on event, which is exact.
The three payloads
| event | status | Other fields |
|---|---|---|
| job.completed | OK | payload.artifacts with id, url, type and content_type |
| job.failed | ERROR | error object |
| job.canceled | ERROR | error object |
Dispatcher
The dispatcher takes a verified body. It looks at event first, ignores unknown events instead of failing (new ones may be added), and keys all work on job_id, as the docs advise for idempotency on your side.
import json
def handle(raw: bytes, done: set) -> str:
body = json.loads(raw)
event = body.get("event")
if not str(event).startswith("job."):
return f"ignored {event}"
job_id = body["job_id"]
if job_id in done:
return "duplicate"
done.add(job_id)
if event == "job.completed":
urls = [a["url"] for a in body.get("payload", {}).get("artifacts", [])]
return f"completed {job_id}: {len(urls)} artifact(s)"
if event == "job.failed":
return f"failed {job_id}: {body.get('error', {}).get('code', 'unknown')}"
if event == "job.canceled":
return f"canceled {job_id}"
return f"ignored {event}"
seen = set()
print(handle(b'{"event":"job.canceled","job_id":"job_1","status":"ERROR","error":{}}', seen)) # canceled job_1Duplicate handling
Delivery is at-least-once: a failed attempt is retried up to 10 times. In production, write the job_id and the side effects in one database transaction with a unique constraint, instead of the in-memory set used here.
Keep a poll fallback
A webhook is an optimization. After ten refused attempts, the delivery is marked failed even though the job still reached its real terminal state. Keep polling the status_url for jobs whose event never arrived, and use POST /v1/jobs/{job_id}/webhook/redeliver to send the terminal event again with a fresh timestamp and signature. That route re-sends whichever of the three events matches the job, so the same dispatcher handles it.
Test all three branches with a signed webhook.test delivery from the dashboard first. That payload has no job_id, so it will not reach the job branches, which is the right behavior for a test.
Sources
Related posts
More in Developers
- Sume webhook 300-second tolerance: verify on receipt, not later
A queued Sume webhook fails the 300-second timestamp check if verified late. Verify at the edge, then queue the body. Python with a testable clock.
- Patching Supabase Postgres 17.11 vs Sume's 10-attempt webhook budget
Supabase's September 25 Postgres 15.19 and 17.11 releases fix 44 CVEs. A restart can outlast Sume's ten 30-second webhook attempts, so plan a redeliver.
- Supabase cached egress is $0.03/GB: cost of serving a 20 MB AI clip
Supabase lists cached Storage egress at $0.03 per GB. Worked arithmetic for serving generated clips, and when to link a Sume media URL instead of copying.
- Swap in an AI-generated presenter: portrait first, then Recast
No photo of a real person? Generate a presenter portrait with Sume's image API, pass its URL to h3-max-recast, and poll the video job. Python included.
Written by Sume