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.

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.
| Fact | Job webhooks | Run webhooks |
|---|---|---|
| Events | job.completed, job.failed, job.canceled | action.run.terminal, format.run.terminal, agent.run.terminal |
| Signed string | <timestamp>.<raw_body>, HMAC SHA-256 | Same scheme, same secret |
| Headers | x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex> | Same |
| Attempts and timeout | Up to 10, fixed spacing (30s default), 10s each | Up to 10, exponential from 30s with jitter capped at 1h, 10s each |
| Dedupe key | job_id | request_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: dictto the signature. - Keep the secret in an environment variable and refuse empty.
- Return
Nonewithstatus_code=204. - Log the
request_idorjob_idwith the delivery.
Sources
Related posts
More in Developers
- MCPJam auth debugger on Sume's MCP host: what each step returns
Point MCPJam's auth debugger at mcp.sume.com and read the results: S256-only PKCE, public clients, authorization_code only, a one-hour token, a resource check.
- Nova 2.5 Sonic async tool calls: start a Sume job, return the job id
With asynchronous tool calling, a Nova 2.5 Sonic agent can start a Sume music or caption job, answer at once with the job id, and poll status later.
- Nushell: submit a Sume job and poll it to a result, no jq needed
A Nushell script that POSTs a Sume image job with an Idempotency-Key, polls /v1/jobs/{id}/status by next_poll_after_seconds, then fetches the result.
- One-job limits in Sume's TTS Router: v3 5,000, v4 10,000, Sonic 20,000
Each TTS Router id has its own max_characters. A Python planner splits a script on paragraph breaks so each request fits the row you picked.
Written by Sume