Verify a Sume Format webhook in Python: empty secret, stale timestamp

A Python check for the Sume format.run.terminal webhook: HMAC-SHA256 over timestamp.raw_body, a 5-minute window, and a refusal to run with an empty secret.

5 min readSume
All posts

Verify a Sume Format webhook by computing HMAC-SHA256 over <timestamp>.<raw_body> with your signing secret and comparing it, in constant time, to the sume-v1= value in x-sume-webhook-signature. Reject a timestamp more than five minutes off, and refuse to start at all if the secret is empty, since an empty key makes every forgery valid.

What arrives

A run with communication.webhook_url gets one signed format.run.terminal POST when it completes or fails. Two headers matter.

Webhook headers and rules, as of 2026-10-08 (docs.sume.com/formats/cookbook)
ItemValue
x-sume-webhook-timestampUnix seconds, as a string
x-sume-webhook-signaturesume-v1= followed by the hex digest
Signed string<timestamp>.<raw_body>
AlgorithmHMAC-SHA256
Tolerance300 seconds

The check

Sign the raw bytes, not a re-serialized JSON object. A framework that parses first and dumps again changes whitespace and key order, and the digest no longer matches.

import hashlib, hmac, os, time

SECRET = os.environ.get("SUME_WEBHOOK_SECRET", "")
if not SECRET:
    raise SystemExit("SUME_WEBHOOK_SECRET is empty: refusing to verify")
TOLERANCE_SECONDS = 300

def verify(raw: bytes, timestamp: str | None, signature: str | None) -> bool:
    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
    signed = timestamp.encode() + b"." + raw
    digest = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sume-v1=" + digest, signature)

if __name__ == "__main__":
    body = b'{"type":"format.run.terminal"}'
    now = str(int(time.time()))
    good = "sume-v1=" + hmac.new(SECRET.encode(), now.encode() + b"." + body, hashlib.sha256).hexdigest()
    print(verify(body, now, good), verify(body, now, "sume-v1=00"))

Why these checks

A webhook endpoint is a public URL, so anyone can post to it. The signature proves the body came from Sume and the timestamp proves it is recent. Without both, a forged or replayed format.run.terminal could mark a job done in your system when it never ran.

Failure modes

Each guard closes a specific hole.

  • Empty secret: the process exits at start instead of accepting any signature.
  • Missing or non-numeric timestamp: returns false before any hashing.
  • Stale timestamp: a captured request cannot be replayed after five minutes.
  • compare_digest: the comparison takes the same time whether the first byte or the last differs.

Around the check

Return a 401 when verify is false and a 2xx quickly when it is true, then do the work off the request. Keep the run's result_url as a backup, since the webhook is a push and a poll recovers a missed one. Delivery attempts are capped (the receipt shows max_attempts of 10), and the receipt shows webhook_delivery with max_attempts and status, so a receiver that was down can be seen from the run itself.

The queue object of a bulk run has no webhook. Register communication.webhook_url on each child item instead.

Testing the receiver

Test the function before you point a real run at it. The main block in the sample signs a body with the current timestamp and checks that a correct signature passes and a wrong one fails. Add two more cases in your own tests: a timestamp 301 seconds old, which must fail even with a correct digest, and the same body with one byte changed, which must fail too.

Use the raw request body in your web framework. In most frameworks that means reading bytes before any JSON parser touches the request. If your framework gives you only parsed JSON, add a raw-body hook rather than re-serializing.

Secrets and rotation

Store the signing secret in your secret manager and load it into the environment at deploy time. Never commit it. When you rotate the secret, update the receiver first and then the sender, and watch for 401 responses during the switch. The refuse-at-start check helps here: a deploy that lost the variable fails loudly instead of accepting everything.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume