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.

Terminal
cargo add axeon-webhooks
src/main.rs
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");
}
Terminal
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:

Rust
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 on stream_body routes.
  • 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).