A Sume webhook receiver in plain Python WSGI, no framework
A 28-line wsgiref app that reads the raw body, verifies sume-v1 with a rotation-safe check, refuses an empty secret, and answers 204. Tested with curl.

A WSGI app can verify a Sume webhook with the standard library alone: read CONTENT_LENGTH bytes from wsgi.input, build the HMAC-SHA256 of the timestamp, a dot and those raw bytes, and compare it with every sume-v1 entry in the signature header. It must refuse to start with an empty secret, because an HMAC with an empty key can be forged by anyone. The sample answers 204 on success and 401 on a bad signature.
Headers and signature
The signed text is the timestamp, a dot, and the raw body. The replay window is 300 seconds. During a rotation the header holds sume-v1=<new>,sume-v1=<old> for 24 hours, and a receiver must accept a match with either entry.
| Header | Value |
|---|---|
| x-sume-webhook-timestamp | Unix seconds, part of the signed text |
| x-sume-webhook-signature | sume-v1=<hex>, or two entries during a rotation |
| x-sume-webhook-secret-fingerprint | Names the current secret, safe to log |
The app
WSGI exposes request headers as HTTP_ keys in upper case, so x-sume-webhook-timestamp becomes HTTP_X_SUME_WEBHOOK_TIMESTAMP. The verify function checks every entry with hmac.compare_digest and collects the results in a list, so the loop does not stop at the first match.
import hashlib, hmac, json, os, time
from wsgiref.simple_server import make_server
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
raise SystemExit("SUME_COM_WEBHOOK_SIGNING_SECRET is empty")
def verify(raw, timestamp, header, secret=SECRET, tolerance=300):
if not secret or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
return False
digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
sigs = [p.strip()[len("sume-v1="):] for p in header.split(",") if p.strip().startswith("sume-v1=")]
return any([hmac.compare_digest(digest, s) for s in sigs]) # check every entry
def app(environ, start_response):
raw = environ["wsgi.input"].read(int(environ.get("CONTENT_LENGTH") or 0))
ok = verify(raw, environ.get("HTTP_X_SUME_WEBHOOK_TIMESTAMP", ""),
environ.get("HTTP_X_SUME_WEBHOOK_SIGNATURE", ""))
if environ["REQUEST_METHOD"] != "POST" or not ok:
start_response("401 Unauthorized", [("Content-Type", "text/plain")])
return [b"bad signature"]
event = json.loads(raw)
print(event.get("event"), event.get("job_id") or event.get("request_id"))
start_response("204 No Content", [])
return [b""]
if __name__ == "__main__":
make_server("0.0.0.0", 8080, app).serve_forever()Test it with curl
The built-in wsgiref server is for tests. Put the same app function behind a production WSGI server for real traffic.
- Export a 64-character test secret, start the app, then sign a body with openssl dgst -sha256 -hmac over the text timestamp.body.
- A good signature returns 204. A header of sume-v1=bad,sume-v1=<good> also returns 204, which proves the rotation path.
- A wrong signature returns 401, and an empty or missing secret stops the process at start-up.
- Dedupe on job_id or request_id before you act, because a retry carries the same ids.
Why the raw body comes first
The signature covers the exact bytes Sume sent. If a framework parses the JSON and you serialize it again, key order and whitespace can change and the check fails even though the delivery is genuine. WSGI hands you the stream untouched, which is why this plain app is a good reference when a framework's behavior is unclear. Read the body once, verify it, and only then parse it with json.loads.
The same function works under any WSGI server, and the verify() helper can be imported into a test file and called with fixtures.
Sources
Related posts
More in Developers
- Sume webhook retries: 10 attempts, 30 s apart, a 4.5 minute window
Sume retries a job webhook up to 10 times, 30 s apart by default. That is 270 s between first and last attempt, 370 s at worst. What to do after.
- Sume webhook retries: 10 attempts, 30 s apart, 10 s timeout each
The delivery schedule for Sume job webhooks: 10 attempts, fixed 30 s spacing, 10 s timeout, about 4.5 minutes of retries, then redeliver and the status poll.
- Clock changes vs clock drift: what breaks Sume webhook checks
Sume webhook timestamps are Unix seconds, so time zone changes cannot break the 5-minute replay check; a drifting server clock can. A check to tell them apart.
- Sume webhook status is OK or ERROR; job status is completed or failed
A Sume job webhook body says status OK or ERROR, while the job endpoints say completed, failed or canceled. Branch on the event name and map both vocabularies.
Written by Sume