Check Sume webhook signatures in Python with hmac.compare_digest

A Python verifier for the sume-v1 header: raw bytes, constant-time compare, 300 s replay window, empty-secret refusal, and a Flask route that dedupes on job_id.

3 min readSume
All posts

In Python, verify a Sume webhook by hashing timestamp + b'.' + raw_body with HMAC SHA-256, prefixing the hex digest with sume-v1=, and comparing it with hmac.compare_digest against each comma-separated entry of x-sume-webhook-signature. Use the raw request bytes, never json.dumps(request.json), because key order and whitespace are part of what was signed.

The function below also refuses an empty secret. With an unset environment variable, hmac.new(b'', ...) still runs, and an attacker who knows the scheme could forge a matching signature, so failing loudly is the safer default.

verify.py

Encode both sides to bytes before comparing; compare_digest rejects non-ASCII str values.

import hashlib, hmac, time

def verify_sume(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
    if not secret:
        raise ValueError("webhook secret is empty; refusing to verify")
    try:
        ts = int(headers["x-sume-webhook-timestamp"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > tolerance:
        return False
    signed = str(ts).encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    want = ("sume-v1=" + digest).encode()
    ok = False
    for part in headers.get("x-sume-webhook-signature", "").split(","):
        if hmac.compare_digest(part.strip().encode(), want):
            ok = True
    return ok

A Flask receiver with dedupe

Sume retries on any non-2xx, so the same event can arrive more than once. A primary key on job_id makes the second delivery a no-op.

import os, sqlite3
from flask import Flask, request
from verify import verify_sume

SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
if not SECRET:
    raise SystemExit("set SUME_COM_WEBHOOK_SIGNING_SECRET")
db = sqlite3.connect("hooks.db", check_same_thread=False)
db.execute("create table if not exists seen (job_id text primary key, body text)")
app = Flask(__name__)

@app.post("/sume")
def hook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_sume(raw, request.headers, SECRET):
        return "bad signature", 401
    event = request.get_json(force=True)
    db.execute("insert or ignore into seen values (?, ?)",
               (event.get("job_id") or event["request_id"], raw.decode()))
    db.commit()
    return "", 204

Test it before you trust it

Sign a body with a throwaway secret, send it with a current timestamp, and expect True. Then change one byte of the body, then the timestamp by ten minutes, and expect False both times. Finally call it with secret="" and expect the ValueError.

Raw bytes in other frameworks

Flask's request.get_data() returns the body untouched. In FastAPI, read await request.body() before calling request.json(), and in Django use request.body. In each case the rule is the same: take the bytes first, verify, and only then parse. A framework that decodes and re-encodes the body has already destroyed the exact bytes that Sume signed.

Failure cases to test

Verifier test matrix
CaseExpected
Correct signature, fresh timestampTrue
One byte of the body changedFalse
Timestamp 10 minutes oldFalse
Header holds old and new entriesTrue when either matches
Empty secretValueError

Delivery limits to design around

Sume sends up to 10 attempts in total, with a fixed delay between them (30 seconds by default), and each attempt has a 10-second timeout. Verify, store the event, return a 2xx, and do slow work afterwards. If a signature fails to verify, compare the x-sume-webhook-secret-fingerprint header with the fingerprint shown next to your secret in the dashboard rather than pasting the secret anywhere.

During a secret rotation the signature header carries one sume-v1= entry per live secret, newest first, separated by commas. The verifier above already accepts a delivery when any entry matches, so rotating does not break it.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume