Verify signatures
Fromenance is a communication provenance platform, and every webhook it sends is signed so your handler can prove the verdict came from Fromenance and was not modified in transit. This page gives working verification code in three languages.
By the end you will have a handler that rejects unsigned, tampered, replayed, and stale deliveries, and accepts real ones.
The scheme
Section titled “The scheme”| Header | Value |
|---|---|
x-fromenance-event |
Event type |
x-fromenance-delivery |
Delivery id, whd_... |
x-fromenance-timestamp |
Unix seconds, as a decimal string |
x-fromenance-signature |
v1= followed by lowercase hex HMAC-SHA256 |
The signed string is the timestamp, a period, and the raw request body exactly as received:
signature = "v1=" + hex( HMAC-SHA256( key = endpoint secret, message = timestamp + "." + raw_body ) )Four rules, in this order:
- Read the body as raw bytes before any JSON parsing. Re-serialized JSON does not match.
- Reject if
x-fromenance-timestampis more than 5 minutes from your clock, in either direction. This bounds replay of a captured request. - Compute the expected signature and compare it with the header using a constant time comparison. Never use
==on the strings. - Only then parse the body and act. Deduplicate on
x-fromenance-deliveryor theidin the body, since retries resend the same payload.
The secret is the whsec_... value returned once when the endpoint was created. Header names are case insensitive on the wire; the samples read them lowercase.
Samples
Section titled “Samples”Each sample is a complete file from the docs repository and is compiled or executed by the docs test suite.
/** * Verify a Fromenance outbound webhook (TypeScript, Node 18+ or Bun). * * Headers on every delivery: * x-fromenance-event verdict.created | submission.replay_detected | indicator.new | campaign.detected * x-fromenance-delivery whd_... delivery id, stable across retries and replays of the same delivery * x-fromenance-timestamp Unix seconds when the request was signed * x-fromenance-signature v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint secret> * * Reject anything older than 5 minutes and compare in constant time. */import { createHmac, timingSafeEqual } from "node:crypto";
export const TOLERANCE_SECONDS = 5 * 60;
export interface FromenanceHeaders { "x-fromenance-event"?: string; "x-fromenance-delivery"?: string; "x-fromenance-timestamp"?: string; "x-fromenance-signature"?: string;}
export function signFromenance(secret: string, timestamp: string, rawBody: string): string { return `v1=${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`;}
export function verifyFromenanceSignature( headers: FromenanceHeaders, rawBody: string, secret: string, nowSeconds: number = Math.floor(Date.now() / 1000),): boolean { const timestamp = headers["x-fromenance-timestamp"]; const signature = headers["x-fromenance-signature"]; if (!timestamp || !signature) return false; if (!/^\d+$/.test(timestamp)) return false; if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected = signFromenance(secret, timestamp, rawBody); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(signature, "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
/** Example handler for a fetch style runtime (Workers, Bun, Node 18+ with a fetch adapter). */export async function handleWebhook(request: Request, secret: string): Promise<Response> { const rawBody = await request.text(); const headers: FromenanceHeaders = { "x-fromenance-event": request.headers.get("x-fromenance-event") ?? undefined, "x-fromenance-delivery": request.headers.get("x-fromenance-delivery") ?? undefined, "x-fromenance-timestamp": request.headers.get("x-fromenance-timestamp") ?? undefined, "x-fromenance-signature": request.headers.get("x-fromenance-signature") ?? undefined, }; if (!verifyFromenanceSignature(headers, rawBody, secret)) { return new Response("invalid signature", { status: 401 }); } const event = JSON.parse(rawBody) as { id: string; type: string; created_at: string; data: unknown }; // Deduplicate on x-fromenance-delivery or event.id before acting; retries resend the same payload. console.log(`accepted ${event.type} ${headers["x-fromenance-delivery"]}`); return new Response(null, { status: 204 });}"""Verify a Fromenance outbound webhook (Python 3.9+, standard library only).
Headers on every delivery: x-fromenance-event verdict.created | submission.replay_detected | indicator.new | campaign.detected x-fromenance-delivery whd_... delivery id, stable across retries and replays of the same delivery x-fromenance-timestamp Unix seconds when the request was signed x-fromenance-signature v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint secret>
Reject anything older than 5 minutes and compare in constant time."""
import hashlibimport hmacimport jsonimport timefrom typing import Mapping, Optional
TOLERANCE_SECONDS = 5 * 60
def sign_fromenance(secret: str, timestamp: str, raw_body: bytes) -> str: message = f"{timestamp}.".encode("utf-8") + raw_body digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() return f"v1={digest}"
def verify_fromenance_signature( headers: Mapping[str, str], raw_body: bytes, secret: str, now_seconds: Optional[int] = None,) -> bool: lower = {k.lower(): v for k, v in headers.items()} timestamp = lower.get("x-fromenance-timestamp") signature = lower.get("x-fromenance-signature") if not timestamp or not signature or not timestamp.isdigit(): return False now = int(time.time()) if now_seconds is None else now_seconds if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False expected = sign_fromenance(secret, timestamp, raw_body) return hmac.compare_digest(expected, signature)
# Example: a Flask view. Read the raw body, never a re-serialized JSON object.## from flask import Flask, request, abort# app = Flask(__name__)## @app.post("/fromenance/webhook")# def fromenance_webhook():# if not verify_fromenance_signature(request.headers, request.get_data(), WEBHOOK_SECRET):# abort(401)# event = json.loads(request.get_data())# # Deduplicate on request.headers["x-fromenance-delivery"] or event["id"]; retries resend the same payload.# handle(event["type"], event["data"])# return "", 204
def handle(event_type: str, data: dict) -> None: if event_type == "verdict.created": print(f"verdict {data['outcome']} for submission {data['submission_id']} by rule {data['rule']}") elif event_type == "submission.replay_detected": print(f"footer replay on submission {data['submission_id']}") elif event_type == "indicator.new": print(f"new indicator {data['indicator']['kind']} {data['indicator']['value']}")
if __name__ == "__main__": secret = "whsec_example" ts = str(int(time.time())) body = json.dumps({"id": "evt_1", "type": "verdict.created", "created_at": "2026-09-27T00:00:00Z", "data": {}}).encode() headers = {"x-fromenance-timestamp": ts, "x-fromenance-signature": sign_fromenance(secret, ts, body)} print("valid:", verify_fromenance_signature(headers, body, secret))// Verify a Fromenance outbound webhook (Go 1.18+, standard library only).//// Headers on every delivery:// x-fromenance-event verdict.created | submission.replay_detected | indicator.new | campaign.detected// x-fromenance-delivery whd_... delivery id, stable across retries and replays of the same delivery// x-fromenance-timestamp Unix seconds when the request was signed// x-fromenance-signature v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>" keyed with the endpoint secret>//// Reject anything older than 5 minutes and compare in constant time.package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log" "net/http" "os" "strconv" "time")
const toleranceSeconds = 5 * 60
func signFromenance(secret, timestamp string, rawBody []byte) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(timestamp + ".")) mac.Write(rawBody) return "v1=" + hex.EncodeToString(mac.Sum(nil))}
func verifyFromenanceSignature(h http.Header, rawBody []byte, secret string, now time.Time) bool { timestamp := h.Get("x-fromenance-timestamp") signature := h.Get("x-fromenance-signature") if timestamp == "" || signature == "" { return false } ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } delta := now.Unix() - ts if delta < 0 { delta = -delta } if delta > toleranceSeconds { return false } expected := signFromenance(secret, timestamp, rawBody) return hmac.Equal([]byte(expected), []byte(signature))}
type event struct { ID string `json:"id"` Type string `json:"type"` CreatedAt string `json:"created_at"` Data json.RawMessage `json:"data"`}
func webhookHandler(secret string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } if !verifyFromenanceSignature(r.Header, rawBody, secret, time.Now()) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } var ev event if err := json.Unmarshal(rawBody, &ev); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } // Deduplicate on r.Header.Get("x-fromenance-delivery") or ev.ID before acting; retries resend the same payload. log.Printf("accepted %s %s", ev.Type, r.Header.Get("x-fromenance-delivery")) w.WriteHeader(http.StatusNoContent) }}
func main() { secret := os.Getenv("FROMENANCE_WEBHOOK_SECRET") if secret == "" { log.Fatal("set FROMENANCE_WEBHOOK_SECRET") } http.HandleFunc("/fromenance/webhook", webhookHandler(secret)) log.Fatal(http.ListenAndServe(":8080", nil))}Testing your handler
Section titled “Testing your handler”- Create the endpoint and store the secret.
- Webhooks, the endpoint, Send test (
POST /v1/webhook-endpoints/{id}/test). A syntheticverdict.createdarrives within a second. - Confirm your handler returned
2xxand the delivery showsdeliveredin the log. A401from your side shows aslast_status_code: 401with the delivery retrying; fix the secret or the raw body handling, then Replay the delivery. - Change one character of the secret in your handler and replay again: the delivery must now fail. Restore it.
Rotating a secret
Section titled “Rotating a secret”There is no in place rotation of an endpoint secret in v1. Create a second endpoint with the same URL and events, deploy the new secret to your handler so it accepts either secret for a few hours, then delete the old endpoint.