Python verifier for Sume webhooks: stale timestamps, empty secret

A short Python function that verifies a Sume webhook: five-minute timestamp window, constant-time HMAC compare and a hard refusal of an empty secret.

5 min readSume
All posts

Sume signs each webhook with HMAC-SHA256 over <timestamp>.<raw_body> and sends it as sume-v1=<hex> in x-sume-webhook-signature, with the Unix time in x-sume-webhook-timestamp. A safe verifier does four things: refuses to start with an empty secret, rejects a timestamp more than five minutes from now, hashes the raw bytes (never re-serialised JSON), and compares in constant time. Below is a complete function that runs as written, with a self-test.

The scheme comes from Runs and results and Run webhooks; the cookbook has the FastAPI route that wraps it.

The verifier

Put the check in a function that takes the raw bytes and two header values. The web framework is not involved, so it is easy to unit test.

import hashlib
import hmac
import os
import time

TOLERANCE_SECONDS = 300


def verify(secret: bytes, raw: bytes, timestamp: str | None, signature: str | None) -> bool:
    if not secret:
        raise ValueError("empty webhook signing secret")
    if not timestamp or not signature:
        return False
    try:
        ts = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    digest = hmac.new(secret, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sume-v1={digest}", signature)


if __name__ == "__main__":
    secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "test-secret").encode()
    body = b'{"event":"format.run.terminal"}'
    now = str(int(time.time()))
    good = "sume-v1=" + hmac.new(secret, f"{now}.".encode() + body, hashlib.sha256).hexdigest()
    print(verify(secret, body, now, good))
    print(verify(secret, body, str(int(time.time()) - 900), good))

What each line protects against

The table maps each check to the failure it prevents. None of them is optional.

Checks and why
CheckFailure it prevents
Empty secret raisesAn unset env var that makes every signature computable by anyone
Timestamp windowReplay of an old captured delivery
Raw bytesA parser that reorders keys and breaks the signature
compare_digestTiming leaks when comparing signatures

Around the function

Read the secret once at start-up and fail fast if it is missing. Return 401 on a failed check, 2xx within 10 seconds on success, and dedupe on request_id since retries repeat it. A run can send up to 10 attempts, so your handler must tolerate repeats.

  • Use the dashboard's Webhooks tab or GET /v1/webhooks/signing-secret to read the secret.
  • Compare the fingerprint header with the one on the dashboard.
  • Test with POST /v1/webhooks/test-deliveries before a real run.

Redelivery for tests

After you fix a receiver, POST /v1/format-runs/{run_id}/webhook/redeliver re-sends the current receipt with a fresh timestamp and signature, without using one of the automatic ten attempts. That makes your tolerance check easy to exercise on a real payload.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume