Skip to content

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.

src/relay.bynk
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 signed
timestamp bounds replay to a five-minute window.
A `Signature` actor attests authenticity, not a principal: it has no identity, so
the 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")
}
}
}
}
}
Open the full project ↗

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.

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.