Verify a Sume run webhook in Python: replay window and empty secret

A Python verifier for the sume-v1 signature on a Sume run webhook: raw body, five-minute replay window, constant-time compare, and no empty secrets.

5 min readSume
All posts

Verify a Sume run webhook by computing HMAC-SHA256 over <timestamp>.<raw_body> with your signing secret and comparing it to the sume-v1= value in x-sume-webhook-signature, after rejecting a timestamp outside a five-minute window. Refuse to verify at all if the secret is empty.

The scheme is documented on Run webhooks; the TypeScript SDK ships verifyWebhook, so this Python version is for receivers in other languages.

What does the verifier need?

Three inputs from the request: the raw body bytes before any JSON parsing, the x-sume-webhook-timestamp header, and the x-sume-webhook-signature header. A parsed and re-serialized body will not verify, because key order and whitespace are part of what was signed.

The signing secret comes from the Webhooks tab of the dashboard or GET /v1/webhooks/signing-secret with a key carrying account:read. Store it as SUME_COM_WEBHOOK_SIGNING_SECRET.

The five-minute window is a default, not a constant. Run webhooks document it as a reasonable default and the SDK exposes toleranceSeconds with a default of 300. A tighter window narrows the replay surface but makes clock drift on your host fail legitimate deliveries, so keep your servers on NTP before tightening it. The SDK's own order is worth copying: check the timestamp before computing the HMAC, and use a constant-time comparison.

What is the code?

During the 24 hours after a secret rotation, the signature header holds two comma-separated values, newest first, so the code checks each one.

The empty-secret guard is the part people skip. An unset environment variable reads as an empty string in many setups, and HMAC with an empty key still produces a valid-looking digest. A verifier that accepts it would compare an attacker-computed signature against the same empty-key digest and pass. Raising on an empty secret turns a silent misconfiguration into a loud boot-time error, which is the right trade.

import hashlib, hmac, os, time

def verify(raw: bytes, timestamp: str, header: str,
           secret: str, tolerance: int = 300) -> bool:
    if not secret:
        raise ValueError("signing secret is empty")
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(int(time.time()) - ts) > tolerance:
        return False
    mac = hmac.new(secret.encode(),
                   timestamp.encode() + b"." + raw, hashlib.sha256)
    want = "sume-v1=" + mac.hexdigest()
    return any(hmac.compare_digest(want, part.strip())
               for part in (header or "").split(","))

if __name__ == "__main__":
    secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
    body, ts = b'{"event":"test"}', str(int(time.time()))
    m = hmac.new(secret.encode(), ts.encode() + b"." + body, "sha256")
    print(verify(body, ts, "sume-v1=" + m.hexdigest(), secret))

What happens after it passes?

Return a 2xx quickly, after durably recording the event, then do the work. Delivery times out at 10 seconds per attempt and retries up to 10 attempts, so a slow handler gets the same run delivered again.

Dedupe on the envelope's request_id, which equals the run id and stays the same across retries. Use created_at to order deliveries, since request_id cannot. Branch on outcome (ok, degraded, error) when the question is whether you got usable output.

Redeliver is a good way to test the full path. POST /v1/format-runs/{run_id}/webhook/redeliver re-sends the current terminal receipt for a Format run with a fresh timestamp and signature, signed with the same secret, so your verifier needs no change. The Send test button on the dashboard fires a dummy webhook.test payload instead, which is not a replay of a real run. Either way, your handler should answer unknown event names with a 204 rather than a 500, so a newly added event does not start a retry storm.

What can go wrong?

Most failures are one of a few causes:

  • The body was parsed before verifying, so the bytes changed.
  • The server clock is more than five minutes off, so the window check fails.
  • The secret in your environment differs from the dashboard; compare the fingerprint in x-sume-webhook-secret-fingerprint with the one shown next to the secret.
  • A rotation happened and your receiver compares the header for equality instead of checking each comma-separated value.

Sources

Related posts

More in Agents

All Agents posts

Written by Sume