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.

To receive Sume job webhooks in Flask, read the raw bytes with request.get_data(), compute an HMAC-SHA256 over the timestamp, a dot and those bytes, compare it to each sume-v1 entry in x-sume-webhook-signature, and return 204 once the event is stored.
Sume signs each delivery, so the receiver has one job: hand the untouched bytes to the HMAC check, reject anything that fails, store the event, and answer with a 2xx quickly. The rest of this page shows that for Flask.
Why does a Flask webhook fail verification?
The usual failure is parsing the JSON first and re-encoding it for the check. Any change in key order or whitespace changes the bytes, so the digest no longer matches. Read the raw body first and parse it only after the signature passes.
The signature covers the timestamp, a dot, and the raw body. Sume's docs say a parsed-and-reserialized object does not verify, because key order and whitespace are part of the signed data. So the check must run before any JSON parsing.
What does a Flask receiver look like?
The sample below reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name the Sume docs use. It refuses to run the check when the secret is empty, enforces the 300-second replay window, and accepts the delivery when any sume-v1 entry matches, which keeps it working during a secret rotation.
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ.get("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
def verify(body: bytes, ts: str, header: str) -> bool:
if not SECRET or not ts.isdigit():
return False
if abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256)
want = "sume-v1=" + mac.hexdigest()
return any(hmac.compare_digest(p.strip(), want) for p in header.split(","))
@app.post("/hooks/sume")
def hook():
body = request.get_data() # raw bytes, before any JSON parsing
ok = verify(body, request.headers.get("x-sume-webhook-timestamp", ""),
request.headers.get("x-sume-webhook-signature", ""))
if not ok:
abort(401)
event = request.get_json(force=True)
if event.get("event") in ("job.completed", "job.failed", "job.canceled"):
print("store", event.get("job_id")) # replace with a durable write
return "", 204
What does Sume send and expect back?
Job webhooks carry job.completed, job.failed or job.canceled, and terminal events only: there are no progress deliveries. Run webhooks (Formats, Actions, Agent Completions) use the same signature scheme, so one verifier covers both. Route on the event field and answer unknown events with 204 so a new event type never becomes a retry storm.
| Step | Value from the Sume docs |
|---|---|
| Signed data | <timestamp>.<raw_body>, HMAC-SHA256 with your workspace secret |
| Signature header | x-sume-webhook-signature: sume-v1=<hex>; during a rotation, two comma-separated entries, newest first |
| Timestamp header | x-sume-webhook-timestamp; default replay window 300 seconds |
| Delivery limits | Up to 10 attempts, 30 s apart by default, 10 s timeout per attempt |
| Dedupe key | job_id (job events) or request_id |
How do I handle retries and lost deliveries?
Return a 2xx only after the event is stored durably. After ten refused attempts the job still reached its terminal state; only the notification failed. Keep a poll on the job's status_url as a fallback, or ask Sume to re-send the real terminal event with POST /v1/jobs/{job_id}/webhook/redeliver (needs the jobs:write scope). Redelivery carries a fresh timestamp and signature, so your replay window will accept it.
Use the dashboard's Send test (or POST /v1/webhooks/test-deliveries) to check the route first. It posts a dummy signed webhook.test payload that has no job_id, so your handler should treat it as a no-op rather than an error.
- Verify on raw bytes before parsing.
- Never run the check with an empty secret.
- Dedupe on job_id; redelivery is a normal event.
- If signatures fail, compare x-sume-webhook-secret-fingerprint with the fingerprint shown in the dashboard before you paste anything into a ticket.
Sources
Related posts
More in Developers
- 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.
- Go Fiber v3: BodyRaw for Sume webhooks, and copy it before you queue
Sign-check a Sume webhook in Go Fiber with c.BodyRaw() and hmac.Equal, then copy the bytes before handing them to a goroutine, since Fiber reuses them.
- Grok Imagine Video 1.5 Lite on Vercel AI Gateway vs Sume /v1/videos
Vercel added Grok Imagine Video 1.5 Lite to AI Gateway on Oct 8. What generateVideo gives you, and the equivalent text-to-video call on Sume's /v1/videos.
Written by Sume