Webhook signatures
Every delivery has a signature, so your endpoint can confirm that the request came from Farbeam and that the body is unchanged. Each rule has its own secret. It starts with fbs_ and is shown once, in the signing_secret field of the POST /rules response.
#The scheme
x-farbeam-signature: t=<unix seconds>,v1=<lowercase hex>The server computes the value this way:
// From the service code
const sign = (secret, ts, body) =>
"t=" + ts + ",v1=" + createHmac("sha256", secret).update(ts + "." + body).digest("hex");- The algorithm is HMAC-SHA256.
- The key is the full secret string, including
fbs_, used as text. It is not hex or base64 decoded. - The signed message is the value of
t, then a dot, then the raw request body. - The output is lowercase hex.
#Verification steps
- Read the raw body bytes before any JSON parsing. Parsing and re-encoding can change the bytes.
- Split the header on commas, and each part on
=. Readtandv1. Ignore other parts, so a future addition does not break you. - Compute the HMAC of
t + "." + raw body. - Compare it with
v1in constant time. - Check that
tis within your replay window. Five minutes is a good default.
#Replay window
t is set at each attempt, so a retry has a new t and a new signature with the same body. A window of 300 seconds is enough for real retries, since each attempt is signed at send time.
A window limits how long a captured request can be replayed. For stronger protection, also store data.uid and rule_id for processed events, for at least the length of the window, and reject repeats. On rule.test deliveries, data is null, so there is no uid.
Keep your server clock correct with NTP. If it drifts by more than the window, valid requests fail.
#Test vector
Use these values to test your code. The expected signature was produced by the service's sign function on 2026-11-04 UTC. The secret is an example that matches no rule.
| Item | Value |
|---|---|
| Secret | fbs_docs_example_secret_not_real |
t | 1793820000 |
| Header | t=1793820000,v1=4371da64afc9f38eb71419959cf3180c6d7a5961165791b710c4de9d8003182d |
The body is one line with no trailing newline:
{"type":"rule.test","rule_id":"rule_c3208c320585","rule_name":"Large stablecoin moves","sent_at":"2026-11-04T19:37:09.496Z","data":null}To run the samples below on this vector, pin the time window to the vector's t or skip the time check. The vector is old by design, so a correct time check rejects it.
#curl and openssl
Compute the signature for a saved body. This is useful to debug.
secret='fbs_docs_example_secret_not_real'
t=1793820000
printf '%s' '{"type":"rule.test","rule_id":"rule_c3208c320585","rule_name":"Large stablecoin moves","sent_at":"2026-11-04T19:37:09.496Z","data":null}' > body.json
# Prints the v1 value
{ printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$secret" -r | cut -d' ' -f1Send a signed request to your own receiver, to test it without waiting for Farbeam.
secret="$FARBEAM_SECRET"
t=$(date +%s)
v1=$({ printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$secret" -r | cut -d' ' -f1)
curl -s -X POST "https://example.com/hooks/farbeam" \
-H "content-type: application/json" \
-H "x-farbeam-signature: t=$t,v1=$v1" \
--data-binary @body.jsonUse --data-binary, because -d can strip newlines from a file.
#JavaScript
Node.js with no dependencies. In Express, use express.raw({ type: "application/json" }) on this route, so the body stays as a Buffer.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyFarbeam(secret, header, rawBody, { toleranceSeconds = 300, now = Date.now() / 1000 } = {}) {
const parts = {};
for (const piece of String(header || "").split(",")) {
const at = piece.indexOf("=");
if (at > 0) parts[piece.slice(0, at).trim()] = piece.slice(at + 1).trim();
}
if (!parts.t || !parts.v1 || !/^[0-9]+$/.test(parts.t) || !/^[0-9a-f]{64}$/.test(parts.v1)) return false;
if (Math.abs(now - Number(parts.t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}#Python
Standard library only. In Flask, read request.get_data(). In FastAPI, read await request.body().
import hashlib, hmac, re, time
def verify_farbeam(secret, header, raw_body, tolerance_seconds=300, now=None):
parts = {}
for piece in (header or "").split(","):
key, sep, value = piece.partition("=")
if sep:
parts[key.strip()] = value.strip()
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not re.fullmatch(r"[0-9]+", t) or not re.fullmatch(r"[0-9a-f]{64}", v1):
return False
current = time.time() if now is None else now
if abs(current - int(t)) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)#Go
Standard library only. Read the body once, then pass the bytes on. This sample was reviewed by hand and has not been compiled, unlike the JavaScript, Python and openssl samples, which were run on the test vector.
package farbeam
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
// VerifySignature checks an x-farbeam-signature header against the raw body.
func VerifySignature(secret, header string, rawBody []byte, tolerance time.Duration, now time.Time) bool {
var t, v1 string
for _, piece := range strings.Split(header, ",") {
key, value, found := strings.Cut(piece, "=")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "t":
t = strings.TrimSpace(value)
case "v1":
v1 = strings.TrimSpace(value)
}
}
seconds, err := strconv.ParseInt(t, 10, 64)
if err != nil {
return false
}
age := now.Sub(time.Unix(seconds, 0)).Seconds()
if math.Abs(age) > tolerance.Seconds() {
return false
}
got, err := hex.DecodeString(v1)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
return hmac.Equal(got, mac.Sum(nil))
}Use it in a handler like this:
package farbeam
import (
"io"
"net/http"
"os"
"time"
)
func Handler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
header := r.Header.Get("x-farbeam-signature")
if !VerifySignature(os.Getenv("FARBEAM_SECRET"), header, raw, 5*time.Minute, time.Now()) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Parse raw as JSON here, queue the work, then answer.
w.WriteHeader(http.StatusOK)
}#Common failures
| Symptom | Cause | Fix |
|---|---|---|
| Always fails | The body was parsed and re-encoded before the check | Check the raw bytes. JSON key order and float format can change. |
| Always fails | Secret trimmed, or fbs_ removed | Use the full value as text |
| Fails on some events | The framework decoded the body as text with the wrong charset | Read bytes. The body is UTF-8. |
| Fails after a while | Clock drift on your server | Run NTP |
| Fails for one rule only | The endpoint uses the secret of another rule | Each rule has its own secret. Pick it by x-farbeam-rule. |
#Rotating a secret
There is no rotate endpoint. To replace a secret:
- Create a new rule with the same expression and URL. Save its secret.
- Accept both secrets at your endpoint for a short time. Pick the secret by
x-farbeam-rule. - Delete the old rule.
If a secret leaks, do this at once. Treat secrets like passwords. See security.
#Discord format
A Discord rule also sends the signature headers, but Discord does not verify them. See Discord delivery.