Sanic webhook route for Sume: request.body, HMAC, empty() 204
Verify a Sume webhook in Sanic with request.body bytes and hmac.compare_digest, then return an empty 204 so the delivery counts as done.

In Sanic, verify a Sume webhook with request.body, which Sanic documents as the raw bytes of the request body. Build <timestamp>. plus those bytes, compute HMAC SHA-256 with your signing secret, and compare against each sume-v1= entry in the x-sume-webhook-signature header with hmac.compare_digest. Return empty() for a 204 when the event is stored and 401 when verification fails.
Skip request.json until the check passes. Reading it first is harmless in Sanic, but a handler that signs a re-dumped dict will fail on whitespace and key order.
Why request.body and not request.json?
Sanic's request guide lists request.body as the raw bytes from the body, next to the parsed accessors. Those bytes are what Sume signed. The signed string is <timestamp>.<raw_body>, and the timestamp arrives in x-sume-webhook-timestamp.
The function below rejects an empty secret, a missing header, a non-integer timestamp and a timestamp outside a five-minute window. It builds a list before calling any, so every entry in a rotation header is compared and timing does not reveal which one matched.
import hashlib, hmac, os, time
def verify(secret, ts, header, body: bytes) -> bool:
if not secret or not ts or not header:
return False
try:
t = int(ts)
except ValueError:
return False
if abs(time.time() - t) > 300:
return False
msg = f"{t}.".encode() + body
want = "sume-v1=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return any([hmac.compare_digest(e.strip().encode(), want.encode())
for e in header.split(",")])How do I register the route?
The route reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name used in Sume's docs, and returns text(..., status=401) on failure. Run it with sanic server:app --port 8080, then use the dashboard's Send test control to confirm a delivery lands.
from sanic import Sanic
from sanic.response import empty, text
app = Sanic("sume_hooks")
@app.post("/hooks/sume")
async def hook(request):
h = request.headers
ok = verify(
os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", ""),
h.get("x-sume-webhook-timestamp", ""),
h.get("x-sume-webhook-signature", ""),
request.body,
)
if not ok:
return text("bad signature", status=401)
# insert the event keyed by job_id or request_id, then acknowledge
return empty()What does Send test prove, and what does it not?
Sume's docs separate two controls. Send test posts a dummy signed webhook.test payload to a URL you type, and it never replays a real job. Redeliver re-posts the real terminal event of one job with a fresh timestamp and signature.
| Control | What it sends | Has a job_id | Where |
|---|---|---|---|
| Send test | Dummy signed webhook.test payload | No | /dashboard/webhooks or POST /v1/webhooks/test-deliveries (account:write) |
| Redeliver | The job's real terminal event, fresh timestamp and signature | Yes | Delivery row or POST /v1/jobs/{job_id}/webhook/redeliver (jobs:write) |
What is a quick checklist for the Sanic route?
Because Sanic handlers are coroutines, a slow database call inside the handler is the most likely reason Sume sees a timeout. Each attempt has 10 seconds, so keep the insert cheap or hand it to a background task with app.add_task, after you have verified the signature.
Once the route is live, watch the delivery status Sume shows on the job and in job events. It includes the attempt count when available, which tells you whether a failing endpoint is still being retried.
- Sign
request.body, notrequest.json. - Accept any
sume-v1=entry during rotation. - Return
empty()for204. - Dedupe on
job_id.
What else should the handler do?
A webhook.test event has no job_id, so make the handler tolerate that: return 204 after the signature check and skip the dedupe insert when the id is missing. Otherwise your test button looks broken while real deliveries work.
Persist first, answer fast, and process later. Each attempt has a 10-second budget, and Sume retries job webhooks up to 10 times at a fixed 30-second default spacing. Dedupe on job_id, and keep the poll fallback running for events that never arrive.
Sources
Related posts
More in Developers
- What did one script_run cost? Sume script_runs and its children
Sume /v1/usage lists script_runs with rows and money per call, and counts script_run_children in includes. Read the cost of one script_run to the cent.
- Sonic 3.6 vs Eleven v4 Turbo: one script, one Python A/B run on Sume
Send the same text to sonic-3.6 and eleven-v4-turbo through the TTS Router with one Python script, poll both jobs, and compare the files by ear.
- Sume 400 invalid_request: read details.errors[].path to find the field
A Sume 400 invalid_request lists each bad field in details.errors with path, loc, message and type. Read them in Python instead of guessing from the message.
- A Sume 400 that returns a job id: input rejected at provider submit
Most Sume 400s create nothing, but a provider input rejected at submit leaves a failed job, a request_id and a refund. How to read it and what key to use.
Written by Sume