Skip to content

Integration guide, end to end

Fromenance is a communication provenance platform: an institution registers each outbound communication at send time, and a customer who forwards a suspicious message to verify@ on the institution’s domain or pastes it on a verify page gets Verified, Not verified, or Known fraud in seconds. This guide is the whole integration as a checklist, written so that a person or an AI agent can execute it top to bottom from this page alone.

There is nothing to install. Fromenance is a REST API: one HTTPS call at send time, one TXT record, one redirect rule. Base URL https://api.fromenance.com, JSON in and out, Authorization: Bearer <api key>, errors as RFC 9457 problem documents (see Errors). The example institution throughout is JT Merlin Bank, domain jtmerlin.example, tenant slug jtmerlin; substitute your own.

By the end you will have completed steps 1 to 6 (required), decided on 7 to 9 (optional), and confirmed the whole path with step 10.

Do. Sign in to app.fromenance.com with an owner or admin account (email magic link or passkey). Open Settings, API keys, Mint key. Name it, choose the mode, choose the scopes register and read.

  • fr_test_ keys are for the sandbox: registrations and submissions are marked sandbox: true, replies go to the tenant’s fraud contact address instead of a customer. Use one for the whole of this guide first.
  • fr_live_ keys are for production. Repeat step 6 with a live key when you go live.
  • The secret is shown once. Store it as FROMENANCE_API_KEY.
  • Also copy the recipient hashing secret from Settings and store it as FROMENANCE_TENANT_SECRET. It is a separate value from the API key and never travels to the API.

Request (alternative, with an existing admin key).

POST https://api.fromenance.com/v1/api-keys
Authorization: Bearer fr_live_...
Content-Type: application/json
{ "name": "integration", "mode": "test", "scopes": ["register", "read"] }

Read. 201 with id (key_...), prefix (the first 12 characters, the only part stored), mode, scopes, and secret (the full key, this response only).

Confirm.

GET https://api.fromenance.com/v1/tenant
Authorization: Bearer fr_test_...

200 with slug (for example jtmerlin), verify_address (jtmerlin@verify.fromenance.com), journal_address, minimal_reply_address, retention_days, match_threshold, sandbox. Keep slug and verify_address; steps 2 and 3 use them.

Step 2: add the domain and publish the TXT record

Section titled “Step 2: add the domain and publish the TXT record”

Do. Register the domain that will host verify@ and that your outbound mail is signed with.

Request.

POST https://api.fromenance.com/v1/domains
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "domain": "jtmerlin.example" }

Read. 201 with id (dom_...), status (pending), roles (["trust", "sender"]), and txt_record:

{ "name": "_fromenance.jtmerlin.example", "type": "TXT", "value": "v=fromenance1; t=jtmerlin; k=0123456789abcdef0123456789abcdef" }

Publish exactly that record at your DNS provider: name _fromenance.<domain>, type TXT, value v=fromenance1; t=<slug>; k=<key> as one quoted string, TTL 300. The key is not a secret; it binds the zone to your tenant. The record is the only DNS change Fromenance needs; it never touches MX, SPF, or DKIM.

If the zone is on Cloudflare, one API call publishes it (token with Zone DNS Edit on that zone):

Terminal window
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"type":"TXT","name":"_fromenance.jtmerlin.example","content":"v=fromenance1; t=jtmerlin; k=0123456789abcdef0123456789abcdef","ttl":300}'

Route 53, Google Cloud DNS, Azure DNS, and registrar panels all take the same three values (name, TXT, value). Check propagation from the public resolvers before asking Fromenance to re-check:

Terminal window
curl -s -H "accept: application/dns-json" "https://cloudflare-dns.com/dns-query?name=_fromenance.jtmerlin.example&type=TXT"
# expect an Answer entry whose data is the quoted value above

Confirm. Fromenance polls every hour for 24 hours, then daily. Force a check and repeat until status is verified:

POST https://api.fromenance.com/v1/domains/dom_.../check
Authorization: Bearer fr_test_...

200 with the domain; read status, verified_at, and last_check_result (for example found 1 TXT record(s), none matched while the old record is still cached). DNS propagation is the usual wait; a TTL of 300 on the record shortens it.

Other calls you may need: GET /v1/domains (list), PATCH /v1/domains/{id} with { "roles": ["trust", "sender", "reply"] } (roles), DELETE /v1/domains/{id}. Trust model and rejection reasons: Domains and mail.

Step 3: create verify@ and the redirect rule

Section titled “Step 3: create verify@ and the redirect rule”

Do. On your mail platform, create the address verify@jtmerlin.example (a group, shared mailbox, or user) and a rule that redirects everything it receives to your tenant inbox, the verify_address from step 1: jtmerlin@verify.fromenance.com. Redirect, not forward: a redirect keeps the customer as the sender and the platform’s DKIM signature or ARC seal intact, so Fromenance can authenticate the hop and reply to the customer.

Click by click for each platform: Google Workspace (default routing rule, Change envelope recipient), Microsoft 365 (mail flow rule, Redirect the message to), Proofpoint, Mimecast, Cisco Secure Email.

Confirm. Step 4 is the confirmation.

Request.

POST https://api.fromenance.com/v1/domains/dom_.../forwarding-test
Authorization: Bearer fr_test_...

Requires status: verified from step 2 (otherwise 409). Fromenance emails verify@jtmerlin.example with a token in the subject and an X-Fromenance-Forward-Test header.

Read. 200 with status (sent), sent_at, to (verify@jtmerlin.example).

Confirm. Poll every 5 to 10 seconds for up to two minutes:

GET https://api.fromenance.com/v1/domains/dom_.../forwarding-test
Authorization: Bearer fr_test_...

Stop when status is working; result reads working via dkim jtmerlin.example or working via arc jtmerlin.example. If status is failed, result reads failed: <reason>; the reasons and fixes are on Domains and mail. Fix the rule and POST again.

Step 5: compute the recipient hash and the content fingerprint

Section titled “Step 5: compute the recipient hash and the content fingerprint”

Two values are computed on your side for every message. Neither the address nor the body is ever sent.

recipient_hash = "hmac-sha256:" + lowercase_hex( HMAC-SHA256( key = FROMENANCE_TENANT_SECRET, message = canonical(address) ) )
canonical(address) = trim; if a display name is present keep only the part inside < >; lowercase; strip a leading "mailto:"

The API validates the shape ^hmac-sha256:[0-9a-f]{64}$ and rejects anything else.

samples/recipient-hash.ts
/**
* recipient_hash for Fromenance registrations. No dependencies; WebCrypto runs in Node 18+, Bun, Deno, and Workers.
*
* recipient_hash = "hmac-sha256:" + hex( HMAC-SHA256( key = tenant HMAC secret, message = canonical(address) ) )
* canonical(address): trim, keep only the part inside <> when a display name is present, lowercase,
* strip a leading "mailto:"
*
* "Jane Doe" <Jane@Bank.com > and jane@bank.com produce the same hash. The raw address never leaves your process.
*/
export function canonicalAddress(input: string): string {
let s = input.trim();
const angle = s.match(/<([^>]+)>/);
if (angle?.[1]) s = angle[1];
s = s.trim().toLowerCase();
s = s.replace(/^mailto:/, "");
return s;
}
export async function recipientHash(address: string, tenantSecret: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(tenantSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(canonicalAddress(address)));
let hex = "";
for (const b of new Uint8Array(sig)) hex += b.toString(16).padStart(2, "0");
return `hmac-sha256:${hex}`;
}
Section titled “content_fingerprint (optional, recommended)”

What the API accepts, exactly: content_fingerprint is an object with two required fields, simhash matching ^0x[0-9a-fA-F]{1,16}$ and sha256 matching ^[0-9a-f]{64}$. There is no field for the raw body and no way to send sha256 alone: send both, or omit the object. Without it a registration still verifies by code plus recipient; with it, a forward that lost its code (screenshot, heavy rewrapping) can still verify by recipient plus fingerprint, and a real footer pasted into a different message is caught as a replay.

Both hashes are computed over the same normalized text. The pipeline, which Fromenance also runs on the customer’s forward at verify time:

  1. HTML to text: drop comments, script, style, head, title, noscript, template, hidden preheaders (display:none, max-height:0, font-size:0), and images (alt text kept); keep each link’s visible text followed by its http(s) href; block tags become newlines; decode entities.
  2. Unwrap gateway rewritten links: Proofpoint URL Defense v2 and v3, Microsoft Defender rewritten links, Google redirect, Mimecast; up to four layers.
  3. Strip forward chrome: forwarded message markers, From:/Sent:/To:/Subject: header blocks, “On … wrote:” lines, quote markers, external banners, device signatures, the Fromenance footer sentence and the Reference: line, any line containing a checksum valid verify code; cut at a -- signature line.
  4. Replace every URL with the registered domain of its host (co.uk style suffixes keep three labels).
  5. Lowercase; keep only letters, digits, and @; collapse whitespace.
  6. simhash: 64 bit SimHash over word 3-gram shingles (a text with fewer than 3 words is one shingle), each shingle hashed with FNV-1a 64 over its UTF-8 bytes; bit set when the count is positive. sha256: SHA-256 hex of the normalized text. If the normalized text is empty, omit content_fingerprint.

The following file is the complete pipeline with no dependencies, and the docs test suite asserts it produces the same bits as the reference implementation on every build. Copy it as is.

samples/fingerprint.ts
/**
* Content fingerprint for Fromenance registrations. No dependencies; runs in Node 18+, Bun, Deno, and Workers.
*
* The API accepts `content_fingerprint: { simhash: "0x<16 hex>", sha256: "<64 hex>" }`. Both values are computed
* over the same normalized text, and Fromenance runs this exact pipeline on the customer's forward at verify time,
* so the bits must match. This file is a byte for byte port of the reference implementation:
*
* 1. HTML to text (scripts, styles, hidden preheaders, and images dropped; link hrefs kept as text)
* 2. Unwrap gateway rewritten links (Proofpoint v2 and v3, Microsoft Defender, Google redirect, Mimecast)
* 3. Strip forward chrome, header blocks, "wrote:" lines, device signatures, the Fromenance footer, and cut at "--"
* 4. Replace every URL with the registered domain of its host
* 5. Lowercase, keep letters, digits, and @, collapse whitespace
* 6. SimHash: 64 bit, word 3-gram shingles hashed with FNV-1a 64; SHA-256 of the same text
*
* The fingerprint is optional. Without it a registration still verifies by code plus recipient.
*/
export interface ContentFingerprint {
/** `0x` plus 16 lowercase hex characters, ready for the API. */
simhash: string;
/** 64 lowercase hex characters. */
sha256: string;
/** Number of normalized tokens. 0 means there was no usable text; omit content_fingerprint in that case. */
tokens: number;
/** The normalized text, for tests only. Never send or store it. */
normalized: string;
}
export async function fingerprintContent(input: {
html?: string | null;
text?: string | null;
}): Promise<ContentFingerprint> {
const normalized = normalizeContent(input);
const simhash = simhash64(normalized);
return {
simhash: `0x${simhash.toString(16).padStart(16, "0")}`,
sha256: await sha256Hex(normalized),
tokens: normalized.length === 0 ? 0 : normalized.split(" ").length,
normalized,
};
}
/** Subject fingerprint: SHA-256 of the subject with Fwd/Re prefixes removed and collapsed. */
export async function fingerprintSubject(subject: string): Promise<string> {
let s = subject.trim();
for (let i = 0; i < 5; i++) s = s.replace(/^\s*(fwd?|fw|re|tr|wg|aw)\s*:\s*/i, "");
return sha256Hex(collapse(s));
}
// ---- Normalization ------------------------------------------------------------------------------------------------
export function normalizeContent(input: { html?: string | null; text?: string | null }): string {
const source = input.html && input.html.trim().length > 0 ? htmlToText(input.html) : (input.text ?? "");
const unwrapped = source.replace(URL_RE, (m) => unwrapUrl(m));
const stripped = stripForwardChrome(unwrapped);
const domains = stripped.replace(URL_RE, (m) => {
const host = hostOf(m);
return host ? registeredDomain(host) : "";
});
return collapse(domains);
}
const NAMED_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
ensp: " ",
emsp: " ",
thinsp: " ",
zwnj: "",
zwj: "",
ndash: "-",
mdash: "-",
lsquo: "'",
rsquo: "'",
ldquo: '"',
rdquo: '"',
hellip: "...",
copy: "(c)",
reg: "(r)",
trade: "(tm)",
bull: "*",
middot: "*",
};
function safeFromCodePoint(cp: number): string {
try {
return String.fromCodePoint(cp);
} catch {
return "";
}
}
export function decodeEntities(input: string): string {
return input.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z]+);/g, (whole, body: string) => {
if (body.startsWith("#x") || body.startsWith("#X")) {
const cp = Number.parseInt(body.slice(2), 16);
return Number.isFinite(cp) ? safeFromCodePoint(cp) : whole;
}
if (body.startsWith("#")) {
const cp = Number.parseInt(body.slice(1), 10);
return Number.isFinite(cp) ? safeFromCodePoint(cp) : whole;
}
const named = NAMED_ENTITIES[body];
return named === undefined ? whole : named;
});
}
const BLOCK_TAGS =
"p|div|br|tr|li|h1|h2|h3|h4|h5|h6|table|blockquote|section|article|header|footer|ul|ol|pre|hr|td|th";
export function htmlToText(html: string): string {
let s = html;
s = s.replace(/<!--[\s\S]*?-->/g, " ");
s = s.replace(/<(script|style|head|title|noscript|template)[^>]*>[\s\S]*?<\/\1>/gi, " ");
// Preheader and hidden blocks that clients never show.
s = s.replace(
/<[^>]+style=("|')[^"']*(display\s*:\s*none|max-height\s*:\s*0|font-size\s*:\s*0)[^"']*\1[^>]*>[\s\S]*?<\/(div|span|p|td)>/gi,
" ",
);
// Tracking pixels and every other image: alt text is kept when it is a word, never the source.
s = s.replace(/<img[^>]*alt=("|')([^"']*)\1[^>]*>/gi, (_m, _q, alt: string) => ` ${alt} `);
s = s.replace(/<img[^>]*>/gi, " ");
// Anchors: keep the href as text so link domains flow into the normalized body, then the visible text.
s = s.replace(
/<a[^>]*href=("|')([^"']*)\1[^>]*>([\s\S]*?)<\/a>/gi,
(_m, _q, href: string, inner: string) => {
const h = href.trim();
const isHttp = /^https?:\/\//i.test(h);
return ` ${inner} ${isHttp ? h : ""} `;
},
);
s = s.replace(new RegExp(`<\\/?(${BLOCK_TAGS})\\b[^>]*>`, "gi"), "\n");
s = s.replace(/<[^>]+>/g, " ");
s = decodeEntities(s);
s = s.replace(/\r\n?/g, "\n");
return s;
}
const URL_RE = /\bhttps?:\/\/[^\s<>"'()[\]{}]+/gi;
function decodeProofpointV2(u: string): string {
let out = "";
for (let i = 0; i < u.length; i++) {
const ch = u[i] as string;
if (ch === "_") out += "/";
else if (ch === "-" && /^[0-9A-Fa-f]{2}$/.test(u.slice(i + 1, i + 3))) {
out += String.fromCharCode(Number.parseInt(u.slice(i + 1, i + 3), 16));
i += 2;
} else out += ch;
}
return out;
}
function safeDecode(v: string): string {
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
function queryParam(url: string, name: string): string | null {
const q = url.indexOf("?");
if (q < 0) return null;
const params = url.slice(q + 1).split("&");
for (const p of params) {
const eq = p.indexOf("=");
const k = eq < 0 ? p : p.slice(0, eq);
if (k === name) return eq < 0 ? "" : p.slice(eq + 1);
}
return null;
}
/** Unwrap one URL that a security gateway or mail client rewrote, up to four layers deep. */
export function unwrapUrl(url: string): string {
let current = url;
for (let i = 0; i < 4; i++) {
const next = unwrapOnce(current);
if (next === current) break;
current = next;
}
return current;
}
function unwrapOnce(url: string): string {
const lower = url.toLowerCase();
if (lower.includes("urldefense.proofpoint.com/v2/url")) {
const u = queryParam(url, "u");
if (u) return decodeProofpointV2(u);
}
if (lower.includes("urldefense.com/v3/__")) {
const start = url.indexOf("__") + 2;
const end = url.indexOf("__;", start);
if (start > 1 && end > start) return url.slice(start, end).replace(/\*/g, "");
}
if (lower.includes("safelinks.protection.outlook.com")) {
const u = queryParam(url, "url");
if (u) return safeDecode(u);
}
if (/^https?:\/\/(www\.)?google\.[a-z.]+\/url\?/i.test(url)) {
const q = queryParam(url, "q") ?? queryParam(url, "url");
if (q) return safeDecode(q);
}
if (lower.includes("mimecastprotect.com/s/") || lower.includes("mimecast.com/s/")) {
const d = queryParam(url, "domain");
if (d) return `https://${safeDecode(d)}/`;
}
return url;
}
const MULTI_PART_TLDS = new Set([
"co.uk",
"org.uk",
"ac.uk",
"gov.uk",
"com.au",
"net.au",
"org.au",
"co.nz",
"co.jp",
"com.br",
"com.mx",
"co.in",
"com.sg",
"co.za",
"com.ar",
"com.tr",
]);
/** Registered domain: last two labels, or three when the suffix is a known multi part TLD. */
export function registeredDomain(host: string): string {
const h = host.toLowerCase().replace(/\.$/, "");
const labels = h.split(".").filter(Boolean);
if (labels.length <= 2) return labels.join(".");
const lastTwo = labels.slice(-2).join(".");
if (MULTI_PART_TLDS.has(lastTwo)) return labels.slice(-3).join(".");
return lastTwo;
}
function hostOf(url: string): string | null {
const m = url.match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#:]+)/i);
return m?.[1] ? m[1].toLowerCase() : null;
}
const FORWARD_MARKERS = [
/^-{2,}\s*forwarded message\s*-{2,}$/i,
/^begin forwarded message:?$/i,
/^-{3,}\s*original message\s*-{3,}$/i,
/^_{8,}$/,
/^-{10,}$/,
/^forwarded message:?$/i,
/^\[(external|ext)\]\s*.*$/i,
/^caution:?\s+this (email|message) originated from outside/i,
/^this (email|message) (originated|was sent) from (outside|an external)/i,
];
const HEADER_LINE = /^(from|sent|to|cc|bcc|date|subject|reply-to|sent by|de|para|von|an)\s*:/i;
const WROTE_LINE = /^on\b.{6,200}\bwrote:\s*$/i;
const DEVICE_SIG = [
/^sent from my (iphone|ipad|galaxy|android|samsung|pixel|blackberry|windows phone).*$/i,
/^sent froms+(mail|outlook)s+fors+(windows|ios|android).*$/i,
/^get outlook for (ios|android).*$/i,
/^sent from yahoo mail.*$/i,
/^sent via .*$/i,
/^sent from my mobile.*$/i,
];
const FOOTER_SENTENCE =
/^(not sure|unsure|wondering) (if )?this (email|message|communication) (is|was|really).*$/i;
/**
* Remove everything a forward adds: forwarded message markers, From/Sent/To/Subject header blocks, "wrote:" lines,
* quote markers, external banners, device signatures, and the Fromenance footer. Also cuts at the "--" signature line.
*/
export function stripForwardChrome(text: string): string {
const codes = new Set(extractVerifyCodes(text));
const lines = text.replace(/\r\n?/g, "\n").split("\n");
const kept: string[] = [];
let inHeaderBlock = false;
let pendingOn: string | null = null;
let forwardStart: number | null = null;
for (const raw of lines) {
let line = raw.replace(/^\s*(?:>\s?)+/, "").trim();
if (line === "--" || line === "-- ") break;
if (pendingOn !== null) {
const joined = `${pendingOn} ${line}`;
if (WROTE_LINE.test(joined)) {
pendingOn = null;
continue;
}
kept.push(pendingOn);
pendingOn = null;
}
if (line.length === 0) {
inHeaderBlock = false;
continue;
}
if (FORWARD_MARKERS.some((re) => re.test(line))) {
inHeaderBlock = true;
if (forwardStart === null) forwardStart = kept.length;
continue;
}
if (HEADER_LINE.test(line)) {
inHeaderBlock = true;
if (forwardStart === null) forwardStart = kept.length;
continue;
}
if (inHeaderBlock && /^[A-Za-z-]{2,20}:\s/.test(line)) continue;
inHeaderBlock = false;
if (WROTE_LINE.test(line)) continue;
if (/^on\b.{6,200}$/i.test(line) && !/wrote:/i.test(line)) {
pendingOn = line;
continue;
}
if (DEVICE_SIG.some((re) => re.test(line))) continue;
if (FOOTER_SENTENCE.test(line)) continue;
if (/^reference:\s*[0-9a-z]{4}[\s-]?[0-9a-z]{4}\s*$/i.test(line)) continue;
if (codes.size > 0) {
let hasCode = false;
for (const c of extractVerifyCodes(line)) if (codes.has(c)) hasCode = true;
if (hasCode) continue;
}
line = line.replace(/\[(external|ext|caution)\]\s*/gi, "");
kept.push(line);
}
if (pendingOn !== null) kept.push(pendingOn);
const body = forwardStart === null ? kept : kept.slice(forwardStart);
return body.join("\n");
}
/** Lowercase, drop everything that is not a letter, digit, or @, collapse whitespace. */
export function collapse(text: string): string {
return text
.toLowerCase()
.replace(/[^\p{L}\p{N}@]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
// ---- Verify codes (needed to strip the footer) --------------------------------------------------------------------
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const VALUE_OF: Record<string, number> = {};
for (let i = 0; i < CROCKFORD.length; i++) VALUE_OF[CROCKFORD[i] as string] = i;
VALUE_OF.O = 0;
VALUE_OF.I = 1;
VALUE_OF.L = 1;
function checksumSymbol(first7: string): string {
let sum = 0;
for (const ch of first7) sum += VALUE_OF[ch.toUpperCase()] as number;
return CROCKFORD[sum % 32] as string;
}
/** 8 Crockford symbols with a checksum in the last position; returns null when the checksum does not validate. */
export function canonicalizeVerifyCode(input: string): string | null {
const stripped = input.replace(/[\s\-\u2013\u2014_.]/g, "").toUpperCase();
if (stripped.length !== 8) return null;
let body = "";
for (const ch of stripped) {
const v = VALUE_OF[ch];
if (v === undefined) return null;
body += CROCKFORD[v];
}
if (checksumSymbol(body.slice(0, 7)) !== body[7]) return null;
return body;
}
const CODE_RE = /(?<![0-9A-Za-z])([0-9A-Za-z]{4})[\s\-\u2013\u2014_.]?([0-9A-Za-z]{4})(?![0-9A-Za-z])/g;
export function extractVerifyCodes(text: string): string[] {
const counts = new Map<string, number>();
for (const m of text.matchAll(CODE_RE)) {
const candidate = canonicalizeVerifyCode(`${m[1]}${m[2]}`);
if (candidate) counts.set(candidate, (counts.get(candidate) ?? 0) + 1);
}
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([c]) => c);
}
// ---- Hashing --------------------------------------------------------------------------------------------------------
const U64_MASK = (1n << 64n) - 1n;
const FNV_OFFSET = 0xcbf29ce484222325n;
const FNV_PRIME = 0x100000001b3n;
/** FNV-1a 64 over the UTF-8 bytes of the input. BigInt keeps the arithmetic identical across runtimes. */
export function fnv1a64(input: string): bigint {
const bytes = new TextEncoder().encode(input);
let hash = FNV_OFFSET;
for (const b of bytes) {
hash ^= BigInt(b);
hash = (hash * FNV_PRIME) & U64_MASK;
}
return hash;
}
/** Word 3-gram shingles. Short texts fall back to a single shingle so a two word message still hashes. */
export function shingles(text: string, n = 3): string[] {
const tokens = text.split(/\s+/).filter((t) => t.length > 0);
if (tokens.length === 0) return [];
if (tokens.length < n) return [tokens.join(" ")];
const out: string[] = [];
for (let i = 0; i + n <= tokens.length; i++) out.push(tokens.slice(i, i + n).join(" "));
return out;
}
/** 64 bit SimHash over word 3-gram shingles hashed with FNV-1a 64. Returns 0n for empty input. */
export function simhash64(normalizedText: string): bigint {
const grams = shingles(normalizedText);
if (grams.length === 0) return 0n;
const counts = new Array<number>(64).fill(0);
for (const g of grams) {
const h = fnv1a64(g);
for (let bit = 0; bit < 64; bit++) {
if ((h >> BigInt(bit)) & 1n) counts[bit]! += 1;
else counts[bit]! -= 1;
}
}
let out = 0n;
for (let bit = 0; bit < 64; bit++) if (counts[bit]! > 0) out |= 1n << BigInt(bit);
return out;
}
/** Hamming distance between two 64 bit SimHashes. Fromenance matches at 6 or less by default. */
export function hamming(a: bigint, b: bigint): number {
let x = (a ^ b) & U64_MASK;
let count = 0;
while (x) {
x &= x - 1n;
count++;
}
return count;
}
export async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
let s = "";
for (const b of new Uint8Array(digest)) s += b.toString(16).padStart(2, "0");
return s;
}

subject_fingerprint is simpler: strip leading Fwd:, Fw:, Re: style prefixes, apply step 5, SHA-256 hex.

Section titled “Step 6: register at send time and place the footer”

Do. For every message, immediately before or after handing it to your sending system, make one call. Use the message id as the Idempotency-Key so a retried send registers once.

Request.

POST https://api.fromenance.com/v1/communications
Authorization: Bearer fr_test_...
Content-Type: application/json
Idempotency-Key: <c1b2@jtmerlin.example>
{
"channel": "email",
"recipient_hash": "hmac-sha256:9f2c...64 hex...",
"message_id": "<c1b2@jtmerlin.example>",
"from_address": "alerts@jtmerlin.example",
"sent_at": "2026-09-24T14:42:10Z",
"subject_fingerprint": "e3b0...64 hex...",
"content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c", "sha256": "e3b0...64 hex..." },
"link_domains": ["jtmerlin.example"],
"template_id": "new-device-alert-v2",
"campaign_id": null,
"provider": "api"
}

Required: recipient_hash, message_id, from_address, sent_at. Everything else is optional; the full field table is on Registering communications.

Read. 201:

{ "id": "com_01J...", "verify_code": "KX73-PQ9G", "expires_at": "2026-12-23T14:42:10Z", "sandbox": true }

A replay with the same key and body returns the same document with idempotent-replayed: true. 400 validation lists the failing fields in errors[]. 429 carries Retry-After (600 calls per minute per key).

Place the footer. Substitute verify_code for {{fromenance_code}} and put this text at the end of the message. The code appears twice on purpose so one copy survives rewrapping and screenshot OCR.

Not sure this email is from JT Merlin Bank? Forward it to verify@jtmerlin.example
or enter code {{fromenance_code}} at jtmerlin.example/verify. Reference: {{fromenance_code}}

For KX73-PQ9G:

Not sure this email is from JT Merlin Bank? Forward it to verify@jtmerlin.example
or enter code KX73-PQ9G at jtmerlin.example/verify. Reference: KX73-PQ9G

Use your institution name, your verify@ address, and your verify page URL (step 8), or omit the verify page clause until step 8 is done. An HTML version with the address and page as links is fine as long as the sentence and the Reference: line stay visible text.

Complete runnable samples.

samples/quickstart.ts
/**
* Quickstart: register one rendered email with plain fetch and render the footer. Nothing to install.
* Runs on Node 18+ (`npx tsx samples/quickstart.ts`) or Bun (`bun run samples/quickstart.ts`).
*
* Environment:
* FROMENANCE_API_KEY fr_test_... from Settings, API keys (scope: register)
* FROMENANCE_TENANT_SECRET the recipient hashing secret from Settings
* FROMENANCE_API_ORIGIN optional, defaults to https://api.fromenance.com
*
* The recipient address and the body are hashed in this process. Only the HMAC and the fingerprint are sent.
*/
import { fingerprintContent, fingerprintSubject } from "./fingerprint.ts";
import { recipientHash } from "./recipient-hash.ts";
export interface RegistrationResponse {
id: string;
verify_code: string;
expires_at: string;
sandbox: boolean;
}
export interface Problem {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
errors?: { path: string; message: string }[];
request_id?: string;
}
const API = (process.env.FROMENANCE_API_ORIGIN ?? "https://api.fromenance.com").replace(/\/$/, "");
/** One call at send time. Throws with the RFC 9457 problem document when the API rejects the request. */
export async function register(
apiKey: string,
body: Record<string, unknown>,
idempotencyKey: string,
): Promise<RegistrationResponse> {
const res = await fetch(`${API}/v1/communications`, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
"idempotency-key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const problem = (await res.json()) as Problem;
throw new Error(
`${problem.status} ${problem.title}: ${problem.detail ?? ""} (request ${problem.request_id})`,
);
}
return (await res.json()) as RegistrationResponse;
}
/** The footer text. The code appears twice on purpose so one copy survives rewrapping and screenshot OCR. */
export function footer(
code: string,
institution: string,
verifyAddress: string,
verifyPageUrl: string,
): string {
const page = verifyPageUrl.replace(/^https?:\/\//, "");
return [
`Not sure this email is from ${institution}? Forward it to ${verifyAddress}`,
`or enter code ${code} at ${page}. Reference: ${code}`,
].join("\n");
}
export async function main(): Promise<{ id: string; verifyCode: string; footer: string }> {
const apiKey = process.env.FROMENANCE_API_KEY;
const tenantSecret = process.env.FROMENANCE_TENANT_SECRET;
if (!apiKey || !tenantSecret) throw new Error("Set FROMENANCE_API_KEY and FROMENANCE_TENANT_SECRET");
// The email exactly as your sending system renders it, before the footer is added.
const messageId = `<${crypto.randomUUID()}@jtmerlin.example>`;
const to = "jane.doe@example.com";
const subject = "A new device signed in to your account";
const html = `<p>Hi Jane,</p>
<p>A new device signed in to your JT Merlin Bank account on September 24 at 10:42 AM.</p>
<p>If this was you, no action is needed. If not, open the app and lock your card.</p>`;
// Hash and fingerprint locally. Neither the address nor the body is sent.
const fp = await fingerprintContent({ html });
const body = {
channel: "email",
recipient_hash: await recipientHash(to, tenantSecret),
message_id: messageId,
from_address: "alerts@jtmerlin.example",
sent_at: new Date().toISOString(),
subject_fingerprint: await fingerprintSubject(subject),
content_fingerprint: fp.tokens > 0 ? { simhash: fp.simhash, sha256: fp.sha256 } : undefined,
link_domains: ["jtmerlin.example"],
template_id: "new-device-alert-v2",
provider: "api",
};
// The message id is a good Idempotency-Key: a retried send registers once.
const registration = await register(apiKey, body, messageId);
// Paste this footer into the message before it goes out.
const text = footer(
registration.verify_code,
"JT Merlin Bank",
"verify@jtmerlin.example",
"https://jtmerlin.example/verify",
);
console.log(`registered ${registration.id} with code ${registration.verify_code}`);
console.log(text);
return { id: registration.id, verifyCode: registration.verify_code, footer: text };
}
if (process.argv[1]?.endsWith("quickstart.ts")) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}

Confirm.

GET https://api.fromenance.com/v1/communications/com_01J...
Authorization: Bearer fr_test_...

200 with status: "registered", verify_code, content_fingerprint (or null), sandbox, and an empty verifications array until step 10. GET /v1/communications?limit=20 lists recent registrations.

Batch, for statement runs. Up to 1,000 items per call, 6,000 items per minute per key, per item results in input order, one bad item does not fail the batch.

POST https://api.fromenance.com/v1/communications/batch
Authorization: Bearer fr_test_...
Content-Type: application/json
Idempotency-Key: statements-2026-09-batch-0
{ "items": [ { ...registration... }, { ...registration... } ] }

Read 200 with results[] (index, ok, id, verify_code, expires_at, or error), registered, failed.

Reserve, when the code must exist before the send does (ESP merge fields). Body optional.

POST https://api.fromenance.com/v1/communications/reserve
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "recipient_hash": "hmac-sha256:...", "template_id": "fraud-alert-v3", "provider": "sendgrid", "expires_in_hours": 24 }

Read 201 with id, verify_code, expires_at. Place the code, send, then complete with the message id and send time (an ESP adapter does this for you when its event carries the code):

PATCH https://api.fromenance.com/v1/communications/com_01J...
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "message_id": "<c1b2@jtmerlin.example>", "sent_at": "2026-09-24T14:42:10Z", "content_fingerprint": { "simhash": "0x...", "sha256": "..." } }

Read 200 with status: "registered" and completed_at. An expired reservation answers 409. ESP specifics: Resend, SendGrid, Amazon SES, Postmark, Mailgun, Salesforce Marketing Cloud, Braze, Generic webhook.

Step 8 (optional): put the verify widget on your site

Section titled “Step 8 (optional): put the verify widget on your site”

Request. Create a site key bound to the origins that will host the page.

POST https://api.fromenance.com/v1/site-keys
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "name": "Public verify page", "allowed_origins": ["https://jtmerlin.example", "https://www.jtmerlin.example"], "normal_rate_per_minute": 60 }

Read. 201 with public_key (sk_pub_..., public by design), allowed_origins, embed.script, embed.iframe.

Do. Paste the script on https://jtmerlin.example/verify:

<div id="fromenance-verify"></div>
<script src="https://cdn.fromenance.com/verify.js" data-site-key="sk_pub_..." data-target="#fromenance-verify" data-institution="JT Merlin Bank" data-theme="auto" async></script>

Then set the page URL on the tenant so replies and the footer can reference it:

PATCH https://api.fromenance.com/v1/tenant
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "verify_page_url": "https://jtmerlin.example/verify", "fraud_contact": "fraud@jtmerlin.example" }

Confirm. Load the page, paste the footer text from step 6 with a registered code, enter the registered address, submit. The card reads Verified. Rate limits, Turnstile, the iframe variant, and the raw POST /v1/public/submit API are on Verify page and Verify page API.

Request.

POST https://api.fromenance.com/v1/webhook-endpoints
Authorization: Bearer fr_test_...
Content-Type: application/json
{ "url": "https://siem.jtmerlin.example/hooks/fromenance", "events": ["verdict.created", "submission.replay_detected", "indicator.new"] }

Read. 201 with id (whe_...) and secret (whsec_..., shown once).

Do. On every delivery, before parsing the body, check the signature: x-fromenance-signature is v1= plus the hex HMAC-SHA256 of <x-fromenance-timestamp>.<raw body> keyed with the secret; reject timestamps more than 5 minutes from now; compare in constant time; deduplicate on x-fromenance-delivery. Respond 2xx within 10 seconds; failures retry with backoff for about 21 hours.

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 });
}

Python and Go versions: Verify signatures.

Confirm.

POST https://api.fromenance.com/v1/webhook-endpoints/whe_.../test
Authorization: Bearer fr_test_...

A synthetic verdict.created arrives; GET /v1/webhook-endpoints/whe_.../deliveries shows it as delivered.

Step 10: verify end to end and read the verdict

Section titled “Step 10: verify end to end and read the verdict”

Do. Send a real message that carries a footer with a code from step 6 to the address you registered. From that mailbox, forward it to verify@jtmerlin.example. Within seconds a reply arrives from your reply identity (jtmerlin@reply.fromenance.com until branded replies are set up). With a test key the reply goes to the tenant’s fraud contact address instead of the forwarding mailbox.

Read. The locked verdict block in the reply, and the same verdict in the API:

GET https://api.fromenance.com/v1/submissions?limit=5
Authorization: Bearer fr_test_...

200 with data[]; each has entry (forward or web), verify_code_found, headers_present, dkim, arc, trust, and verdict with outcome, rule, replay, matched.communication_id, signals, replied_at.

Outcome Reply text Rule you should see Meaning
verified Verified: this message matches a communication we registered and sent to you on <sent_at>. code+recipient+content, code+recipient, or recipient+fingerprint The code (or the fingerprint) resolved to a registration for this recipient
not_verified Not verified: no registered communication matches this message. That does not by itself mean it is fraudulent, but treat it with caution. no_match, code:replay, code:recipient_mismatch, code+recipient:content_mismatch Nothing matched, or a real footer was replayed on a different message or by a different recipient (replay: true)
not_verified in Authoritative Mode Not verified: we did not send this message. no_match:authoritative Same as above for a tenant whose coverage has been reviewed and Authoritative Mode enabled
known_fraud Known fraud: this message matches an impersonation attempt our fraud team has confirmed. Do not interact with it. fraud_list:indicator, fraud_list:fingerprint An indicator is on your fraud list or the content matches a sample an analyst marked as fraud

Confirm the three outcomes. Forward the registered message from the registered mailbox: Verified. Forward it from a different mailbox: Not verified with replay: true. Add a domain from a phishing sample to the fraud list (POST /v1/fraud-list with { "items": [ { "kind": "domain", "value": "jtmerlin-secure-login.example" } ] }) and forward a message containing it: Known fraud. Full detail on reading submissions, overrides, and exports: Verifications.

Repeat steps 1 and 6 with an fr_live_ key, set reply_mode to branded after Branded replies, set expected_daily_volume on the tenant or the source so coverage is measured, and watch Sources and the Overview for coverage gaps.

Fromenance integration checklist (docs: https://docs.fromenance.com/getting-started/integration-guide). Base URL https://api.fromenance.com, header Authorization: Bearer <api key>, JSON bodies, RFC 9457 errors. Nothing to install.
1. Get an owner or admin login at https://app.fromenance.com. Settings, API keys: mint a key with scopes register and read; fr_test_ for the sandbox, fr_live_ for production; shown once. Copy the recipient hashing secret from Settings. Confirm: GET /v1/tenant returns slug and verify_address.
2. POST /v1/domains {"domain":"<domain>"}. Publish txt_record: name _fromenance.<domain>, type TXT, value "v=fromenance1; t=<slug>; k=<key>". POST /v1/domains/{id}/check until status is verified.
3. Create verify@<domain> on the mail platform and a rule that REDIRECTS (not forwards) it to <slug>@verify.fromenance.com. Guides: /domains-and-mail/google, /domains-and-mail/microsoft-365, /domains-and-mail/proofpoint, /domains-and-mail/mimecast, /domains-and-mail/cisco-secure-email.
4. POST /v1/domains/{id}/forwarding-test, then GET the same path every few seconds until status is working. failed: <reason> is explained at /domains-and-mail#rejection-reasons.
5. recipient_hash = "hmac-sha256:" + lowercase hex HMAC-SHA256(key = tenant secret, message = trimmed, lowercased address without display name). content_fingerprint (optional) = {"simhash":"0x<16 hex>","sha256":"<64 hex>"} over the normalized body using the pipeline and code at /getting-started/integration-guide#step-5-compute-the-recipient-hash-and-the-content-fingerprint; both fields or omit the object.
6. At send time POST /v1/communications with Idempotency-Key = message id and body {channel:"email", recipient_hash, message_id, from_address, sent_at, subject_fingerprint?, content_fingerprint?, link_domains?, template_id?, campaign_id?, provider?}. Read verify_code. Append the footer: "Not sure this email is from <Institution>? Forward it to verify@<domain> or enter code <verify_code> at <domain>/verify. Reference: <verify_code>".
7. Optional: POST /v1/communications/batch {"items":[...]} up to 1,000; POST /v1/communications/reserve to get a code before send, then PATCH /v1/communications/{id} with message_id and sent_at.
8. Optional: POST /v1/site-keys {"name","allowed_origins":[origins]}; embed <script src="https://cdn.fromenance.com/verify.js" data-site-key="sk_pub_..." data-target="#fromenance-verify" data-institution="<Institution>" async> on <domain>/verify; PATCH /v1/tenant {"verify_page_url","fraud_contact"}.
9. Optional: POST /v1/webhook-endpoints {"url","events":["verdict.created","submission.replay_detected","indicator.new"]}; verify x-fromenance-signature = "v1=" + hex HMAC-SHA256(secret, "<x-fromenance-timestamp>.<raw body>"), reject timestamps older than 5 minutes, compare in constant time, respond 2xx.
10. Send a registered message with the footer, forward it to verify@<domain>, read the reply and GET /v1/submissions. Verified = matches a registration for this recipient; Not verified = no match or a replayed footer (replay:true); Known fraud = fraud list hit.
More: https://docs.fromenance.com/llms.txt and https://fromenance.com/llms.txt