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.
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:
<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
- Store your signing key as an organization secret. Pick any random high-entropy value (32+ bytes recommended).
- Set
hash_key on the webhook sink to the secret’s name.
{
"type": "webhook",
"name": "my-webhook",
"url": "https://example.com/webhook/receive",
"hash_key": "MY_WEBHOOK_SIGNING_KEY"
}
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.
Verify the signature
The verification recipe is the same in every language:
- Read the three headers off the incoming request.
- Reject if
X-Webhook-Timestamp is too old (e.g. more than 5 minutes ago) — this is your replay window.
- Recompute
sha256= + hmac_sha256(key, nonce + "." + timestamp + "." + raw_body) in hex.
- Compare against
X-Signature-256 using a constant-time comparison.
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.
Python (FastAPI)
Node.js (Express)
Go (net/http)
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}
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 });
},
);
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 }
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.