Skip to content

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.

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:

  1. Read the body as raw bytes before any JSON parsing. Re-serialized JSON does not match.
  2. Reject if x-fromenance-timestamp is more than 5 minutes from your clock, in either direction. This bounds replay of a captured request.
  3. Compute the expected signature and compare it with the header using a constant time comparison. Never use == on the strings.
  4. Only then parse the body and act. Deduplicate on x-fromenance-delivery or the id in 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.

Each sample is a complete file from the docs repository and is compiled or executed by the docs test suite.

samples/verify-webhook.ts
/**
* 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 });
}
  1. Create the endpoint and store the secret.
  2. Webhooks, the endpoint, Send test (POST /v1/webhook-endpoints/{id}/test). A synthetic verdict.created arrives within a second.
  3. Confirm your handler returned 2xx and the delivery shows delivered in the log. A 401 from your side shows as last_status_code: 401 with the delivery retrying; fix the secret or the raw body handling, then Replay the delivery.
  4. Change one character of the secret in your handler and replay again: the delivery must now fail. Restore it.

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.