Python verifier for Sume webhooks: rotation header and empty secrets

A Python function that checks the sume-v1 HMAC over timestamp.raw_body, accepts either signature during rotation, and refuses an empty secret. Under 30 lines.

5 min readSume
All posts

Sume signs each webhook with HMAC-SHA256 over <timestamp>.<raw_body> and sends it as x-sume-webhook-signature: sume-v1=<hex> with x-sume-webhook-timestamp. During a secret rotation the header has one entry per live secret, newest first, separated by commas, so a verifier must accept the delivery if any sume-v1= entry matches.

Two Python details cause most failures: hashing a re-serialized JSON body instead of the raw bytes, and passing an empty secret, which makes an HMAC that anyone can compute. The function below refuses an empty secret and compares every entry in constant time.

import hashlib, hmac, time

def verify_sume_webhook(raw_body: bytes, timestamp: str, header: str,
                        secret: str, tolerance: int = 300) -> bool:
    if not secret:
        raise ValueError("webhook signing secret is empty")
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    digest = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body,
                      hashlib.sha256).hexdigest()
    expected = f"sume-v1={digest}"
    matched = False
    for entry in header.split(","):
        entry = entry.strip()
        if entry.startswith("sume-v1=") and hmac.compare_digest(entry, expected):
            matched = True
    return matched

Where the inputs come from

Read the signing secret on the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with a key that has account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET, the name the delivery worker uses. The secret is derived for your workspace, so a valid signature proves Sume signed the delivery for you.

Job webhooks and run webhooks share one secret and one scheme, so this function covers both. Pass the body exactly as received. In Flask use request.get_data(), in FastAPI use await request.body(), before any JSON parsing.

Headers on a delivery, as of 2026-10-09 (docs.sume.com/workflows/webhooks)
HeaderUse
x-sume-webhook-timestampSeconds since epoch; reject outside your window (300 s is a good default)
x-sume-webhook-signaturesume-v1=<hex>, comma-separated during rotation, newest first
x-sume-webhook-secret-fingerprintNames the new secret; compare with the dashboard when a check fails

Rotation and replay

For 24 hours after a rotation Sume signs with both the new and previous secret. Upgrade the receiver before you rotate: a verifier that compares the header for equality fails on every delivery in the window. The fingerprint header names the new secret from the moment you rotate, which tells you what to move to, not which secrets are still accepted.

After verifying, return a fast 2xx and dedupe. Job webhooks use job_id as the key and run webhooks use request_id. Sume retries non-2xx and network errors, up to 10 attempts.

  • Never log the secret. The fingerprint is the only safe value to paste into a ticket.
  • Do not follow up with a redirect: Sume does not follow them on run webhooks, and a 3xx is a failed attempt.
  • A slow handler burns the 10-second attempt budget. Record the event, return, then process.

Testing the verifier

Write three tests. A body signed with the secret verifies. The same body with one changed byte fails. A header with two entries, sume-v1=<new>,sume-v1=<old>, verifies when either matches your secret. Add a fourth test that an empty secret raises, so a missing environment variable fails the deploy and does not silently accept every request.

Use the Send test action on /dashboard/webhooks to send a signed webhook.test payload to your endpoint. It is not a replay of a real job. For a real replay use Redeliver on the delivery row, or POST /v1/jobs/{job_id}/webhook/redeliver with jobs:write.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume