Falcon ASGI: verify a Sume webhook with req.bounded_stream.read()
Read the raw Sume webhook body in Falcon ASGI with req.bounded_stream.read(), verify HMAC SHA-256, and set HTTP_204 or raise HTTPUnauthorized.

In Falcon's ASGI app, read the raw body of a Sume webhook with await req.bounded_stream.read(), compute HMAC SHA-256 over <timestamp>.<raw_body>, and compare it with each sume-v1= entry in the signature header. Set resp.status = falcon.HTTP_204 when the check passes and raise falcon.HTTPUnauthorized when it does not.
Falcon parses media only when you ask, so there is no body-consumed-by-middleware trap here. The pitfall is the opposite one: calling await req.get_media() first and signing a re-serialized object.
Why bounded_stream?
Falcon's ASGI reference documents Request.bounded_stream as an alias to stream, and its stream object has an awaitable read(). Calling read() with no arguments returns the whole body. The same page warns that an app may not use both read() and the asynchronous iterator on the same request, so pick one reader and stick with it.
Sume webhook bodies are small, and run receipts above 1 MiB are never sent inline. For a hard cap, check req.content_length first and answer 413 yourself above 1,048,576 bytes.
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(",")])What does the resource look like?
Falcon routes by responder method. Add on_post to a resource class, mount it on the path you registered with Sume, and serve the ASGI app with uvicorn app:app.
import falcon
import falcon.asgi
class SumeHook:
async def on_post(self, req, resp):
raw = await req.bounded_stream.read()
ok = verify(
os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", ""),
req.get_header("X-Sume-Webhook-Timestamp") or "",
req.get_header("X-Sume-Webhook-Signature") or "",
raw,
)
if not ok:
raise falcon.HTTPUnauthorized(title="bad signature")
# insert the event keyed by job_id or request_id, then acknowledge
resp.status = falcon.HTTP_204
app = falcon.asgi.App()
app.add_route("/hooks/sume", SumeHook())Which header and id do I log?
Each Sume delivery also carries x-sume-webhook-secret-fingerprint. When a signature fails to verify, log that header next to the fingerprint shown beside the secret in the dashboard. A mismatch means you hold the wrong secret, and neither side has to send the secret itself.
| Symptom | Likely cause | Check |
|---|---|---|
| Every delivery fails | Wrong or empty secret | Compare x-sume-webhook-secret-fingerprint with the dashboard fingerprint |
| Only some fail, around a rotation | Header carries two entries | Accept any sume-v1= match |
| Fails after a proxy hop | Body was changed before you read it | Sign the bytes your app receives, not a re-serialized copy |
| Fails on old deliveries | Timestamp outside your window | Five minutes is the documented default |
How do I test it before pointing Sume at it?
Use the dashboard's Send test control, or POST /v1/webhooks/test-deliveries with an API key that has account:write. It posts a signed webhook.test payload to a URL you type, so you can confirm the route returns 204 without spending a generation.
Then submit one real job with mode: "webhook" and a public HTTPS webhook_url. Sume rejects localhost, private-network and non-HTTPS URLs, so use a tunnel while developing. When the job finishes you should see exactly one job.completed, job.failed or job.canceled delivery.
- Sign
await req.bounded_stream.read(), never a re-dumpedget_media()result. - Return
204only after the event is stored. - Keep polling the job as the backup path.
What should happen after the response?
Write the event down, answer 204, then do the slow work. Sume gives each attempt 10 seconds. Dedupe on job_id for job events and request_id for run events, and treat polling the status_url as your recovery path for anything that never arrives.
Sources
Related posts
More in Developers
- Fastify raw body for a Sume webhook: parseAs string and verifyWebhook
Fastify parses JSON before your handler, which breaks signatures. Use addContentTypeParser with parseAs string and verifyWebhook from @sume-com/sdk.
- Flask webhook receiver for Sume job events: verify the signature
Verify a Sume job webhook in Flask: read request.get_data() raw, check the sume-v1 HMAC and 300 s window, refuse an empty secret, answer 204.
- Port a gemini-nano-banana-2.1 image call to Sume POST /v1/images
Map Google's response_format aspect_ratio and image_size, base64 output and inline inputs to Sume's aspect_ratio, resolution, data[].url and input_references.
- Gleam: verify a Sume webhook with gleam_crypto and secure_compare
Verify a Sume webhook in Gleam: crypto.hmac over timestamp.raw_body as a BitArray, lower-cased base16, secure_compare, rotation entries, and a 300 s window.
Written by Sume