Sume webhook handler status codes: when to return 401, 2xx or 5xx
Sume retries any non-2xx up to 10 times, 30 seconds apart. Return 2xx only after you store the event. A Python handler that refuses a bad signature.

The status code your webhook endpoint returns is a control signal for Sume. A 2xx means the delivery is finished. Anything else, including a timeout, is retried. The docs give the budget: up to 10 attempts in total, a fixed 30 seconds between them (not exponential backoff), and 10 seconds for each attempt. Pick the code that says what you mean.
Which status for which situation
| Situation | Return | Sume then |
|---|---|---|
| Event stored durably | 200 | Stops retrying |
| Signature or timestamp invalid | 401 | Retries; it will fail again until the cause is fixed |
| Your store is down | 503 | Retries in 30 seconds |
| Handler slower than 10 seconds | (timeout) | Counts as a failed attempt and retries |
The retry window
Ten attempts with 30 seconds between them cover about 270 seconds of spacing, which is a bit over four and a half minutes, plus up to 10 seconds of timeout for each attempt. So a database outage shorter than about four minutes recovers by itself if you return 503. A longer outage leaves a failed delivery, but the job still reached its terminal state, so poll its status_url or call POST /v1/jobs/{job_id}/webhook/redeliver once you are back.
Handler
The handler below verifies the signature over <timestamp>.<raw_body> with a 300-second tolerance, refuses an empty secret, and stores the event before it answers. It uses job_id as the key on your side, as the docs advise.
import hashlib, hmac, json, time
from http.server import BaseHTTPRequestHandler
SECRET = "" # load SUME_COM_WEBHOOK_SIGNING_SECRET here
SEEN = {}
def verify(raw, ts, header, secret, tolerance=300):
if not secret:
raise RuntimeError("signing secret is empty")
if abs(time.time() - int(ts)) > tolerance:
return False
digest = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(e.strip(), f"sume-v1={digest}") for e in header.split(","))
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
ts = self.headers.get("x-sume-webhook-timestamp", "0")
if not verify(raw, ts, self.headers.get("x-sume-webhook-signature", ""), SECRET):
return self.send_response(401) or self.end_headers()
body = json.loads(raw) # a webhook.test body has no job_id
SEEN.setdefault(body.get("job_id") or body["request_id"], raw) # store, then answer
self.send_response(200)
self.end_headers()Do not answer early
Returning 200 before the write succeeds loses the event, because Sume believes the delivery is done. Store, then answer. If the work after storing is slow, queue it and return, since the 10-second limit counts against the attempt.
Sources
Related posts
More in Developers
- 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.
- 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.
Written by Sume