# Verify a signed webhook with HMAC

URL: /docs/cookbook/sign_webhook
Markdown: /docs/cookbook/sign_webhook.md

> 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](https://github.com/icantcodefyi/sakuralang/tree/main/compiler/examples/sign_webhook) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/sign_webhook
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "sign_webhook"
version = "0.1.0"
edition = "2026"

[permissions]
```

### Source and tests [#source-and-tests]

`src/main.sak` includes all required imports and tests.

```sakura test id=cookbook-sign_webhook project=sign_webhook
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 [#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](https://github.com/icantcodefyi/sakuralang/commit/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.
