Farbeam
Docs / Alerts and webhooks

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

Text
x-farbeam-signature: t=<unix seconds>,v1=<lowercase hex>

The server computes the value this way:

JavaScript
// 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

  1. Read the raw body bytes before any JSON parsing. Parsing and re-encoding can change the bytes.
  2. Split the header on commas, and each part on =. Read t and v1. Ignore other parts, so a future addition does not break you.
  3. Compute the HMAC of t + "." + raw body.
  4. Compare it with v1 in constant time.
  5. Check that t is 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.

ItemValue
Secretfbs_docs_example_secret_not_real
t1793820000
Headert=1793820000,v1=4371da64afc9f38eb71419959cf3180c6d7a5961165791b710c4de9d8003182d

The body is one line with no trailing newline:

JSON
{"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.

curl
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' ' -f1

Send a signed request to your own receiver, to test it without waiting for Farbeam.

curl
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.json

Use --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.

JavaScript
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().

Python
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.

go
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:

go
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

SymptomCauseFix
Always failsThe body was parsed and re-encoded before the checkCheck the raw bytes. JSON key order and float format can change.
Always failsSecret trimmed, or fbs_ removedUse the full value as text
Fails on some eventsThe framework decoded the body as text with the wrong charsetRead bytes. The body is UTF-8.
Fails after a whileClock drift on your serverRun NTP
Fails for one rule onlyThe endpoint uses the secret of another ruleEach rule has its own secret. Pick it by x-farbeam-rule.

#Rotating a secret

There is no rotate endpoint. To replace a secret:

  1. Create a new rule with the same expression and URL. Save its secret.
  2. Accept both secrets at your endpoint for a short time. Pick the secret by x-farbeam-rule.
  3. 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.