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.

5 min readSume
All posts

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

Webhook delivery facts used here (Sume docs read 2026-10-08)
FactValue
Signed string<timestamp>.<raw_body>
Headersx-sume-webhook-timestamp, x-sume-webhook-signature
Signature formsume-v1=<hex>, comma-separated during a rotation
Replay windowReject outside about five minutes
AttemptsUp to 10, 30 seconds apart, 10 second timeout each
Dedupe keyjob_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.test event must get a 2xx.
  • 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

All Integrations posts

Written by Sume