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.

5 min readSume
All posts

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.

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

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

All Developers posts

Written by Sume