Verify and forward a signed webhook
Accept an inbound webhook only if its HMAC signature and timestamp prove it came from the trusted sender, then forward the verified event to a configured upstream URL.
context relay
consumes bynk { Fetch, Logger, Secrets }
---The webhook payload we accept. Parsed from the request body — but only *after*the signature is verified, from the exact bytes that were signed.---type Event = { id: String, kind: String,}
---The sender authenticates not with a token but with an HMAC over the raw body.The boundary recomputes it (constant-time, WebCrypto) and rejects a mismatch with`401` before the handler runs — there is no app-written crypto. The signedtimestamp bounds replay to a five-minute window.
A `Signature` actor attests authenticity, not a principal: it has no identity, sothe binder is always omitted (`by Webhook`).---actor Webhook { auth = Signature( secret = "WEBHOOK_SECRET", header = "X-Signature", timestamp = "X-Timestamp", tolerance = 300 )}
service api from http { -- Verify the inbound webhook, then forward it to a configured upstream URL. on POST("/hooks/event") by Webhook (body: Event) -> Effect[HttpResult[String]] given Fetch, Logger, Secrets { let target <- Secrets.get("RELAY_TARGET_URL") match target { None => ServerError("relay target is not configured") Some(url) => { let req = Request { method: Post, url: url, contentType: Some("application/json"), authorization: None, body: Some(Json.encode(body)), } let res <- Fetch.send(req) match res { Ok(r) => { let _ <- Logger.info("relayed event \(body.id) (\(body.kind)) -> \(r.status)") Ok("relayed") } Err(_) => ServerError("upstream relay failed") } } } }}This example reaches Workers-only shapes (storage bindings, agents, or cron), so it runs with bynk dev rather than in the browser playground. See Install to get started.
How it works
Section titled “How it works”Authentication is a Signature actor: auth = Signature(secret = "WEBHOOK_SECRET", header = "X-Signature", timestamp = "X-Timestamp", tolerance = 300). Before the
handler runs, the boundary recomputes HMAC-SHA256 over the raw body (constant-time,
WebCrypto), rejects a mismatch or a stale timestamp with 401, and only then parses
the Event from the same bytes — there is no app-written crypto, and the signed
timestamp bounds replay to a five-minute window. A Signature actor attests
authenticity, not a principal, so it has no identity and the binder is omitted
(by Webhook).
The handler POST /hooks/event consumes bynk { Fetch, Logger, Secrets }. It reads
the upstream URL from Secrets.get("RELAY_TARGET_URL") rather than hard-coding it,
returning ServerError if it is unset. On Some(url) it re-encodes the verified
event with Json.encode, builds a POST Request with
contentType: Some("application/json"), and sends it onward with Fetch.send. A
successful response logs a line and returns Ok("relayed"); an Err returns
ServerError("upstream relay failed").
Every step is effectful at the boundary — the actor’s HMAC check, then Secrets.get,
then Fetch.send — so there is no pure kernel to factor into a commons, and the
example carries no unit tests by design.