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.

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-fingerprintwith 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
- A weekly scheduled agent run for podcast clips: cron, cap and trigger
Set a Sume Scheduled Action to run every week, with a cron schedule, an IANA time zone and a spend cap that a manual or API run can lower but never raise.
- Run the Sume video agent from your backend with Agent Completions
POST /v1/agent/completions runs the same agent as the Sume Agents chat, with tools and media generation, and returns an async run receipt you poll or webhook.
- Safe automation for AI agents that call paid APIs
Keep agents read-only by default, keep secrets out of logs, and on hosted MCP send an idempotency_key, preview with dry_run, and cap with max_spend_usd.
- Scheduled AI video agent runs: cron, API triggers, and receipts
A Sume schedule is a saved Agents automation that runs on a cron cadence and returns a run receipt. Author it in the dashboard; start and monitor runs by API.
Written by Sume