aiohttp web server: verify a Sume webhook with await request.read()
An aiohttp 3.14 web handler that verifies the Sume HMAC on await request.read(), then calls request.json(). Exits on an empty secret and handles webhook.test.

On the aiohttp server side, call await request.read() to get the raw bytes, verify the Sume signature, and then call await request.json() for the data. aiohttp caches the body after the first read, so the second call works. The sample ran on aiohttp 3.14 with Python 3.14.
Read the raw bytes in aiohttp
Most posts about aiohttp cover the client and its timeouts. This one is about aiohttp.web, where the handler is an async def that takes a web.Request. request.read() returns bytes and stores them, so request.json() afterwards does not hit an empty stream.
The headers are in request.headers, a case-insensitive mapping.
await request.read()before any other body call.web.Response(status=401, text="bad signature")for a failed check.web.run_app(app, port=...)under the__main__guard starts the server.
The receiver
Install with pip install aiohttp and run python server.py with PORT=8000. The file stops at import with a message when the secret is not set, so the process never listens without one.
import hashlib, hmac, os, sys, time
from aiohttp import web
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(","))
async def hook(request: web.Request) -> web.Response:
raw = await request.read()
h = request.headers
if not verify(raw, h.get("x-sume-webhook-timestamp", ""),
h.get("x-sume-webhook-signature", "")):
return web.Response(status=401, text="bad signature")
job_id = (await request.json()).get("job_id")
if job_id and job_id not in SEEN:
SEEN.add(job_id)
print("new job", job_id, flush=True)
return web.Response(text="ok")
app = web.Application()
app.add_routes([web.post("/hooks/sume", hook)])
if __name__ == "__main__":
web.run_app(app, 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
A Send test delivery uses the event name webhook.test and has no job_id. The handler calls (await request.json()).get("job_id"), gets None, skips the dedupe step and returns 200.
Real events are job.completed, job.failed and job.canceled. For a failed job the body carries status: "ERROR" and an error object, and the same handler path still applies.
Prove it with a signed request
Sume waits 10 seconds for each attempt. An async def handler should write the event to storage and return, and leave downloads to a background task.
The next block is a Node script 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 server.py first. The output should be 200, then 401, then 401.
import { createHmac } from "node:crypto";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const url = process.argv[2] ?? "http://127.0.0.1:8000/hooks/sume";
const body = JSON.stringify({ event: "job.completed", request_id: "job_demo",
job_id: "job_demo", status: "OK", payload: { artifacts: [] } });
async function send(label, ts, key) {
const mac = createHmac("sha256", key).update(`${ts}.${body}`).digest("hex");
const res = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-sume-webhook-timestamp": String(ts),
"x-sume-webhook-signature": `sume-v1=${mac}`,
},
body,
});
console.log(label, res.status);
}
const now = Math.floor(Date.now() / 1000);
await send("right secret: ", now, secret);
await send("wrong secret: ", now, secret + "x");
await send("stale timestamp:", now - 900, secret);
Sources
Related posts
More in Developers
- Alt text for a 30-image gallery in one Sume Agent Completion
One Agent Completion call can take up to 30 images and return an alts array under an object schema. Python stdlib script with the cap, poll and limits.
- API key scopes for Sume: which key can call which endpoint family?
Sume API keys carry fixed scopes: formats:write, actions:read, agent_completions:write, account:read. Which scope each route needs, and why old keys get a 403.
- Arabic speech to text API: Sume STT with language_code ar
Transcribe Arabic audio with Sume STT: send language_code ar, check the reported language, and review the text. $0.01 per audio minute.
- Avatar job tracking table: which Sume ids to store and why
Avatar work produces a handle, a job id, a preview id and a video id. A small SQL table that keeps them straight, plus the status fields to poll.
Written by Sume