Rust HMAC-SHA256: verify a webhook signature in axum
Compute HMAC-SHA256 in Rust with the hmac and sha2 crates, then verify a webhook in axum: take Bytes, MAC timestamp.body, verify_slice each entry.

To compute HMAC-SHA256 in Rust, use the hmac and sha2 crates: create Hmac::<Sha256>::new_from_slice(key), feed the message with update, then call finalize() for the tag, or verify_slice(&tag) to check a tag you received in constant time. To verify a webhook in axum, take the body as Bytes rather than Json, MAC the timestamp, a dot and those raw bytes, and accept the delivery when any hex-decoded sume-v1= entry passes verify_slice.
Crate facts come from the docs.rs pages for hmac, digest::Mac, axum::extract, hex and serde_json; Sume facts come from Run webhooks, Webhooks and Verifying webhooks. All were read on 2026-09-28. Sume's SDK is TypeScript, and its docs spell out the scheme for receivers in other languages, so in Rust you write the check yourself. The Go version is in Golang: verify a webhook signature.
How do I compute HMAC-SHA256 in Rust?
Add hmac, sha2 and hex to Cargo.toml; docs.rs lists hmac 0.13.0 and sha2 0.11.0 as current, and the hmac examples import Hmac, KeyInit and Mac. new_from_slice takes a key of any size.
finalize() returns a CtOutput, whose equality check runs in constant time. into_bytes() unwraps the raw array, and the hmac docs warn that incorrect use of it may permit timing attacks. Use the bytes to send a signature, never to compare one.
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
fn main() {
let mut mac = Hmac::<Sha256>::new_from_slice(b"my secret").expect("any key size");
mac.update(b"1785000000.");
mac.update(br#"{"event":"format.run.terminal"}"#);
let signature = hex::encode(mac.finalize().into_bytes()); // lowercase hex
println!("sume-v1={signature}");
}How do I verify a webhook signature in Rust?
Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends sume-v1=<hex_signature> in x-sume-webhook-signature, with the timestamp in x-sume-webhook-timestamp. During a signing-secret rotation, the header carries one comma-separated entry per live secret, and a delivery is valid when any entry matches.
verify_slice compares raw MAC bytes, so hex-decode each entry first. It consumes the MAC, so clone it for each entry; it rejects a tag of the wrong length, then compares with ct_eq, a constant-time equality. The function below checks every entry and refuses an empty secret, as Sume's TypeScript verifier does in current code. Each step maps to one Rust call:
| Step | Sume rule | Rust |
|---|---|---|
| Replay window | Reject timestamps outside it; five minutes is a reasonable default | SystemTime::now().duration_since(UNIX_EPOCH) |
| MAC | HMAC-SHA256 over <timestamp>.<raw_body> | Hmac::<Sha256>::new_from_slice, then update |
| Entries | Accept any sume-v1= entry | split(','), strip_prefix("sume-v1="), hex::decode |
| Compare | Constant time | mac.clone().verify_slice(&sig) |
use axum::http::HeaderMap;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
fn verify_sume(headers: &HeaderMap, body: &[u8], secret: &[u8]) -> bool {
let ts = headers.get("x-sume-webhook-timestamp").and_then(|v| v.to_str().ok());
let sigs = headers.get("x-sume-webhook-signature").and_then(|v| v.to_str().ok());
let (Some(ts), Some(sigs)) = (ts, sigs) else { return false };
let Ok(ts) = ts.parse::<u64>() else { return false };
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
if secret.is_empty() || now.abs_diff(ts) > 300 {
return false; // empty key, or outside the five-minute window
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret) else { return false };
mac.update(format!("{ts}.").as_bytes());
mac.update(body); // the raw bytes, never re-serialized JSON
let mut ok = false;
for entry in sigs.split(',') {
let Some(hex_sig) = entry.trim().strip_prefix("sume-v1=") else { continue };
if let Ok(sig) = hex::decode(hex_sig) {
ok |= mac.clone().verify_slice(&sig).is_ok(); // check every entry
}
}
ok
}How do I read the raw body in an axum handler?
Use the Bytes extractor, which gives you the raw request body. Json consumes the body and deserializes it, so the exact bytes you need to verify are gone. The body is a stream that can be consumed only once, and axum requires the extractor that consumes it to be the last argument: put HeaderMap first.
By default, Bytes doesn't accept bodies larger than 2MB. That fits Sume's run webhooks: a receipt over 1 MiB arrives with payload: null and a result_url to fetch it from. Parse only after the check passes, from the same bytes, with serde_json::from_slice.
// Same file as verify_sume, which already imports HeaderMap.
use axum::{body::Bytes, http::StatusCode, routing::post, Router};
async fn sume_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
let secret = std::env::var("SUME_COM_WEBHOOK_SIGNING_SECRET").unwrap_or_default();
if !verify_sume(&headers, &body, secret.as_bytes()) {
return StatusCode::UNAUTHORIZED; // unset secret fails too
}
let Ok(event) = serde_json::from_slice::<serde_json::Value>(&body) else {
return StatusCode::BAD_REQUEST;
};
record_once(&event).await; // your store or queue, keyed by request_id or job_id
StatusCode::NO_CONTENT // answer fast, work afterwards
}
fn app() -> Router {
Router::new().route("/hooks/sume", post(sume_webhook))
}Why does every signature fail?
Most failures trace back to one of these:
- The handler took
Json<T>, or MACed aserde_json::Valueturned back into bytes. A parsed-and-reserialized object doesn't verify, because key order and whitespace are part of what was signed. - The header was compared whole. During a rotation it carries one entry per live secret, so a whole-header comparison fails; split it on commas and skip entries without the
sume-v1=prefix. - The hex string itself went to
verify_slice. It expects the decoded MAC bytes, and a length mismatch is an error. - The secret doesn't match. Compare the
x-sume-webhook-secret-fingerprintheader with the fingerprint shown beside the secret in the dashboard; Debug Sume webhook delivery covers the rest.
What should the handler do after it verifies?
Record the event, answer 204 inside Sume's 10-second attempt window, and do the slow work afterwards, for example on a queue. Dedupe on request_id for run webhooks and on job_id for job webhooks, since retries repeat them, and answer 204 to an event type you don't know rather than a 500. Signed webhooks for Sume video runs covers retries and the other delivery rules.
Sources
- Run webhooks
- Webhooks
- Verifying webhooks
- docs.rs: hmac (read 2026-09-28)
- docs.rs: hmac::Hmac (read 2026-09-28)
- docs.rs: digest::Mac (read 2026-09-28)
- docs.rs: digest mac.rs source (read 2026-09-28)
- docs.rs: ctutils::CtEq (read 2026-09-28)
- docs.rs: sha2 (read 2026-09-28)
- docs.rs: axum::extract (read 2026-09-28)
- docs.rs: axum::response (read 2026-09-28)
- docs.rs: http::HeaderMap (read 2026-09-28)
- docs.rs: hex::decode (read 2026-09-28)
- docs.rs: hex::encode (read 2026-09-28)
- docs.rs: serde_json::from_slice (read 2026-09-28)
- Rust std: SystemTime (read 2026-09-28)
Related posts
More in Integrations
- Schedule by Zapier: run a Zap every day to make a video
Schedule by Zapier starts a Zap every hour, day, week, or month. Add a Custom Request keyed on the date to start one video run per day.
- Shopify Flow automation: start an AI product video
Shopify Flow automation can start an AI product video: a Send HTTP request action posts each new product to a video API, keyed so retries can't repeat.
- Shopify product metafields: add a video to a product
Shopify product metafields are custom product fields. A File metafield holds one MP4 or MOV video, and a text field can track the video's status.
- Sidekiq retry: backoff, retry count and paid API calls
Sidekiq retries a failed job 25 times over about 20 days by default. Cap it, wait retry-after with sidekiq_retry_in, and reuse one Idempotency-Key.
Written by Sume