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.

4 min readSume
All posts

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.

Delivery headers from the webhook docs, read 2026-10-08
HeaderValue
x-sume-webhook-timestampUnix seconds, part of the signed text
x-sume-webhook-signaturesume-v1=<hex>, or two entries during a rotation
x-sume-webhook-secret-fingerprintNames 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

All Developers posts

Written by Sume