Bottle: verify a Sume webhook with request.body.read()
A single-file Bottle 0.13 app that verifies the Sume webhook HMAC with request.body.read(), aborts 401 on a bad signature and exits on an empty secret.

Bottle is one file with no dependencies, and request.body.read() gives the raw bytes of a Sume webhook. Verify the HMAC over <timestamp>.<body>, call abort(401) when no sume-v1= entry matches, and parse the JSON from the same bytes. I ran this on Bottle 0.13.4 with Python 3.14.
Read the raw bytes in Bottle
request.body is a file-like object. Read it once into raw, and use json.loads(raw) for the data, not request.json, so the HMAC and the parser see the same bytes.
request.get_header(name, default) is case-insensitive and returns the default for a missing header, so the verifier always receives a string.
raw = request.body.read()once.abort(401, "bad signature")for a failed check.run(host="127.0.0.1", port=...)binds to loopback for the demo.
The receiver
The sample binds to 127.0.0.1, so only a local process can reach it. For Sume to call it, expose it through a public HTTPS URL, because webhook URLs must be public HTTPS. Install with pip install bottle and run python app.py with PORT=8000.
import hashlib, hmac, json, os, sys, time
from bottle import abort, post, request, run
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET") or sys.exit("SUME_COM_WEBHOOK_SIGNING_SECRET is empty")
SEEN: set[str] = set()
def verify(raw: bytes, ts: str, header: str) -> bool:
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(SECRET.encode(), ts.encode() + b"." + raw, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
return any(hmac.compare_digest(e.strip().encode(), want.encode()) for e in header.split(","))
@post("/hooks/sume")
def hook():
raw = request.body.read()
if not verify(raw, request.get_header("x-sume-webhook-timestamp", ""),
request.get_header("x-sume-webhook-signature", "")):
abort(401, "bad signature")
job_id = json.loads(raw).get("job_id")
if job_id and job_id not in SEEN:
SEEN.add(job_id)
print("new job", job_id, flush=True)
return "ok"
run(host="127.0.0.1", port=int(os.environ.get("PORT", "8000")))
What the check has to do
The rules come from the webhooks guide. Sume signs the raw JSON body, so the receiver hashes the bytes it received and never a re-serialised object. During a secret rotation the signature header can hold several comma-separated entries, newest first, and a delivery is good when any one matches.
The secret comes from SUME_COM_WEBHOOK_SIGNING_SECRET. The program above stops at start-up when the variable is empty, so a missing secret cannot turn into an endpoint that accepts everything.
| Rule | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled (terminal only) |
| Signature | HMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex> |
| Replay window | Reject timestamps outside about 5 minutes (300 s used here) |
| Attempts | Up to 10, a fixed 30 s apart by default, 10 s timeout each |
| Acknowledge | Any 2xx after you stored the event |
| Dedupe key | job_id |
| Send test | POST /v1/webhooks/test-deliveries (account:write), body is webhook.test |
| Redeliver | POST /v1/jobs/{job_id}/webhook/redeliver (jobs:write), fresh timestamp and signature |
The webhook.test event has no job_id
webhook.test events have no job_id. json.loads(raw).get("job_id") returns None, the if job_id and ... guard skips the set, and the handler returns "ok" with a 200. If you use Send test to check your tunnel, this is the path you exercise.
The check for a real event is the same except that job_id is present and gets recorded.
Prove it with a signed request
Bottle's built-in server handles one request at a time. That is enough to prove the signature logic, and not enough for a busy account.
The next block is a bash script with openssl and curl that signs one body three ways and prints the status of each answer. Start the receiver with SUME_COM_WEBHOOK_SIGNING_SECRET=whsec_test PORT=8000 python app.py first. The output should be 200, then 401, then 401.
#!/usr/bin/env bash
set -eu
URL="${1:-http://127.0.0.1:8000/hooks/sume}"
BODY='{"event":"job.completed","request_id":"job_demo","job_id":"job_demo","status":"OK","payload":{"artifacts":[]}}'
sign() { # $1 = timestamp, $2 = secret
printf '%s.%s' "$1" "$BODY" | openssl dgst -sha256 -hmac "$2" -hex | sed 's/^.* //'
}
send() { # $1 = timestamp, $2 = secret
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "x-sume-webhook-timestamp: $1" \
-H "x-sume-webhook-signature: sume-v1=$(sign "$1" "$2")" \
--data-binary "$BODY"
}
NOW=$(date +%s)
echo "right secret: $(send "$NOW" "$SUME_COM_WEBHOOK_SIGNING_SECRET")"
echo "wrong secret: $(send "$NOW" "${SUME_COM_WEBHOOK_SIGNING_SECRET}x")"
echo "stale timestamp: $(send "$((NOW - 900))" "$SUME_COM_WEBHOOK_SIGNING_SECRET")"
Sources
Related posts
More in Developers
- C# HttpClient: submit a Seedance 2.5 job and poll until completed
A .NET top-level-statements script that posts a seedance-2.5 job to Sume, polls every 30 seconds, and stops on completed, failed or cancelled.
- callback_url or webhook_url: which field each Sume video route takes
POST /v1/videos takes callback_url; motion control, lip-sync and image routes take mode plus webhook_url. The field names and what they share.
- Cancel a wrong video job after a Sora port: only before it starts
Ported prompts on the wrong model burn money. Sume cancels a video job only before generation starts; later you get 409 job_generation_already_started.
- Cancel a Format run: cancel_effect canceled vs no_op, and the bill
Cancel is idempotent. cancel_effect says canceled or no_op, a canceled run never sends a webhook, and generation that finished is still billed.
Written by Sume