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.

5 min readSume
All posts

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.

Send test versus redeliver, read 2026-10-11 from Webhooks on docs.sume.com.
ControlWhat it sendsHas a job_idWhere
Send testDummy signed webhook.test payloadNo/dashboard/webhooks or POST /v1/webhooks/test-deliveries (account:write)
RedeliverThe job's real terminal event, fresh timestamp and signatureYesDelivery 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, not request.json.
  • Accept any sume-v1= entry during rotation.
  • Return empty() for 204.
  • 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

All Developers posts

Written by Sume