Verify a signed webhook with HMAC
Check that a webhook delivery carries a valid HMAC-SHA-256 signature before acting on it.
Generated by cargo run -p agent_docs from compiler/examples/. Edit the example files, then regenerate.
Check that a webhook delivery carries a valid HMAC-SHA-256 signature before acting on it.
A webhook provider signs each body with a secret it shares with you and sends the tag in a
header, here X-Signature as Base64 (Shopify's X-Shopify-Hmac-Sha256 has this shape).
webhook_key imports the secret as a CryptoKey that may only Verify: the material stays
a Secret, so the key is never shown, logged or serialized, and export_key refuses it.
key.verify(tag, body) recomputes the HMAC and compares it in constant time, so the time a
check takes does not reveal how many leading bytes of a forged tag were right. A missing
header or one that is not Base64 is not authentic, and the endpoint answers 401 without
saying why.
The tests sign deliveries the way the provider does and check that the right secret is
accepted, that a changed body, another secret, a missing header and a malformed one are
rejected, and that the receiving key cannot sign. They run the router as a function, with no
server, and need no effects or permissions: std/crypto is pure. In an application the
secret comes from Env.secret, which needs an env entry in [permissions].
Source project · harness mode: test.
From the repository root, with this checkout's sakura on PATH:
cd compiler/examples/sign_webhook
sakura check --json
sakura test --jsonManifest and permissions
[package]
name = "sign_webhook"
version = "0.1.0"
edition = "2026"
[permissions]Source and tests
src/main.sak includes all required imports and tests.
import std/bytes.{Alphabet}
import std/crypto
import std/crypto.{Algorithm, CryptoKey, Digest, KeyError, KeyFormat, Usage}
import std/request.{BadRequest, Request, Text}
import std/response.{Response, Status}
import std/router.{Router}
/// The provider's signing secret as an HMAC-SHA-256 key that can only verify.
fn webhook_key(secret: Secret<String>) -> Result<CryptoKey, KeyError> {
let material = Secret.map(secret, fn(s) { String.to_bytes(s) }, @trust("webhook signing key"))
crypto.import_key(KeyFormat.Raw, Algorithm.Hmac(Digest.Sha256), [Usage.Verify], false, material)
}
/// Whether `x-signature` is the Base64 HMAC-SHA-256 of the body under `key`, compared in
/// constant time. A missing or malformed header is not authentic. The tag covers the body's
/// bytes as they arrived, read by `Request.bytes`; a body over 64 KiB is `BadRequest`.
fn authentic(key: CryptoKey, req: Request) -> Bool ! {Fail<BadRequest>} {
let body = Request.bytes(req, 65_536)
match Request.header(req, "x-signature").map(fn(s) { Bytes.from_base64(s, Alphabet.Standard) }) {
Some(Ok(tag)) => key.verify(tag, body),
_ => false,
}
}
fn receive(key: CryptoKey, req: Request) -> Response {
try {
if authentic(key, req) {
Response.status(200, "accepted")
} else {
Response.status(401, "bad signature")
}
} catch {
BadRequest { detail } => Response.status(400, detail),
}
}
fn app(key: CryptoKey) -> Router<{}> {
Router.new().post("/webhook", fn(req) { receive(key, req) })
}
/// What the provider does: sign `body` with the shared secret and send the tag in a header.
fn delivery(secret: String, body: String) -> Request {
let signer = crypto.import_key(
KeyFormat.Raw,
Algorithm.Hmac(Digest.Sha256),
[Usage.Sign],
false,
Secret.of(String.to_bytes(secret)),
)
let tag = match signer {
Ok(key) => Bytes.to_base64(crypto.hmac(key, String.to_bytes(body)), Alphabet.Standard, true),
Err(_) => "",
}
Request { ..Request.post("/webhook", body), headers: [("X-Signature", tag)] }
}
fn status_of(req: Request) -> Status {
match webhook_key(Secret.of("whsec_test")) {
Ok(key) => Router.handle(app(key), req).status,
Err(_) => Status.from(500),
}
}
test "a delivery signed with the shared secret is accepted" {
expect_eq(status_of(delivery("whsec_test", "\{\"event\":\"paid\"\}")), Status.from(200))
}
test "a changed body or another secret is rejected" {
let signed = delivery("whsec_test", "\{\"event\":\"paid\"\}")
expect_eq(status_of(signed.with_payload(Text("\{\"event\":\"refund\"\}"))), Status.from(401))
expect_eq(status_of(delivery("whsec_other", "\{\"event\":\"paid\"\}")), Status.from(401))
}
test "a missing or malformed signature is rejected" {
let signed = delivery("whsec_test", "ping")
expect_eq(status_of(Request { ..signed, headers: [] }), Status.from(401))
expect_eq(
status_of(Request { ..signed, headers: [("x-signature", "not base64!")] }),
Status.from(401),
)
}
test "the receiving key can verify but not sign" {
match webhook_key(Secret.of("whsec_test")) {
Ok(key) => {
expect(crypto.permits(key, Usage.Verify))
expect(!crypto.permits(key, Usage.Sign))
},
Err(_) => expect(false),
}
}Compiler provenance and regeneration
Finish only when no holes remain, independent tests pass, the requested target builds, and contracts/tests/permissions have not been weakened. Repeat the frozen baseline check after the final edits. Report missing prerequisites and failing checks explicitly; do not claim a skipped test passed.
These ten projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by cargo test -p sakura_cli --test examples;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.
The initial API baseline is compiler revision
5cae349516c21714d90d36e87f1cfd6debe980c6.
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run sakura --version and sakura prompt before adapting a recipe.
Sakura Lang is pre-1.0: read STABILITY.md before depending on an API. An installed
skill's frontmatter records its compiler version and build revision. These teaching examples are separate from the agent
evaluation tasks.
The source of each page is its example's recipe.md, sakura.toml,
src/main.sak and fixture files. Run cargo run -p agent_docs from compiler/
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
Last updated