Stdlib Python Sume webhook receiver: http.server, 204 for webhook.test
A 29-line http.server receiver that refuses an empty secret, checks sume-v1 in a 300-second window, takes the two-signature header, and 204s webhook.test.

A Sume webhook receiver needs five things, and the Python standard library supplies all of them: read the raw body, check the sume-v1 HMAC with a constant-time compare, reject a timestamp more than five minutes old, accept any one entry of a two-signature header, and answer a 2xx. The 29-line server below does it with http.server, refuses to start without a secret, and answers 204 to webhook.test, which carries no job_id.
It is a teaching sample, not a production server. Put real traffic behind HTTPS, because Sume accepts only public HTTPS webhook URLs.
What the receiver relies on
| Fact | Value |
|---|---|
| Signed string | <timestamp>.<raw_body> |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature |
| Signature form | sume-v1=<hex>, comma-separated during a rotation |
| Replay window | Reject outside about five minutes |
| Attempts | Up to 10, 30 seconds apart, 10 second timeout each |
| Dedupe key | job_id |
The server
Set SUME_COM_WEBHOOK_SIGNING_SECRET from the Webhooks tab of the dashboard. If it is empty, the program exits before it opens a port. Each request is hashed over the timestamp, a dot, and the raw bytes; any matching entry in the header passes. A bad signature or a stale timestamp gets a 401. A valid event is stored once per job_id, then the reply is 204.
import hashlib, hmac, http.server, json, os, time
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
raise SystemExit("refusing to start without SUME_COM_WEBHOOK_SIGNING_SECRET")
SEEN = set()
class Hook(http.server.BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
ts = self.headers.get("x-sume-webhook-timestamp", "")
sig = self.headers.get("x-sume-webhook-signature", "")
mac = hmac.new(SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
fresh = ts.isdigit() and abs(time.time() - int(ts)) <= 300
if not (fresh and any(hmac.compare_digest(e.strip(), "sume-v1=" + mac) for e in sig.split(","))):
return self.reply(401)
event = json.loads(raw)
job = event.get("job_id") # webhook.test has none
if job and job not in SEEN:
SEEN.add(job)
print("store", event.get("event"), job)
self.reply(204)
def reply(self, code):
self.send_response(code); self.end_headers()
def log_message(self, *args):
pass
http.server.HTTPServer(("127.0.0.1", int(os.environ.get("PORT", "8080"))), Hook).serve_forever()Run against signed requests
Five signed requests went to the server in a test. Two valid job.completed bodies with the same job_id got 204, and the log showed one store line. A webhook.test body got 204 and stored nothing. A wrong secret and a timestamp of 1 each got 401.
The in-memory SEEN set is lost on restart. Use a database unique key on job_id in real code, and return the 2xx only after the write.
Before you go live
- Run the dashboard's Send test against the URL. The
webhook.testevent must get a2xx. - Keep polling the status URL as a backstop; after ten refused attempts the job is still done, but the delivery has failed.
- Use Redeliver to replay a real terminal event, with a fresh timestamp and signature.
Why the body is read first
The signature is computed over the raw bytes that Sume sent. If a framework parses the JSON first and you serialize it again, whitespace and key order can change, and the check fails for a good delivery. Here the handler reads Content-Length bytes from the socket and uses them for both the check and the parse.
The timestamp check uses isdigit so that a missing or odd header becomes a rejection and not an exception. The hmac.compare_digest call compares in constant time, and it is called for each entry, so a rotation with two signatures works.
The reply helper sends no body. A 204 is a success, and it tells Sume to stop retrying.
One more rule: keep the handler fast. Each attempt has a 10-second timeout, so store the event and return, and do any download or processing later in a worker. A slow handler spends the delivery budget and invites a retry that you then have to dedupe.
Sources
Related posts
More in Integrations
- Roo Code alwaysAllow for Sume MCP: which tools to auto-approve
Roo Code alwaysAllow skips the approval click. Auto-approve Sume read tools only, and keep paid ones manual or behind dry_run and a spend cap.
- Roo Code MCP timeout 60 s default: add Sume as streamable-http
Roo Code defaults MCP requests to 60 seconds and allows 1 to 3600. Here is the streamable-http entry for Sume with a timeout that fits jobs_wait.
- Sume MCP idempotency_key: retry a timed-out paid call safely
Reuse the same idempotency_key to retry a paid Sume MCP call that timed out. A new key makes a new job. A reused key with a different payload returns 409.
- Image 1.0 and Video 1.0 are not on Sume's hosted MCP: what to call
images_create and videos_create are REST-only. On hosted MCP the router tools generate_image and generate_video cover stills and clips. Fallback and checks.
Written by Sume