Does your Format webhook receiver pass OWASP's checklist?

Check a Sume Format webhook receiver against OWASP's webhook cheat sheet: raw body, HMAC, five-minute window, dedupe on request_id, and where docs are silent.

5 min readSume
All posts

A Sume Format webhook receiver passes most of OWASP's checklist if it does five things: verify an HMAC-SHA256 signature over the raw body, reject timestamps more than five minutes off, compare in constant time, dedupe on request_id, and answer 2xx before it does the work. Two items are on you or are not documented: keeping event ids long enough, and rotating the signing secret.

The security baseline comes from the Webhook Security Cheat Sheet (read 2026-10-10). Sume's side comes from Runs and results and the Cookbook.

Line by line

The table pairs each OWASP recommendation with what the Sume docs say for a format.run.terminal delivery. Where the docs I read are silent, the row says so instead of guessing.

OWASP webhook recommendations against Sume Format webhooks (OWASP page read 2026-10-10)
OWASP recommendationSume Format webhookYour action
Sign with HMAC-SHA256Signature is HMAC-SHA256 over <timestamp>.<raw_body>, sent as sume-v1=<hex>Verify before parsing
Use the raw request bodySume signs the raw bytes; a parsed and re-serialized body will not matchRead bytes first
Constant-time compareNot stated by Sume; the Python cookbook receiver uses hmac.compare_digestNever use ==
Reject old timestamps (5 minutes is Stripe's library default per OWASP)Docs: reject timestamps outside a five-minute windowSame window
Cache event ids for at least twice the tolerancerequest_id is stable across retries and is the dedupe keyKeep ids far longer than 10 minutes
Rotate with two signaturesThe pages I read describe a secret fingerprint header, not dual signingCheck the fingerprint on every call
Outbound: HTTPS only, no private addressesSume accepts public HTTPS only and re-validates the URL at deliveryRegister your final URL

Where Sume goes beyond the checklist

Each delivery carries x-sume-webhook-secret-fingerprint, twelve hex characters. Compare it with the fingerprint shown next to your secret and you learn immediately that both sides hold the same secret, which is the most common cause of a signature that never matches. Sume does not follow redirects, treats a 3xx as a failed attempt, and waits up to ten seconds per attempt.

Deliveries also have a dashboard test: POST /v1/webhooks/test-deliveries sends a webhook.test payload, and POST /v1/format-runs/{run_id}/webhook/redeliver replays a real terminal event. Neither uses up the ten automatic attempts.

Where you must supply the control

OWASP says to keep event ids for at least twice the timestamp tolerance, which is ten minutes for a five-minute window. A Sume delivery can be retried up to ten times, with exponential backoff capped at one hour, so a duplicate can arrive hours after the first. Keep request_id for at least a day, and make the insert-or-ignore atomic.

Return 2xx for a duplicate you have already queued, as OWASP advises, and do not repeat its side effects. One more Sume-specific trap: when a receipt is over 1 MiB the envelope arrives with payload: null and an error.result_url, so a handler that always reads payload as an object will fail on your largest runs.

A verifier that refuses an empty secret

This stdlib function follows the documented scheme. It raises on an empty secret instead of quietly accepting every signature, and the demo at the bottom shows a valid body passing and a one-byte change failing.

import hashlib, hmac, os, time

def verify(raw: bytes, ts: str, sig: str, secret: str, now: float | None = None) -> bool:
    if not secret:
        raise ValueError("empty signing secret")
    if not ts.isdigit() or abs((now or time.time()) - int(ts)) > 300:
        return False
    digest = hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sume-v1=" + digest, sig)

secret = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET") or "demo-secret"
raw = b'{"event":"format.run.terminal"}'
ts = str(int(time.time()))
good = "sume-v1=" + hmac.new(secret.encode(), ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
print(verify(raw, ts, good, secret), verify(raw + b" ", ts, good, secret))
try:
    verify(raw, ts, good, "")
except ValueError as err:
    print("refused:", err)

Sources

Related posts

More in Formats

All Formats posts

Written by Sume