Python webhook receiver: read artifacts[].url and error.next_action
A standard-library Python receiver for Sume job webhooks: verify, then branch on status OK or ERROR to get the artifact URL or error.retryable and next_action.

Signature checking gets most of the attention in webhook posts, but the part you actually ship is what happens after it passes. A Sume job webhook has two shapes that need different code, and both are keyed on the top-level status field.
For a completed job, event is job.completed, status is OK and payload.artifacts is an array of {id, url, type, content_type}. For a failed or canceled job, status is ERROR, payload is null and an error object takes its place. The request_id and job_id fields hold the same job id.
The two shapes
| Event | status | payload | error |
|---|---|---|---|
| job.completed | OK | artifacts[] with id, url, type, content_type | absent |
| job.failed | ERROR | null | object with code, message, retryable, next_action |
| job.canceled | ERROR | null | object |
A receiver that handles both
This standard-library receiver verifies the signature over the raw bytes first, including the 300 second window, and returns 401 for anything that fails. Only then does it parse the JSON and call handle. For OK it prints the first artifact URL. For ERROR it prints the error code, whether it is retryable and the next_action hint, which is the field to branch on in real code.
import hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ.get("SUME_WEBHOOK_SECRET", "")
if not SECRET:
raise SystemExit("SUME_WEBHOOK_SECRET is required")
def valid(raw: bytes, h) -> bool:
ts, sig = h.get("x-sume-webhook-timestamp", ""), h.get("x-sume-webhook-signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256)
return any(hmac.compare_digest(p.strip(), "sume-v1=" + mac.hexdigest()) for p in sig.split(","))
def handle(event: dict) -> str:
if event["status"] == "OK":
return f"{event['job_id']}: download {event['payload']['artifacts'][0]['url']}"
err = event.get("error") or {}
return f"{event['job_id']} {event['event']}: {err.get('code')} retryable={err.get('retryable')} next={err.get('next_action')}"
class Hook(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
ok = valid(raw, {k.lower(): v for k, v in self.headers.items()})
if ok:
print(handle(json.loads(raw)))
self.send_response(200 if ok else 401)
self.end_headers()Run it
Append the self-test. It starts the receiver on a free port, then sends one signed completed event and one signed failed event, and prints the HTTP status each time. Set SUME_WEBHOOK_SECRET to any test value and run the file with Python 3. The two handle lines print before their 200 lines. The media_invalid code in the sample is a placeholder, so use the codes in your own logs.
import threading, urllib.request
server = HTTPServer(("127.0.0.1", 0), Hook)
threading.Thread(target=server.serve_forever, daemon=True).start()
events = [
{"event": "job.completed", "job_id": "job_a", "status": "OK",
"payload": {"artifacts": [{"url": "https://example.com/out.mp4"}]}},
{"event": "job.failed", "job_id": "job_b", "status": "ERROR", "payload": None,
"error": {"code": "media_invalid", "retryable": False, "next_action": "fix_input"}},
]
for e in events:
raw, ts = json.dumps(e).encode(), str(int(time.time()))
sig = hmac.new(SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
req = urllib.request.Request(f"http://127.0.0.1:{server.server_port}/", raw, {
"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": "sume-v1=" + sig})
print(urllib.request.urlopen(req).status)What to do with the result
- Store the event and the artifact URL, return 200, then download in a worker. Do not hold the delivery open while you fetch a video.
- On
ERROR, readerror.retryablebefore you resubmit, and resubmit with the sameIdempotency-Keywhen it is true. - Treat
next_actionas a hint:fix_inputmeans change the request,retry_latermeans wait,contact_supportmeans send the request id. - Deduplicate on
job_idandevent. A redelivery can reach you more than once.
Payload fields, retries and redelivery are in the webhook guide. The meaning of each error field is in the error reference.
Sources
Related posts
More in Developers
- Does a queue_full 429 charge me? Sume's reservation rules
A Sume 429 queue_full means the workspace has no accepted-job capacity left. The failed admission releases its reservation; retry with the same Idempotency-Key.
- IN_QUEUE or queued? Two status fields on a Sume job, do not mix
GET /v1/jobs/{id}/status returns sume_status and a queue-shaped status that map one to one. Which to poll, and how /v1/videos values differ.
- Quota job error vs 402 insufficient_credits: where each appears
A 402 insufficient_credits means the submit was refused; a quota job category means an accepted job later failed. How to tell them apart and what to do next.
- Railway cron job that submits a Sume job and exits (Python)
Railway cron services must exit when done, skip a run while the last one is still going, and start at most every 5 minutes. Submit in webhook mode and exit.
Written by Sume