> ## Documentation Index
> Fetch the complete documentation index at: https://docs.allium.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook authentication

> Verify that incoming webhook requests came from Beam, with replay protection.

When a webhook sink has a signing key configured, Beam signs every request with **HMAC-SHA-256**. Your endpoint verifies the signature to confirm the request came from Beam and was not tampered with or replayed.

Signing is opt-in per webhook sink. Without a key, Beam still POSTs to your URL but adds no signature headers.

## How signing works

Each request carries three headers:

| Header                | Value                                                 |
| :-------------------- | :---------------------------------------------------- |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when Beam sealed the request |
| `X-Webhook-Nonce`     | Per-request UUID v4                                   |
| `X-Signature-256`     | `sha256=<hex>` — HMAC-SHA-256 of the signed payload   |

The signed payload is the concatenation:

```text theme={null}
<nonce>.<timestamp>.<raw request body>
```

Binding the nonce and timestamp into the signature means an attacker cannot replay an old request with a fresh nonce or shift the timestamp forward without invalidating the signature.

## Enable signing on a sink

1. Store your signing key as an [organization secret](/app/organization-management/secrets). Pick any random high-entropy value (32+ bytes recommended).
2. Set `hash_key` on the webhook sink to the secret's name.

```json theme={null}
{
  "type": "webhook",
  "name": "my-webhook",
  "url": "https://example.com/webhook/receive",
  "hash_key": "MY_WEBHOOK_SIGNING_KEY"
}
```

<Warning>
  If you rotate the secret value, you must **redeploy** the pipeline for the new value to take effect. The sink will be flagged as **stale** until then.
</Warning>

## Verify the signature

The verification recipe is the same in every language:

1. Read the three headers off the incoming request.
2. Reject if `X-Webhook-Timestamp` is too old (e.g. more than 5 minutes ago) — this is your replay window.
3. Recompute `sha256=` + `hmac_sha256(key, nonce + "." + timestamp + "." + raw_body)` in hex.
4. Compare against `X-Signature-256` using a **constant-time** comparison.

<Warning>
  Verify against the **raw request body bytes**, not a re-serialized JSON object. Any whitespace or key-order difference will change the hash and break verification.
</Warning>

<Note>
  If the sink has [`enable_http_encoding`](/beam/api-reference/configuration#sinks) set, Beam signs the **compressed** body — the same bytes that arrive on the wire with `Content-Encoding: zstd`. Run signature verification on the raw request body **before** decompressing it; hashing the decompressed payload will not match.
</Note>

<Tabs>
  <Tab title="Python (FastAPI)">
    ```python theme={null}
    import hmac
    import hashlib
    import time
    from fastapi import FastAPI, Request, HTTPException

    SIGNING_KEY = b"your-signing-key"  # same value stored in the org secret
    MAX_AGE_SECONDS = 300

    app = FastAPI()

    @app.post("/webhook/receive")
    async def receive(request: Request):
        ts = request.headers.get("X-Webhook-Timestamp", "")
        nonce = request.headers.get("X-Webhook-Nonce", "")
        sig = request.headers.get("X-Signature-256", "")

        if not (ts and nonce and sig):
            raise HTTPException(401, "missing signature headers")
        if abs(time.time() - int(ts)) > MAX_AGE_SECONDS:
            raise HTTPException(401, "stale timestamp")

        body = await request.body()
        signed = f"{nonce}.{ts}.".encode() + body
        expected = "sha256=" + hmac.new(SIGNING_KEY, signed, hashlib.sha256).hexdigest()

        if not hmac.compare_digest(expected, sig):
            raise HTTPException(401, "bad signature")

        return {"ok": True}
    ```
  </Tab>

  <Tab title="Node.js (Express)">
    ```javascript theme={null}
    import crypto from "node:crypto";
    import express from "express";

    const SIGNING_KEY = "your-signing-key"; // same value stored in the org secret
    const MAX_AGE_SECONDS = 300;

    const app = express();

    app.post(
      "/webhook/receive",
      express.raw({ type: "application/json" }), // preserve raw bytes
      (req, res) => {
        const ts = req.header("X-Webhook-Timestamp") ?? "";
        const nonce = req.header("X-Webhook-Nonce") ?? "";
        const sig = req.header("X-Signature-256") ?? "";

        if (!ts || !nonce || !sig) return res.status(401).end();
        if (Math.abs(Date.now() / 1000 - Number(ts)) > MAX_AGE_SECONDS) {
          return res.status(401).end();
        }

        const signed = Buffer.concat([Buffer.from(`${nonce}.${ts}.`), req.body]);
        const expected =
          "sha256=" +
          crypto.createHmac("sha256", SIGNING_KEY).update(signed).digest("hex");

        const a = Buffer.from(expected);
        const b = Buffer.from(sig);
        if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
          return res.status(401).end();
        }

        res.json({ ok: true });
      },
    );
    ```
  </Tab>

  <Tab title="Go (net/http)">
    ```go theme={null}
    package main

    import (
        "crypto/hmac"
        "crypto/sha256"
        "encoding/hex"
        "io"
        "net/http"
        "strconv"
        "time"
    )

    var signingKey = []byte("your-signing-key")

    const maxAgeSeconds = 300

    func receive(w http.ResponseWriter, r *http.Request) {
        ts := r.Header.Get("X-Webhook-Timestamp")
        nonce := r.Header.Get("X-Webhook-Nonce")
        sig := r.Header.Get("X-Signature-256")
        if ts == "" || nonce == "" || sig == "" {
            http.Error(w, "missing signature", http.StatusUnauthorized)
            return
        }
        tsInt, err := strconv.ParseInt(ts, 10, 64)
        if err != nil || abs(time.Now().Unix()-tsInt) > maxAgeSeconds {
            http.Error(w, "stale timestamp", http.StatusUnauthorized)
            return
        }
        body, _ := io.ReadAll(r.Body)

        mac := hmac.New(sha256.New, signingKey)
        mac.Write([]byte(nonce + "." + ts + "."))
        mac.Write(body)
        expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

        if !hmac.Equal([]byte(expected), []byte(sig)) {
            http.Error(w, "bad signature", http.StatusUnauthorized)
            return
        }
        w.WriteHeader(http.StatusOK)
    }

    func abs(x int64) int64 { if x < 0 { return -x }; return x }
    ```
  </Tab>
</Tabs>

## Replay protection

The timestamp + nonce pair lets you reject duplicates:

* **Timestamp window** — reject any request whose `X-Webhook-Timestamp` is more than \~5 minutes off your server clock. A short window keeps the nonce cache small.
* **Nonce cache** *(optional, defense-in-depth)* — record each `X-Webhook-Nonce` you've accepted in a short-TTL cache (Redis, etc.) and reject repeats. Cache TTL should match your timestamp window.

For most consumers the timestamp check alone is sufficient.
