A Python Sume webhook verifier for the two-signature rotation header

In a 24-hour secret rotation Sume sends two sume-v1 signatures. This 25-line Python verifier accepts either, refuses an empty secret, and prints its own test.

5 min readSume
All posts

A Sume webhook verifier must accept a header with two signatures during a secret rotation. For 24 hours after you rotate, Sume signs with both the new and the previous secret and sends sume-v1=<new>,sume-v1=<old>, so the receiver should accept the delivery if any entry matches. The 25-line Python below does that, refuses an empty secret, and checks the timestamp against a 300-second window.

Equality on the whole header fails on every delivery during the window, which is how a rotation turns into a night of retries.

What the signature covers

These details come from the webhooks and SDK pages, read 2026-10-08.

  • Sume signs the raw JSON body with HMAC SHA-256 over the string <timestamp>.<raw_body>.
  • Headers are x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>.
  • Reject timestamps outside your replay window. Five minutes is the documented default.
  • The same scheme covers job webhooks (job.completed, job.failed, job.canceled) and run webhooks (format.run.terminal), so one verifier serves both.

The verifier

Pass the raw bytes of the request body. A parsed and re-serialized body does not verify because key order and whitespace are part of what was signed. The loop compares every entry with a constant-time comparison and does not stop at the first match, so timing does not reveal which secret matched.

import hashlib, hmac, time

def verify(raw: bytes, ts: str, header: str, secret: str, tolerance=300, now=None):
    if not secret:
        raise ValueError("refusing to verify with an empty secret")
    now = time.time() if now is None else now
    try:
        if abs(now - int(ts)) > tolerance:
            return False
    except ValueError:
        return False
    digest = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    expected = "sume-v1=" + digest
    ok = False
    for entry in header.split(","):
        if hmac.compare_digest(entry.strip(), expected):
            ok = True
    return ok

body, ts = b'{"event":"job.completed"}', "1791460800"
def sign(s):
    return "sume-v1=" + hmac.new(s.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
hdr = sign("new-secret") + "," + sign("old-secret")
for s in ("new-secret", "old-secret", "other"):
    print(s, verify(body, ts, hdr, s, now=1791460810))

Reading the test output

The test signs one body with two secrets and builds the rotation header. Verifying with the new secret prints True, verifying with the old secret prints True, and a third unrelated secret prints False. The now argument is fixed 10 seconds after the timestamp so the replay check passes without a real clock.

In production, read the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name that Sume's delivery worker uses. If that variable is unset, the empty string reaches verify and it raises instead of returning a quiet False that would let a misconfigured server accept nothing without telling anyone why.

Operational notes

Return a 2xx after you store the event durably. Sume retries network errors and non-2xx responses, up to 10 attempts in total, with a fixed delay (30 seconds by default) and a 10-second timeout per attempt. Use job_id as the idempotency key on your side.

Upgrade the receiver before you rotate. Rotating twice inside one window retires the secret from two rotations ago immediately, which is how a leak actually stops. If a signature does not verify, compare x-sume-webhook-secret-fingerprint with the fingerprint in the dashboard; neither side has to send the secret.

Testing against a real delivery

Before you trust the verifier, send it something Sume signed. The Webhooks tab of the dashboard has a Send test control, and POST /v1/webhooks/test-deliveries does the same with an account:write key. It posts a dummy webhook.test payload, signed, to a URL that you type. The body has no job_id, so your handler must accept an event it does not know and answer with a 2xx, not a 500.

Send test never replays a real job. To re-send a real terminal event, use Redeliver on a delivery row, or POST /v1/jobs/{job_id}/webhook/redeliver with a jobs:write key. Redeliver signs with a fresh timestamp, so it passes your replay window even when the original delivery is hours old.

In a web framework, make sure that the raw body reaches verify before any JSON middleware touches it. In Flask that is request.get_data(), and in FastAPI it is await request.body(). Both give bytes, which is what the function above expects.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume