Verify a Sume webhook in Python with the standard library only

Check the sume-v1 HMAC in Python without a package: raw body, timestamp window, two signatures during rotation, and a refusal of an empty secret. 20 lines.

5 min readSume
All posts

To verify a Sume job webhook in Python, compute HMAC-SHA256 over <timestamp>.<raw_body> with your signing secret, prefix it with sume-v1=, and compare it in constant time with every sume-v1= entry in the x-sume-webhook-signature header. Reject the call if the timestamp is more than 300 seconds from your clock, and refuse to run at all if the secret is empty.

The TypeScript SDK ships verifyWebhook; Python has no Sume package, so the Webhooks page gives the scheme and a TypeScript verifier, and this page ports it. The scheme is identical for job webhooks (job.*) and run webhooks (*.run.terminal), so one function covers both.

What the scheme requires

The values come from the Webhooks page and the Verifying webhooks page, read 2026-10-09.

Sume webhook signature facts, as of 2026-10-09 (Webhooks; Verifying webhooks).
ItemValue
Signed data<timestamp>.<raw_body>, the raw bytes before any JSON parse
AlgorithmHMAC SHA-256, hex digest
Timestamp headerx-sume-webhook-timestamp (seconds)
Signature headerx-sume-webhook-signature: sume-v1=<hex>
During a rotationsume-v1=<new>,sume-v1=<previous>, newest first, for 24 hours
Replay windowReject outside 300 s (five minutes is the suggested default)
SecretDashboard Webhooks tab or GET /v1/webhooks/signing-secret; env name SUME_COM_WEBHOOK_SIGNING_SECRET

The verifier

The function takes bytes, not a parsed object. In Flask use request.get_data(); in FastAPI use await request.body(). Do not re-serialize parsed JSON, because key order and whitespace are part of the signed data.

import hashlib
import hmac
import time


def verify(raw: bytes, timestamp: str, header: str, secret: str, tolerance: int = 300) -> bool:
    if not secret:
        return False  # never verify against an empty secret
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256)
    expected = f"sume-v1={mac.hexdigest()}".encode()
    ok = False
    for entry in (header or "").split(","):
        if hmac.compare_digest(entry.strip().encode(), expected):
            ok = True  # keep looping so timing does not show which entry matched
    return ok


if __name__ == "__main__":
    body, secret, ts = b'{"event":"job.completed"}', "whsec_test", str(int(time.time()))
    sig = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    print(verify(body, ts, f"sume-v1=deadbeef,sume-v1={sig}", secret))  # True
    print(verify(body, ts, f"sume-v1={sig}", ""))  # False

Why each guard is there

The empty-secret guard matters because an unset environment variable becomes an empty string in many setups. An HMAC with an empty key is still a valid HMAC, so without the guard a misconfigured service would accept signatures that anyone can compute.

The loop checks every entry because of the rotation window. For 24 hours after a rotation Sume signs with both secrets, newest first, so a receiver that still holds the old secret passes on the second entry, and a receiver that already holds the new secret passes on the first. A verifier that compares the whole header for equality fails during that window.

Return a fast 2xx after you have stored the event, and use job_id as the dedupe key. Return 401 on a failed check. Unknown event names should get a 204 and not a 500, so new event types do not start a retry storm.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume