Sign a test job.completed webhook and replay it at your receiver

Build a Sume-style job.completed delivery in Python: HMAC over timestamp.body, a verifier that refuses an empty secret, plus tamper, stale and rotation checks.

5 min readSume
All posts

You can test a Sume webhook receiver without waiting for a real render by signing a payload yourself. The signature is sume-v1= followed by the hex HMAC-SHA256 of the timestamp, a dot and the raw body, sent with an x-sume-webhook-timestamp header. The script below signs, verifies, and proves that tampering, a stale timestamp and an empty secret are all handled.

What a delivery looks like

Test the unhappy paths first. A receiver that accepts a good signature is easy; the bugs live in the body that changed by one byte, the timestamp from yesterday and the secret that was never configured. The asserts above exist to make each of those a failing test in your own suite.

Delivery headers and fields the test reproduces (read 2026-10-07)
ItemValue
Event namesjob.completed, job.failed, job.canceled
Payload keysevent, request_id, job_id, status (OK or ERROR), payload.artifacts
Timestamp headerx-sume-webhook-timestamp
Signature headerx-sume-webhook-signature: sume-v1=<hex>
Signed string<timestamp>.<raw_body>
Secret rotationHeader may carry comma-separated entries, any match is accepted
Suggested replay window5 minutes

Sign the raw body

Sign the exact bytes you will send, and verify against the raw body, never a re-serialized one. A parser that reorders keys changes the bytes and breaks the signature.

Sign and verify

Run this file as it stands. The payload type video is illustrative: only the field layout comes from the webhook docs. Every assert is a case your production receiver should reject or accept the same way.

The empty-secret check is deliberate. If an environment variable is missing, an HMAC with an empty key still produces a valid-looking signature, and an attacker who knows that can forge deliveries. Raising an error at startup is safer than quietly accepting everything.

import hashlib, hmac, json, time

def sign(secret, body, ts=None):
    ts = str(ts or int(time.time()))
    mac = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
    return {"x-sume-webhook-timestamp": ts, "x-sume-webhook-signature": f"sume-v1={mac}"}

def verify(secret, body, headers, tolerance=300):
    if not secret:
        raise ValueError("refusing to verify with an empty secret")
    ts = headers.get("x-sume-webhook-timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    want = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
    entries = [e.strip() for e in headers.get("x-sume-webhook-signature", "").split(",")]
    return any(hmac.compare_digest(e, f"sume-v1={want}") for e in entries)

body = json.dumps({"event": "job.completed", "request_id": "job_1", "job_id": "job_1", "status": "OK",
                   "payload": {"artifacts": [{"id": "artf_1", "url": "https://media.sume.com/artifacts/x.mp4",
                                              "type": "video", "content_type": "video/mp4"}]}})
good = sign("whsec_test", body)
assert verify("whsec_test", body, good)
assert not verify("whsec_test", body + " ", good)          # body changed
assert not verify("whsec_test", body, sign("whsec_test", body, ts=1))  # stale
rotated = {**good, "x-sume-webhook-signature": "sume-v1=00," + good["x-sume-webhook-signature"]}
assert verify("whsec_test", body, rotated)                  # rotation: any entry matches
try:
    verify("", body, good)
except ValueError:
    print("ok")

Replay with curl

To replay against your running receiver, print the two header values from sign() and send the same body bytes with curl. Set TS, SIG and BODY from that output first.

curl -sS -X POST http://localhost:8000/hooks/sume \
  -H "x-sume-webhook-timestamp: $TS" \
  -H "x-sume-webhook-signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-binary "$BODY"

Before you ship

Dedupe on job_id, since deliveries can repeat. The webhook docs cover the rotation header and the redeliver route, and the receiver sizing post explains why the handler should answer fast.

After the local test passes, use the redeliver route on a real finished job to confirm the receiver also works with Sume's actual headers, including the x-sume-webhook-secret-fingerprint header.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume