Actix Web: verify a Sume webhook from the web::Bytes extractor
Take the Sume webhook body as web::Bytes in Actix Web, verify HMAC SHA-256 with the hmac and subtle crates, and return 204 or 401.

In Actix Web, extract the Sume webhook body as web::Bytes, read the two signature headers from HttpRequest, and recompute HMAC SHA-256 over <timestamp>.<raw_body>. Compare the result with each sume-v1= header entry using subtle's constant-time equality, return 401 when nothing matches, and 204 once the event is stored.
Using Bytes instead of a web::Json<T> extractor keeps the signed bytes intact. If you deserialize first, you are verifying a re-serialized copy of the payload, not what Sume signed.
Why web::Bytes and not web::Json?
The docs.rs page for actix_web::web::Bytes describes it as a cheaply cloneable, sliceable chunk of contiguous memory, and its trait list shows FromRequest, which is what lets you put it straight into a handler signature. You get the whole payload as bytes, sign it, and parse it only after the signature passes.
Add actix-web, hmac, sha2, subtle and hex to Cargo.toml. The verifier below refuses an empty secret, parses the timestamp as an integer, and rejects anything more than 300 seconds away from the server clock.
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use std::time::{SystemTime, UNIX_EPOCH};
fn verify(secret: &str, ts: &str, header: &str, body: &[u8]) -> bool {
if secret.is_empty() { return false; }
let Ok(t) = ts.parse::<i64>() else { return false };
let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
if (now - t).abs() > 300 { return false; }
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
mac.update(format!("{t}.").as_bytes());
mac.update(body);
let want = format!("sume-v1={}", hex::encode(mac.finalize().into_bytes()));
header.split(',').fold(false, |ok, e| {
ok | bool::from(e.trim().as_bytes().ct_eq(want.as_bytes()))
})
}What does the handler return?
The handler reads headers with a small closure that falls back to an empty string, which the verifier rejects. fold visits every entry, so a rotation header with two signatures costs the same time whichever one matches.
use actix_web::{post, web, App, HttpRequest, HttpResponse, HttpServer};
#[post("/hooks/sume")]
async fn hook(req: HttpRequest, body: web::Bytes) -> HttpResponse {
let h = |n: &str| req.headers().get(n).and_then(|v| v.to_str().ok()).unwrap_or("");
let secret = std::env::var("SUME_COM_WEBHOOK_SIGNING_SECRET").unwrap_or_default();
if !verify(&secret, h("x-sume-webhook-timestamp"), h("x-sume-webhook-signature"), &body) {
return HttpResponse::Unauthorized().finish();
}
// insert the event keyed by job_id or request_id, then acknowledge
HttpResponse::NoContent().finish()
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(hook)).bind(("0.0.0.0", 8080))?.run().await
}What else does Sume's delivery behavior change?
Sume does not follow redirects on run webhooks, so a route that answers with 301 counts as a failed attempt. Register the exact public HTTPS path, with no trailing-slash redirect in front of it.
| 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 |
| Redirects | Webhook URL must be public HTTPS | 3xx is a failed attempt; redirects are not followed |
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.
Sources
Related posts
More in Developers
- Caddy in front of a Sume webhook receiver: body limit and timeouts
Does Caddy break Sume webhook signatures? reverse_proxy passes headers and body through; set max_size above 1 MiB and an 8 s upstream timeout.
- Clojure: Ring middleware that verifies a Sume webhook from raw bytes
Verify a Sume webhook in Clojure: slurp Ring's one-shot body as bytes, HMAC-SHA256 with javax.crypto, MessageDigest/isEqual, rotation entries, 300 s window.
- Django view for Sume webhooks: verify request.body, then parse
A Django view that verifies a Sume webhook against request.body: csrf_exempt, 300 s replay window, empty-secret refusal, multi-signature header, 204.
- Eleven rows on Sume return MP3 only: use Sonic when you need WAV
Sume's eleven-* TTS Router ids return MP3 only, and three of the five accept just one format. WAV, avatar voices and word timings belong to the sonic ids.
Written by Sume