Verify an inbound webhook
Goal: accept a webhook (from Stripe, GitHub, a sibling system) only when its signature proves it came from the trusted sender — and reject replays.
A webhook is authenticated not by a token but by a signature over the request
body: the sender computes an HMAC of the raw body with a shared secret, and you
recompute and compare it. A Signature actor makes the compiler emit that check.
A signed webhook
Section titled “A signed webhook”context hooks
type Event = { id: String }
actor Webhook { auth = Signature(secret = "WEBHOOK_SECRET", header = "X-Signature")}
service api from http { on POST("/hooks/event") by Webhook (body: Event) -> Effect[HttpResult[String]] { Ok("received") }}Before the body runs, the boundary reads the raw request body once,
recomputes HMAC-SHA256 over those exact bytes (WebCrypto, constant-time), and
compares it against the configured header — accepting a bare hex digest or a
sha256=<hex> prefix (the GitHub shape). Any mismatch, or a missing/malformed
header, fails closed with 401; only then is the body parsed, from the
same bytes. There is no app-written HMAC, and no re-serialisation to get the
bytes wrong.
A Signature actor:
- attests authenticity, not a principal — it has no
identity, so the binder is always omitted (by Webhook); - must take a
body— the signature is over the body, so a bodyless signed request is meaningless; - is HTTP-only.
Reject replays with a signed timestamp
Section titled “Reject replays with a signed timestamp”Webhooks retry, so a captured request can be replayed. When the sender signs a
timestamp, configure a timestamp header and a tolerance (in seconds):
context hooks
type Event = { id: String }
actor Webhook { auth = Signature( secret = "WEBHOOK_SECRET", header = "X-Signature", timestamp = "X-Timestamp", tolerance = 300 )}
service api from http { on POST("/hooks/event") by Webhook (body: Event) -> Effect[HttpResult[String]] { Ok("received") }}Now the signed string is <timestamp>.<body>, and the boundary additionally
rejects (fail-closed) a request whose timestamp is not a finite number within
tolerance seconds of now — a five-minute replay window here. A tolerance
without a timestamp is a compile error.
The replay window bounds replay; it does not eliminate it. Full event deduplication (by event id) is the job of an idempotency capability — out of scope here.
See also: Reference — Actors.