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.

5 min readSume
All posts

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.

Sume job webhook delivery rules (read 2026-10-07)
RuleValue
Eventsjob.completed, job.failed, job.canceled (terminal only)
SignatureHMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex>
Replay windowReject timestamps outside about 5 minutes (300 s used here)
AttemptsUp to 10, a fixed 30 s apart by default, 10 s timeout each
AcknowledgeAny 2xx after you stored the event
Dedupe keyjob_id
Send testPOST /v1/webhooks/test-deliveries (account:write), body is webhook.test
RedeliverPOST /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

All Developers posts

Written by Sume