Cookbook / Public APIs
Signed webhooks
Send webhooks your customers can verify, and verify the ones you receive, with timestamps and secret rotation.
A webhook receiver cannot trust a request just because it arrives at its URL. axeon-webhooks implements the widely used scheme (the same shape as Stripe's): the sender signs "<timestamp>.<body>" with HMAC-SHA256 and sends t=<seconds>,v1=<hex>. The receiver checks the signature in constant time and rejects timestamps that are too old, which limits replays.
cargo add axeon-webhooks
use axeon::Server;
use axeon_webhooks::{Signer, Verifier, VerifyWebhook};
use std::time::Duration;
const SECRET: &str = "whsec_change_me_to_32_random_bytes!!";
fn main() {
let mut app = Server::new();
// Receiving: unsigned or wrongly signed requests get 401 before the handler runs.
app.route("/webhooks/incoming")
.middleware(VerifyWebhook::new(
"X-Signature",
Verifier::new(SECRET).tolerance(Duration::from_secs(300)),
))
.post(|req| async move { format!("{} bytes, verified", req.body().len()) });
// Sending: sign a body to see what your customers would receive (demo only).
app.post("/demo/sign", |req| async move {
Signer::new(SECRET).sign_now(req.body())
});
app.listen("127.0.0.1:3000").expect("server failed to start");
}
BODY='{"type":"product.updated","id":"p_42"}'
SIG=$(curl -s -X POST http://127.0.0.1:3000/demo/sign -d "$BODY")
curl -s -X POST http://127.0.0.1:3000/webhooks/incoming -H "x-signature: $SIG" -d "$BODY" # verified
curl -si -X POST http://127.0.0.1:3000/webhooks/incoming -H "x-signature: $SIG" -d '{"changed":true}' # 401
curl -si -X POST http://127.0.0.1:3000/webhooks/incoming -d "$BODY" # 401
Rotating secrets#
Give the verifier both secrets, switch senders to the new one, then drop the old:
let verifier = Verifier::new("new-secret").secret("old-secret");
// While switching, a sender can include both signatures in one header:
let header = new.sign_with(&[&old], body, timestamp);
Notes#
- Verify the raw body, exactly as received, which is why the middleware reads
req.body()and why it does not work onstream_bodyroutes. - The timestamp check limits replays to the tolerance window (5 minutes by default); it does not make a request single-use. Receivers should still be idempotent, for example by recording the event IDs they have handled.
- Use at least 32 random bytes for the secret, and keep it out of source control.
- To check a signature yourself, call
Verifier::verify(header, body).