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.

5 min readSume
All posts

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.

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
RedirectsWebhook URL must be public HTTPS3xx 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

All Developers posts

Written by Sume