Litestar handler for Sume webhooks: await request.body() and 204

A Litestar post handler that awaits request.body(), verifies the Sume HMAC signature, sets status_code=204 explicitly, and raises 401 on a mismatch.

5 min readSume
All posts

In Litestar, take the Request object in your handler, await request.body() to get the exact bytes, and recompute HMAC SHA-256 over <timestamp>.<raw_body> for a Sume webhook. Set status_code=204 on the decorator, rather than relying on a default, and raise NotAuthorizedException when no sume-v1= entry matches.

Do not declare the body as a data: dict parameter on this route. Litestar would parse it first, and you would lose the bytes Sume signed.

Where does the raw body come from in Litestar?

Litestar's requests documentation describes the Request object and its body() method, and says middlewares and ASGI handlers should build a Request and use stream() or body() to read the body safely, because request_max_body_size is applied per request. For a webhook that is the right reading path: one await, bytes in hand.

The verifier is plain Python. It refuses an empty secret, accepts any one of several comma-separated sume-v1= entries so a secret rotation does not break deliveries, and checks the timestamp against a five-minute window.

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 write the handler?

Setting status_code=204 and returning None gives Sume the empty success it wants. The explicit status code matters: Sume counts only 2xx as delivered, so a 4xx or 5xx from a mis-set default simply triggers a retry.

from litestar import Litestar, Request, post
from litestar.exceptions import NotAuthorizedException

@post("/hooks/sume", status_code=204)
async def hook(request: Request) -> None:
    raw = await request.body()
    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", ""),
        raw,
    )
    if not ok:
        raise NotAuthorizedException("bad signature")
    # insert the event keyed by job_id or request_id, then acknowledge

app = Litestar([hook])

What does Sume do when the answer is not 2xx?

Retry behavior differs by webhook family, so read your own endpoint's logs against this table.

Sume delivery facts, read 2026-10-11 from Webhooks and Run webhooks on docs.sume.com.
FactJob webhooksRun webhooks
Eventsjob.completed, job.failed, job.canceledaction.run.terminal, format.run.terminal, agent.run.terminal
Signed string<timestamp>.<raw_body>, HMAC SHA-256Same scheme, same secret
Headersx-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>Same
Attempts and timeoutUp to 10, fixed spacing (30s default), 10s eachUp to 10, exponential from 30s with jitter capped at 1h, 10s each
Dedupe keyjob_idrequest_id

What should the handler do after the check passes?

Persist the event first, then return 204. Sume treats any 2xx as delivered and gives each attempt 10 seconds, so a handler that does real work inline can time out and trigger a retry of an event you already accepted. Write the body to your queue or table, answer, and process after the response.

Dedupe on the id the docs name: job_id for job events and request_id for run events. A retry and a manual redeliver carry the same ids with a fresh timestamp and signature, so a unique constraint on that id makes the second copy harmless.

Keep polling as the backup. After ten refused attempts the job or run is still finished, only the notification failed, and Sume's docs say to keep the status_url poll available for events that never arrive.

What should I double-check in Litestar?

Litestar's docs say middlewares and ASGI handlers should build a Request and use stream() or body() instead of the raw ASGI events, so any middleware in front of this route should read the body that way too.

Then confirm the decorator. A webhook route should declare status_code=204, return None, and raise NotAuthorizedException for a bad signature so Sume sees 401 and retries only genuine failures on its own schedule.

  • Do not add data: dict to the signature.
  • Keep the secret in an environment variable and refuse empty.
  • Return None with status_code=204.
  • Log the request_id or job_id with the delivery.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume