Sign and verify a Sume webhook offline: a Python test vector

Build a sume-v1 test signature with Python's hmac and check your verifier against it, including an empty secret, an old timestamp and a rotation header.

5 min readSume
All posts

You can test a Sume webhook verifier without any network: sign a body yourself with HMAC-SHA256 over <timestamp>.<raw_body>, prefix the hex digest with sume-v1=, and feed the result to the verifier. The verifier below refuses an empty secret, rejects timestamps outside the 300-second window, and accepts a header with several comma-separated entries.

import hashlib
import hmac
import json
import time


def verify(secret, timestamp, signature_header, raw_body, tolerance=300, now=None):
    if not secret:
        raise ValueError("signing secret is empty; refusing to verify")
    now = time.time() if now is None else now
    try:
        if abs(now - int(timestamp)) > tolerance:
            return False
    except (TypeError, ValueError):
        return False
    signed = timestamp.encode() + b"." + raw_body
    digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    expected = "sume-v1=" + digest
    entries = [e.strip() for e in (signature_header or "").split(",")]
    return any(hmac.compare_digest(e, expected) for e in entries)


def route(raw_body):
    event = json.loads(raw_body).get("event", "")
    if event.startswith("job."):
        return 200, "job event"
    if event.endswith(".run.terminal"):
        return 200, "run event"
    return 204, "ignored"

A test vector

To use it, build a signed case from the same secret. These calls are the test vector:

import hashlib, hmac, time
secret, body, ts = "whsec_test", b'{"event":"job.completed"}', str(int(time.time()))
sig = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
print(verify(secret, ts, sig, body))
print(verify(secret, ts, "sume-v1=00," + sig, body))
print(verify(secret, str(int(ts) - 400), sig, body))
print(route(body))
try:
    verify("", ts, sig, body)
except ValueError as e:
    print(e)

What it prints

Append that snippet to the file and run it. It prints True, True, False, then (200, 'job event'), then the empty-secret message. The second line is a rotation header: during a secret rotation the header carries one entry per live secret, and the check passes when any entry matches.

Rules the code follows

The table lists the rules from the webhooks page.

Verifier rules (read 2026-10-07)
RuleValue
Signed string<timestamp>.<raw_body>
Header formatx-sume-webhook-signature: sume-v1=<hex>
Replay window300 seconds by default
RotationSeveral sume-v1= entries, comma-separated; accept any match
DeliveryUp to 10 attempts, 30 s apart, 10 s timeout each

Receiver habits

Verify the raw bytes, not a parsed and re-serialized body, and compare with hmac.compare_digest. Return a 2xx fast, then do the slow work after you reply, since each attempt has a 10-second timeout. Use job_id as the idempotency key for your handler, because retries and redeliveries repeat the same job.

Unknown events

The route function answers 204 for event names it does not know, so a new event type on Sume's side does not turn into a retry storm.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume