# Fromenance docs: full text Source: https://docs.fromenance.com. Generated from the built pages at build time, in sidebar order. The integration guide is https://docs.fromenance.com/getting-started/integration-guide. --- Page: Integration guide, end to end URL: https://docs.fromenance.com/getting-started/integration-guide # 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 , 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. Collect these values first; every step below refers to them by name. | Name | What it is | Where it comes from | | FROMENANCE_API_KEY | fr_test_... or fr_live_... | step 1, shown once | | FROMENANCE_TENANT_SECRET | the recipient hashing secret | step 1, Settings | | DOMAIN | the institution’s sending domain, for example jtmerlin.example | you | | SLUG | the tenant slug, for example jtmerlin | GET /v1/tenant | | DNS access | a way to add one TXT record on DOMAIN (dashboard or API credential) | you | | Mail platform admin | to create verify@DOMAIN and one redirect rule | you | Order matters only where stated: step 2 (TXT) must be verified before step 4 (forwarding test), and step 6 works as soon as step 1 is done. A plain text copy of this whole guide is served at /integration-guide.md for tools that prefer Markdown. ## Step 1: get a login and create an API key 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). ```http 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. ```http 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 Do. Register the domain that will host verify@ and that your outbound mail is signed with. Request. ```http 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 : ```json { "name": "_fromenance.jtmerlin.example", "type": "TXT", "value": "v=fromenance1; t=jtmerlin; k=0123456789abcdef0123456789abcdef" } ``` Publish exactly that record at your DNS provider: name _fromenance. , type TXT, value v=fromenance1; t=; k= 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): ```bash 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: ```bash 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 : ```http 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 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. ## Step 4: run the forwarding test Request. ```http 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: ```http 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: ; 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 Two values are computed on your side for every message. Neither the address nor the body is ever sent. ### recipient_hash (required) ```text 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. - TypeScript (WebCrypto) - Python (hmac) - Shell (openssl) ```ts 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" 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 { 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}`; } ``` ### 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: - 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. - Unwrap gateway rewritten links: Proofpoint URL Defense v2 and v3, Microsoft Defender rewritten links, Google redirect, Mimecast; up to four layers. - 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. - Replace every URL with the registered domain of its host (co.uk style suffixes keep three labels). - Lowercase; keep only letters, digits, and @ ; collapse whitespace. - 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. ```ts 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 { 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 { 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 = { 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(//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(/]*alt=("|')([^"']*)\1[^>]*>/gi, (_m, _q, alt: string) => ` ${alt} `); s = s.replace(/]*>/gi, " "); // Anchors: keep the href as text so link domains flow into the normalized body, then the visible text. s = s.replace( /]*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 = {}; 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 = /(?(); 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(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 { 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. ## 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. ```http POST https://api.fromenance.com/v1/communications Authorization: Bearer fr_test_... Content-Type: application/json Idempotency-Key: { "channel": "email", "recipient_hash": "hmac-sha256:9f2c...64 hex...", "message_id": "", "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 : ```json { "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. ```text 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 : ```text 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. - TypeScript (fetch) - Python (urllib) ```ts 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, idempotencyKey: string, ): Promise { 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 = `

Hi Jane,

A new device signed in to your JT Merlin Bank account on September 24 at 10:42 AM.

If this was you, no action is needed. If not, open the app and lock your card.

`; // 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. ```http 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. ## Step 7 (optional): batch and reserve 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. ```http 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. ```http 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): ```http PATCH https://api.fromenance.com/v1/communications/com_01J... Authorization: Bearer fr_test_... Content-Type: application/json { "message_id": "", "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 Request. Create a site key bound to the origins that will host the page. ```http 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 : ```html
``` Then set the page URL on the tenant so replies and the footer can reference it: ```http 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 . ## Step 9 (optional): receive webhooks Request. ```http 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 . 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. ```ts 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=." 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 { 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. ```http 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 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: ```http 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 . | 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 . ## Going live 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. ## Copy for your agent ```text Fromenance integration checklist (docs: https://docs.fromenance.com/getting-started/integration-guide). Base URL https://api.fromenance.com, header Authorization: Bearer , 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":""}. Publish txt_record: name _fromenance., type TXT, value "v=fromenance1; t=; k=". POST /v1/domains/{id}/check until status is verified. 3. Create verify@ on the mail platform and a rule that REDIRECTS (not forwards) it to @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: 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 ? Forward it to verify@ or enter code at /verify. Reference: ". 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 ``` Attributes: | Attribute | Notes | | data-site-key | Required | | data-target | CSS selector of the container; a div is inserted before the script when omitted | | data-institution | The name shown in “Did this message come from JT Merlin Bank?” | | data-theme | light , dark , or auto (follows the visitor’s system setting) | | data-accent | Button and link colour, for example #0f4c81 . The verdict card also picks up the accent set in Settings, Branding | | data-turnstile | Your Cloudflare Turnstile site key, needed when the key is or can become Turnstile gated | | data-api | Only for staging; defaults to https://api.fromenance.com | The script also exposes window.Fromenance.mount({ siteKey, target, institutionName, theme, accent, turnstileSiteKey, onVerdict }) for single page applications, and window.Fromenance.version . Iframe ```html ``` Query parameters mirror the data attributes: key , name , theme , accent , turnstile , api . The iframe posts two messages to the parent window: { type: "fromenance:height", height } whenever its content height changes, so you can resize it, and { type: "fromenance:verdict", verdict } after each answer. The allowed origin check applies to the page that embeds the iframe, so add your site’s origin to the site key, not cdn.fromenance.com . ## What the customer sees A message textarea, an optional reference code field, an optional field for the address that received the message (recommended, since the recipient is half of the strongest match), and file inputs for an .eml or a screenshot (PNG, JPEG, WebP, up to 6 MB). Text and .eml answer in under three seconds; screenshots show “Reading the screenshot” while OCR runs and answer within about fifteen seconds. The verdict card carries your logo, the outcome label (Verified, Not verified, Known fraud), the locked verdict text, a next step that includes your fraud contact, and a link to your support URL. Outcomes use the same three colours as the admin app and nothing else in the widget uses them. ## Handling the verdict on the page Pass onVerdict to mount() or listen for the iframe message to record analytics or route the customer: ```js window.Fromenance.mount({ siteKey: "sk_pub_...", target: document.querySelector("#fromenance-verify"), institutionName: "JT Merlin Bank", onVerdict: (v) => { analytics.track("verify_result", { outcome: v.outcome, rule: v.rule, submission: v.submission_id }); if (v.outcome === "known_fraud") location.assign("/security/report-fraud"); }, }); ``` The verdict object is the same one the verify page API returns. ## Rate limits and Turnstile - 60 submissions per minute per IP address and 600 per minute per site key. Over the limit the API answers 429 with Retry-After and the widget shows “Too many checks right now”. - When a site key exceeds its normal_rate_per_minute , or has turnstile_required set, the API answers 428 turnstile-required . The widget then loads Cloudflare Turnstile with the site key from data-turnstile , and resubmits with the token once the challenge passes. Without data-turnstile the customer sees “Please complete the security check” and cannot continue, so set it if your page can see bursts. ## Privacy on the web path The address the customer types is hashed with your tenant secret to compare against registrations, stored encrypted only so the submission can be linked to a later forward, and purged 30 days after the verdict. Uploaded .eml files and screenshots are stored encrypted under your tenant prefix and follow your retention setting. The widget never sets cookies. --- Page: Verify page API URL: https://docs.fromenance.com/verify-page/api # Verify page API Fromenance is a communication provenance platform, and POST /v1/public/submit is the endpoint the verify widget calls. Use it directly when you build your own verify UI, a mobile app flow, or a chat bot step on your domain. By the end you will be able to submit each of the four inputs, read every field of the response, poll for a screenshot verdict, and handle the two non 2xx cases. ## Authentication Two things authenticate a call: the header X-Site-Key: sk_pub_... and the request Origin , which must be in the key’s allowed origins. There is no secret. Browsers set Origin automatically; from a server or a native app set it to one of the allowed origins yourself, and treat the key as public. ## Submit POST https://api.fromenance.com/v1/public/submit ```json { "text": "…the pasted message, plain text or HTML…", "eml_base64": "…base64 of a .eml file…", "image_base64": "…base64 of a PNG, JPEG, or WebP…", "code": "KX73-PQ9G", "email": "jane.doe@example.com", "turnstile_token": "…" } ``` | Field | Limit | Notes | | text | 200,000 characters | The message as the customer pasted it. HTML is fine | | eml_base64 | 2,000,000 characters | A full .eml with headers. Best input: headers let Fromenance check DKIM for your sending domains and read the innermost To | | image_base64 | 8,000,000 characters (6 MB decoded) | A screenshot. OCR runs asynchronously; only the extracted text feeds the deterministic extractors | | code | 12 characters | A reference code the customer typed. Combined with text or .eml when both are present | | email | | The address that received the message. Hashed with the tenant secret to match recipient_hash ; the encrypted copy is purged after 30 days | | turnstile_token | 4,000 characters | Required after a 428 | At least one of text , eml_base64 , image_base64 , or code is required. When eml_base64 is present it wins over text ; when only image_base64 is present the response is 202 with a poll token. ## Response: PublicVerdict 200 for text, .eml , and code. 202 for screenshots. ```json { "status": "decided", "poll_token": null, "submission_id": "sub_01J...", "outcome": "verified", "rule": "code+recipient+content", "matched_sent_at": "2026-09-24T14:42:10.000Z", "signals": { "code_found": true, "recipient_match": true, "fingerprint_distance": 2 }, "display": { "institution_name": "JT Merlin Bank", "logo_url": "https://jtmerlin.example/logo.png", "accent": "#0f4c81", "fraud_contact": "fraud@jtmerlin.example", "support_url": "https://jtmerlin.example/security", "outcome_label": "Verified", "verdict_text": "Verified: this message matches a communication we registered and sent to you on September 24, 2026 at 2:42 PM UTC.", "next_step": "You can act on it. If anything still looks wrong, contact us at fraud@jtmerlin.example." } } ``` | Field | Notes | | status | decided or pending | | poll_token | Set while pending ; pass it to the verdict endpoint | | submission_id | sub_... , the same id the admin app shows | | outcome | verified , not_verified , known_fraud , or null while pending | | rule | The matching rule, see Concepts | | matched_sent_at | When the matched registration was sent, on Verified | | signals | code_found , recipient_match (null when no address was given), fingerprint_distance (null when no body on either side) | | display | Everything needed to render the answer in your branding. verdict_text is the locked block and next_step already contains your fraud contact | Repeat submissions of the same message by the same customer return the same verdict and increment the count on the existing submission rather than creating a new one. ## Poll for a screenshot GET https://api.fromenance.com/v1/public/verdict/{poll_token} with the same X-Site-Key and Origin . Returns the same shape; status becomes decided once OCR and matching finish, usually within 15 seconds. If the image could not be read the endpoint answers 502 upstream with the detail “We could not read that screenshot. Paste the message text instead.” The widget polls every 1.5 seconds for up to 60 seconds. ## Errors | Status | Type | Meaning | | 400 bad-request | | No input, or an image that is not PNG, JPEG, WebP, or GIF | | 401 unauthorized | | Missing or revoked site key, or an Origin not on the allow list | | 413 payload-too-large | | Screenshot over 6 MB | | 428 turnstile-required | | The key is over its normal rate or has Turnstile required. Render Cloudflare Turnstile with your Turnstile site key and resend with turnstile_token | | 429 rate-limited | | 60 per minute per IP or 600 per minute per site key. Retry-After is 60 seconds for the IP limit and 10 for the key limit | Errors are RFC 9457 problem documents; see Errors . ## Sample ```ts samples/public-submit.ts /** * Call the public verify API directly, the same way the embeddable widget does. * Authenticated by the site key (sk_pub_...) and the page origin. No secret is involved. * * Environment: * FROMENANCE_SITE_KEY a site key whose allowed origins include the Origin you send * FROMENANCE_ORIGIN the page origin the site key allows, for example https://jtmerlin.example * FROMENANCE_API_ORIGIN optional, defaults to https://api.fromenance.com */ export interface PublicVerdict { status: "decided" | "pending"; poll_token: string | null; submission_id: string; outcome: "verified" | "not_verified" | "known_fraud" | null; rule: string | null; matched_sent_at: string | null; signals: { code_found: boolean; recipient_match: boolean | null; fingerprint_distance: number | null; } | null; display: { institution_name: string; logo_url: string | null; accent: string | null; fraud_contact: string | null; support_url: string | null; outcome_label: string; verdict_text: string; next_step: string; } | null; } export interface SubmitInput { text?: string; eml_base64?: string; image_base64?: string; code?: string; email?: string; turnstile_token?: string; } export interface PublicClientOptions { siteKey: string; origin: string; apiOrigin?: string; fetch?: typeof fetch; } export async function submit(opts: PublicClientOptions, input: SubmitInput): Promise { const api = (opts.apiOrigin ?? "https://api.fromenance.com").replace(/\/$/, ""); const f = opts.fetch ?? fetch; const res = await f(`${api}/v1/public/submit`, { method: "POST", headers: { "content-type": "application/json", "x-site-key": opts.siteKey, origin: opts.origin }, body: JSON.stringify(input), }); if (res.status === 428) throw new Error("Turnstile required: render the challenge and resend with turnstile_token"); if (res.status === 429) throw new Error(`Rate limited, retry after ${res.headers.get("retry-after")} seconds`); if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`); return (await res.json()) as PublicVerdict; } /** Screenshots answer 202 with a poll token. Poll until status is decided; the widget polls every 1.5 s for 60 s. */ export async function pollVerdict(opts: PublicClientOptions, token: string): Promise { const api = (opts.apiOrigin ?? "https://api.fromenance.com").replace(/\/$/, ""); const f = opts.fetch ?? fetch; for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 1500)); const res = await f(`${api}/v1/public/verdict/${token}`, { headers: { "x-site-key": opts.siteKey, origin: opts.origin }, }); if (!res.ok) throw new Error(`poll failed: ${res.status}`); const v = (await res.json()) as PublicVerdict; if (v.status === "decided") return v; } throw new Error("screenshot verdict took longer than 60 seconds"); } export async function main(): Promise { const siteKey = process.env.FROMENANCE_SITE_KEY; const origin = process.env.FROMENANCE_ORIGIN; if (!siteKey || !origin) throw new Error("Set FROMENANCE_SITE_KEY and FROMENANCE_ORIGIN"); const opts: PublicClientOptions = { siteKey, origin, apiOrigin: process.env.FROMENANCE_API_ORIGIN }; const verdict = await submit(opts, { text: "Not sure this email is from JT Merlin Bank? Reference: KX73-PQ9G", email: "jane.doe@example.com", }); const decided = verdict.status === "pending" && verdict.poll_token ? await pollVerdict(opts, verdict.poll_token) : verdict; console.log(decided.display?.outcome_label, decided.rule, decided.display?.verdict_text); } ``` --- Page: Verifications URL: https://docs.fromenance.com/verifications # Verifications Fromenance is a communication provenance platform, and every customer question ends up as a submission with a verdict. This page is for the analyst who works the Submissions and Indicators screens and for the engineer who pulls the same data through the API. By the end you will be able to read a submission end to end, override a verdict and know what that changes, and export indicators to your SIEM or takedown vendor. ## Submissions list Admin app: Submissions. API: GET /v1/submissions (scope read ), cursor paginated with cursor and limit , returning { data, next_cursor } . | Filter | Values | | outcome | verified , not_verified , known_fraud (an override counts as its override outcome) | | entry | forward , web , api | | replay | true or false | | has_headers | true when the customer forwarded as an attachment and original headers were present | | from , to | ISO 8601 bounds on received_at | Each row carries entry , status , received_at , processed_at , count (how many times the same customer sent the same message), verify_code_found , submitter_hash , recipient_hash_found , headers_present , dkim , arc , trust (dkim:jtmerlin.example on the forward path), from_domain , imitated_template_id (the nearest registered template by fingerprint on Not verified), campaign_id , sandbox , the verdict , and any override . Global search in the top bar finds submissions by code, address hash, domain, or indicator. ## Submission detail Admin app: click a row to open the detail drawer. API: GET /v1/submissions/{id} adds: - subject and preview_text : the forwarded message as plain text with every link replaced by its bare domain. The admin app renders this as a preview with links disabled; nothing in it is clickable and no remote content loads. - headers_summary : the Received chain, DKIM, ARC, SPF, and trust results. - indicators : each with kind , value , on_fraud_list , and submission_count across your tenant, with one click Add to fraud list . - raw_available : whether the raw MIME is still within retention. Download raw (GET /v1/submissions/{id}/raw ) is limited to analysts, admins, and owners, and every download is written to the audit log. ## Verdict and signals The verdict block shows the outcome, the rule that produced it, replay , authoritative , and the matched registration (communication_id , sent_at , template_id ) when there is one. signals explains the rule: code_found , recipient_match , fingerprint_distance , headers_present , dkim . Rules are listed in Concepts . Two things to look at first on a Not verified: whether code_found is true (a replay or a coverage gap on the matched template) and imitated_template_id (which of your templates the lure copies). ## Overrides Analysts, admins, and owners can override any verdict. Admin app: Override in the detail drawer with a note. API: POST /v1/submissions/{id}/override with { "outcome": "fraud" | "legitimate", "note": "..." } . | Override | What changes | | fraud | The verdict shows Known fraud. Every indicator on the submission is added to the fraud list, and its fingerprint is recorded so later submissions with the same content resolve to Known fraud by fraud_list:fingerprint without a lookup. Overriding a Verified verdict to fraud is treated as an incident and alerts Fromenance operations | | legitimate | The verdict shows Verified. A registration is created retroactively (retro: true ) for the matched recipient and fingerprint, and a coverage gap is flagged on the source that should have registered it | Overrides are recorded on the verdict (override.outcome , by , at , note ) and in the audit log. The original outcome and rule are kept. ## Indicators Admin app: Indicators, grouped by kind with first and last seen, submission count, priority, and fraud list status, with bulk Add to fraud list . API: GET /v1/indicators with filters kind (url , domain , email , phone , ip ), q , on_fraud_list , since , priority (low , normal , high ). Indicators are extracted from every submission, including Verified ones at low priority, and skip your own domains. Replays raise the priority of their indicators to high. A new indicator on a non verified submission fires the indicator.new webhook . ## Exports Admin app: Export CSV or Export STIX on Indicators, and CSV on every table. API: GET /v1/indicators/export with the same filters and either Accept: text/csv or Accept: application/stix+json (or ?format=csv|stix ). - CSV columns: kind,value,first_seen,last_seen,submission_count,priority,on_fraud_list , served as an attachment named fromenance-indicators-.csv . - STIX 2.1: a bundle of indicator objects with STIX patterns ([url:value = '...'] , [domain-name:value = '...'] , [email-addr:value = '...'] , [ipv4-addr:value = '...'] , [x-phone-number:value = '...'] ), valid_from set to first seen, and labels carrying the kind and fraud list status. Import it into your TIP or SIEM as any other STIX feed. ## Fraud list Admin app: Fraud list. API: GET /v1/fraud-list , POST /v1/fraud-list with { "items": [ { "kind": "domain", "value": "jtmerlin-secure-login.example", "note": "campaign 2026-09-12" } ] } (up to 500 per call), DELETE /v1/fraud-list/{id} . Entries come only from analyst action: a manual add, or an override to fraud. Nothing is added automatically from a single submission, because anyone can forward anything through your rule, including attempts to poison the list. Each entry records source (analyst , campaign , feed ), who added it, the note, and the submission it came from. A submission whose extracted indicators hit the list resolves to Known fraud by fraud_list:indicator before any other rule runs. ## Overview numbers The Overview screen and GET /v1/stats give daily counts of registered, submitted, by outcome, by entry, by source, replays, coverage gaps, code found rate, reply p95, and the top indicators of the week. Coverage per source is registered volume against the expected daily volume you set on the source or the tenant. --- Page: Webhooks URL: https://docs.fromenance.com/webhooks # Webhooks Fromenance is a communication provenance platform, and outbound webhooks push every verdict, replay, and new indicator to your own systems within seconds. Each delivery is signed with a per endpoint secret, retried with backoff for about a day, and replayable from the admin app. By the end you will have an endpoint subscribed to the events you need, know the envelope and headers of each delivery, and know how retries and replays behave. ## Events | Event | Fires | data | | verdict.created | Once per verdict, after matching and before the reply is sent | The verdict object: submission_id , outcome , rule , replay , authoritative , matched (communication_id , sent_at , template_id ), signals , indicators (kind , value ), replied_at , created_at , plus tenant_id | | submission.replay_detected | When a verdict is flagged as a footer replay, in addition to verdict.created | submission_id , tenant_id , rule , code | | indicator.new | The first time an indicator is seen on a non verified submission | tenant_id , indicator (id , kind , value , first_seen , submission_id , priority ) | | campaign.detected | Phase 2, when a cluster becomes a campaign | Subscribable now; nothing is sent until the intelligence tier ships | The list of events is also returned as events on GET /v1/webhook-endpoints . ## Create an endpoint Admin app: Webhooks, Add endpoint , URL, events. Or: ```bash curl -X POST https://api.fromenance.com/v1/webhook-endpoints \ -H "Authorization: Bearer fr_live_..." \ -H "Content-Type: application/json" \ -d '{"url":"https://siem.jtmerlin.example/hooks/fromenance","events":["verdict.created","submission.replay_detected","indicator.new"],"description":"Splunk HEC relay"}' ``` The response includes secret (whsec_... ), shown once and encrypted at rest with your tenant key. Endpoints default to verdict.created only. PATCH changes the URL, events, description, or status (active , paused ); DELETE removes it. A paused endpoint fails pending deliveries with “endpoint disabled” rather than queueing them. ## Delivery Each delivery is an HTTPS POST with a JSON body: ```json { "id": "evt_01J...", "type": "verdict.created", "created_at": "2026-09-26T16:01:04.221Z", "data": { "submission_id": "sub_01J...", "outcome": "not_verified", "rule": "code:replay", "replay": true, "...": "..." } } ``` Headers: | Header | Value | | content-type | application/json | | user-agent | fromenance-webhooks/1.0 | | x-fromenance-event | The event type | | x-fromenance-delivery | whd_... , the delivery id. The same id is reused on every retry of one delivery; a replay gets a new id with replay_of pointing at the original | | x-fromenance-timestamp | Unix seconds when the request was signed | | x-fromenance-signature | v1=."> keyed with the endpoint secret | Respond with any 2xx within 10 seconds. Anything else, a timeout, or a connection error counts as a failed attempt. Verify the signature on every request before parsing the body; samples in TypeScript, Python, and Go are on Verify signatures . ## Retries Failed deliveries are retried with backoff: after 1 minute, then 5, 15, and 30 minutes, then 1, 1, 2, 2, 4, 4, and 6 hours, for 12 attempts in total over roughly 21 hours. After the last failure the delivery is marked failed and stays in the log. Deliveries to different endpoints are independent; a slow endpoint never delays another. Because retries resend the same payload with the same x-fromenance-delivery , make your handler idempotent on that header or on id in the body. ## Delivery log and replay Admin app: Webhooks, the endpoint, Deliveries shows every delivery with status (pending , retrying , delivered , failed ), attempts , last_status_code , last_error , next_attempt_at , delivered_at , and the payload. API: GET /v1/webhook-endpoints/{id}/deliveries . Replay re-sends any delivery, delivered or failed, as a new delivery with replay_of set: POST /v1/webhook-endpoints/{id}/deliveries/{delivery_id}/replay . Use it after an outage on your side, or to backfill a new SIEM index. Send test (POST /v1/webhook-endpoints/{id}/test ) delivers a synthetic verdict.created so you can confirm signature verification before real traffic. ## Payload notes - Payloads never contain the customer’s address, the forwarded message, or its links. Indicators are the extracted values (a phishing domain, a phone number), which is what your SIEM needs. - signals on verdict.created is an open object; new keys may be added. Do not fail on unknown keys. - Verdicts on sandbox submissions (from fr_test_ keys) are delivered like any other, so you can test your pipeline without live traffic. Use Send test for a synthetic event with a known shape. --- Page: Verify signatures URL: https://docs.fromenance.com/webhooks/verify-signatures # 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 | 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: ```text 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-timestamp is 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-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. ## Samples Each sample is a complete file from the docs repository and is compiled or executed by the docs test suite. - TypeScript - Python - Go ```ts 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=." 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 { 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 }); } ``` ## Testing your handler - Create the endpoint and store the secret. - Webhooks, the endpoint, Send test (POST /v1/webhook-endpoints/{id}/test ). A synthetic verdict.created arrives within a second. - 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. - Change one character of the secret in your handler and replay again: the delivery must now fail. Restore it. ## 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. --- Page: Admin URL: https://docs.fromenance.com/admin # Admin Fromenance is a communication provenance platform administered from app.fromenance.com . This page covers roles and the map of the admin app; the two pages after it cover users and keys, and retention and privacy. By the end you will know which role to give each person on your team and where every setting is. ## Roles Every user in a tenant has exactly one role. Sign in is by email magic link or passkey; there are no passwords. | Role | Intended for | Can | | Owner | The accountable executive or the tenant creator | Everything an admin can, plus billing changes and deleting the tenant | | Admin | Security or IT lead who owns the account | Settings, users and roles, API keys, domains, sources, reply templates, webhooks, submissions, overrides, fraud list, indicators, exports, audit log, billing (read) | | Analyst | Fraud or security team member working incidents | Submissions and their detail, raw MIME download, overrides, fraud list, indicators, exports, registrations, dashboards, webhook delivery logs (read), audit log (read) | | Integrator | The developer or ESP admin wiring registration in | API keys, domains, sources, reply templates, webhooks, registrations and coverage, dashboards, test tools. No submission content | | Viewer | Anyone who needs the numbers | Dashboards, indicators, registrations, and read only lists | Permissions by area: | Area | Owner | Admin | Analyst | Integrator | Viewer | | Tenant settings, branding, retention | yes | yes | | | | | Users and roles | yes | yes | | | | | API keys | yes | yes | | yes | | | Domains and reply identity | yes | yes | | yes | | | Sending sources | yes | yes | | yes | | | Reply templates | yes | yes | | yes | | | Webhook endpoints (write) | yes | yes | | yes | | | Webhook delivery log (read) | yes | yes | yes | yes | yes | | Submissions, detail, raw MIME | yes | yes | yes | | | | Overrides | yes | yes | yes | | | | Fraud list (write) | yes | yes | yes | | | | Indicators and exports | yes | yes | yes | | yes | | Registrations and coverage | yes | yes | yes | yes | yes | | Dashboards and stats | yes | yes | yes | yes | yes | | Audit log | yes | yes | yes | | | | Billing | yes | read | | | | | Delete tenant | yes | | | | | The same permission set applies to API keys: a key with the admin scope acts as an admin, read as a viewer plus submissions, and register only registers. See Users and keys . ## Where things live | Screen | What is there | | Overview | Today and 30 days: submissions by outcome, registrations by source, reply p95, replay attempts, coverage gap count, top indicators | | Submissions | The main table with filters and the detail drawer. See Verifications | | Indicators | Extracted indicators with fraud list status, bulk add, CSV and STIX export | | Fraud list | The tenant block list with source and history | | Campaigns | Placeholder until the intelligence tier ships | | Registrations | Search by code, message id, template, and date; coverage per source against expected volume | | Sources | Sending sources with health, webhook URL, secret rotation, adapter instructions, and the test event button | | Domains | Domains with TXT status and the copyable record, re-check, role toggles, reply identity setup | | Verify page | Site keys, allowed origins, embed snippets, live widget preview | | Reply templates | One MJML template per outcome with the locked verdict block, preview with sample data, send test | | Webhooks | Endpoints, event selection, delivery log with replay, send test | | Settings | Profile, branding (logo, accent, display name, support URL, fraud contact), retention days, Authoritative Mode (read only), users and roles, API keys, audit log, billing | | Setup | The six step wizard, re-enterable from Settings | ## Audit log Every write in the admin app and API is recorded with the actor (user or API key), action, target, and before and after values: key minted or revoked, user invited or role changed, domain added, template saved, override recorded, raw MIME downloaded, fraud list entry added. Settings, Audit log lists it; GET /v1/audit returns it with cursor pagination. --- Page: Users and keys URL: https://docs.fromenance.com/admin/users-and-keys # Users and keys Fromenance is a communication provenance platform, and access to a tenant is through user accounts with roles and through scoped API keys. This page covers both. By the end you will have invited your team with the right roles and minted keys with the smallest scope that does the job. ## Users Settings, Users and roles, Invite . Or POST /v1/users with { "email": "...", "role": "analyst", "name": "..." } . The invitee gets a magic link; on first sign in they can register a passkey. Roles are described on Admin . - Change a role with PATCH /v1/users/{id} ; remove a user with DELETE /v1/users/{id} . Both need the admin or owner role and are audited. - A tenant must keep at least one owner. - The same person can belong to several tenants (for example a managed security provider). The tenant switcher in the top bar changes context; GET /v1/me lists memberships. - Single sign on (SAML and OIDC) is planned for a later release. ## API keys Settings, API keys, Mint key . Or: ```bash curl -X POST https://api.fromenance.com/v1/api-keys \ -H "Authorization: Bearer fr_live_..." \ -H "Content-Type: application/json" \ -d '{"name":"statements-batch","mode":"live","scopes":["register"]}' ``` | Property | Notes | | Format | fr_live_ or fr_test_ followed by 40 hex characters. Only the first 12 characters (prefix ) and a SHA-256 hash are stored; the secret is shown once | | mode | live writes to production. test writes to a sandbox partition: registrations and submissions are marked sandbox: true and replies go to your fraud contact address, never to a customer | | scopes | One or more of register , read , admin | | last_used_at | Updated on use, visible in the list | | Revoke | DELETE /v1/api-keys/{id} . Takes effect within a minute at the edge | Scopes map to endpoint groups: | Scope | Allows | | register | POST /v1/communications , /batch , /reserve , PATCH /v1/communications/{id} | | read | GET on communications, submissions, indicators, fraud list, stats, search, webhook delivery logs, and the tenant | | admin | Everything an admin user can do: users, keys, domains, sources, templates, webhooks, overrides, fraud list writes, site keys, audit log | Give your sending system a register key and nothing else. Give your SIEM integration a read key. Reserve admin keys for provisioning scripts and rotate them when the script is done. Use the key as a bearer token: Authorization: Bearer fr_live_... . Rate limits are per key: 600 requests per minute on register and reserve, 6,000 items per minute on batch. ## The tenant secret Recipient hashing uses a second secret, issued with your tenant and separate from every API key. The samples read it as FROMENANCE_TENANT_SECRET . It never travels to the API. Store it with the same care as a live key, since anyone holding it can compute the recipient hash for an address. Ask your Fromenance contact if you need it re-issued; re-issuing invalidates the recipient hashes on every existing registration, so it is done with a re-registration plan. ## Site keys Site keys (sk_pub_... ) authenticate the verify widget and are public by design; they are bound to allowed origins rather than kept secret. Verify page, New site key , or POST /v1/site-keys . Revoke with DELETE /v1/site-keys/{id} . Details on Verify page . ## Sessions The admin app uses a session cookie (fromenance.session_token ) issued by magic link or passkey sign in. Sessions are bound to the browser and expire on sign out. API keys, not sessions, are the right credential for anything automated. ## Signing in with Identizen The admin app and the backoffice accept three ways in: an emailed magic link, a passkey registered under Settings, and Identizen, the accountless phone login. Identizen is a standard OpenID Connect provider: your phone approves the login, Fromenance receives a per site identifier and never your email or phone number, and nothing at Identizen can sign in as you. Because the identifier carries no email, the first time a phone signs in Fromenance does not yet know which member it is. It asks once: enter the address your institution invited, open the link that arrives, and the phone is attached to your account. From then on, Continue with Identizen is a single approval on the phone. You can also attach or detach a phone from Settings, Profile (backoffice: the Account page). The magic link keeps working as the fallback. --- Page: Retention and privacy URL: https://docs.fromenance.com/admin/retention-and-privacy # Retention and privacy Fromenance is a communication provenance platform that handles customer mail on your behalf, so what it keeps and for how long is a setting you control and a list you can read here. This page is written for the person answering your vendor security questionnaire. By the end you will know every stored field, every retention clock, and the four things that are never stored. ## Retention setting Settings, Retention, or PATCH /v1/tenant with retention_days (7 to 730, default 90). It drives: | Data | Lifetime | | Registrations | Expire at expires_at , which defaults to send time plus retention_days . Expired registrations no longer match; a registration you pass an explicit expires_at for keeps that value | | Raw MIME of forwarded messages and uploaded .eml files | Deleted after retention_days , with a deletion record in the audit log | | Screenshots | Same as raw MIME | | Submissions, verdicts, indicators | Kept for the life of the tenant; they hold hashes, results, and extracted indicators, not message content | Independent of the setting: | Data | Lifetime | | Submitter address (the customer who forwarded or typed their address) | Encrypted with your tenant key, kept only to send the reply and link repeat submissions, purged 30 days after the reply | | Subject of a forwarded message | Encrypted with your tenant key, follows the raw MIME lifetime | | Rejected inbound (mail that failed the trust check) | 24 hours, then deleted | | Idempotency keys and stored responses | 24 hours | | Uncompleted reservations | 24 hours by default (1 to 72 per call), then marked expired | | Webhook deliveries and payloads | Kept in the delivery log for the life of the tenant. Payloads contain verdicts and indicators, never message content | | Audit log | Kept for the life of the tenant | ## What is stored Per registration: the recipient hash, message id, From address, send time, subject and content fingerprints (one way hashes), link domains, your template and campaign ids, the provider, the verify code, and expiry. Per submission: entry point, timestamps, the code found, the submitter hash, the recipient hash found in the innermost To header, the content fingerprint, whether headers were present, DKIM, ARC, and trust results, the sending domain, the raw MIME key (until deletion), the encrypted submitter address (until purge), and the extracted indicators. Per verdict: outcome, rule, confidence, signals, the matched registration id, reply time, and any override with its note. ## What is never stored - A raw recipient address on a registration. The API rejects anything that is not already an HMAC; you hash locally before the call. The column does not exist in the schema. - The normalized text used for fingerprinting. It exists in memory for the duration of one call and is discarded; only the SimHash and SHA-256 are kept. - Quoted message content in replies. Replies never include the suspicious message, its links, or its sender. The verdict block is locked text. - Your message bodies from the API path. register() sends fingerprints. Only the journal path and ESP body retrieval see a body, and then only to fingerprint it before it is discarded. ## Encryption Each tenant has its own data key. Encrypted columns (submitter addresses, subjects, source configuration, webhook and source secrets, the recipient hashing secret) are AES-256-GCM ciphertext under that key. Raw MIME and screenshots in object storage are encrypted under the tenant key as well and stored under a per tenant prefix. Keys are wrapped by a master key that lives only in the runtime’s secret store. See Security . ## Exporting and deleting - Indicators export as CSV or STIX 2.1 from the admin app or GET /v1/indicators/export ; every table exports CSV. - Raw MIME is downloadable by analysts, admins, and owners while within retention, and every download is audited. - Deleting the tenant (owner only) removes registrations, submissions, verdicts, indicators, raw objects, and keys; only the audit record of the deletion remains. Ask your Fromenance contact for a signed deletion confirmation if your policy requires one. ## Data residency All storage and processing is in the United States in v1. A DPA is available from day one; see Security for the subprocessor list. --- Page: Errors URL: https://docs.fromenance.com/api/errors # Errors Fromenance is a communication provenance platform whose API returns one error shape everywhere: an RFC 9457 problem details document with content type application/problem+json . This page lists every problem type and what to do about it. By the end you will be able to parse any error, find the offending field, quote a request id to support, and back off correctly when rate limited. ## The problem document ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "The request body did not match the schema.", "instance": "/v1/communications", "errors": [ { "path": "recipient_hash", "message": "Invalid input: must match pattern ^hmac-sha256:[0-9a-f]{64}$" } ], "request_id": "a1b2c3d4e5f6" } ``` | Field | Notes | | type | A URL on this page. The fragment is the stable problem code; match on it, not on title | | title | Short human readable summary, stable per type | | status | The HTTP status, repeated | | detail | What went wrong in this instance. May change wording between releases | | instance | The request path | | errors[] | Only on validation : one entry per failing field with a JSON path (items.3.sent_at for a batch item) and a message | | request_id | Also sent as the x-request-id response header on every response. Quote it when contacting support | Read type and status first, then errors[] when present, and log request_id with every failure. ## Problem types | Type | Status | When | What to do | | #bad-request | 400 | The request is malformed in a way a schema does not describe: no input on the verify page, a screenshot that is not an image, an unparseable webhook body | Fix the request | | #validation | 400 | The body, query, or path did not match the schema | Read errors[] ; each path names the field | | #unauthorized | 401 | Missing, revoked, or malformed credential; a site key used from an origin not on its allow list; a provider webhook with a bad signature | Check the bearer token, the X-Site-Key and Origin , or the provider’s signing secret | | #forbidden | 403 | The credential is valid but lacks the scope or role, or a tenant context is required | Use a key with the right scope or a user with the right role | | #not-found | 404 | No such resource in your tenant. Ids are tenant scoped, so another tenant’s id is also a 404 | Check the id and its prefix | | #conflict | 409 | The action does not fit the current state: a forwarding test on an unverified domain, completing an expired reservation | Resolve the state first | | #payload-too-large | 413 | A screenshot over 6 MB | Send a smaller image or the message text | | #idempotency-mismatch | 422 | The Idempotency-Key was already used with a different body within 24 hours | Use a new key for a new request | | #turnstile-required | 428 | The site key is over its normal rate or has Turnstile required | Render Turnstile and resend with turnstile_token | | #rate-limited | 429 | Over a rate limit | Wait for Retry-After seconds, then retry | | #internal | 500 | Something failed on the Fromenance side. Logged with the request id | Retry with backoff; contact support with the request id if it persists | | #upstream | 502 | A dependency failed: the raw message could not be fetched from the inbound provider, an adapter is temporarily disabled, a screenshot could not be read | Retry later, or use a different input | ## Rate limits | Limit | Scope | Retry-After | | 600 requests per minute | Per API key on POST /v1/communications and /reserve | 10 | | 6,000 items per minute | Per API key on POST /v1/communications/batch , counted per 100 items | 10 | | 60 requests per minute | Per IP on POST /v1/public/submit | 60 | | 600 requests per minute | Per site key on POST /v1/public/submit | 10 | A 429 always carries Retry-After in seconds. Back off for at least that long; do not retry validation errors at all. ## Idempotent replays A successful replay of a creating POST with the same Idempotency-Key and body returns the stored response with its original status and the header idempotent-replayed: true . It is not an error; treat it as a normal response. --- Page: Overview URL: https://docs.fromenance.com/api # Overview ## Fromenance API 1.0.0 Communication provenance for banks, credit unions, insurers, and utilities. Register every outbound communication at send time; verify what customers forward or paste. Errors are RFC 9457 problem details. All timestamps are UTC ISO 8601. Cursor pagination with cursor and limit . Fromenance - https://docs.fromenance.com - support@fromenance.com Information - Terms of Service - OpenAPI version: 3.1.0 ## Operations GET/healthz GET/v1/communications POST/v1/communications POST/v1/communications/batch POST/v1/communications/reserve GET/v1/communications/{id} PATCH/v1/communications/{id} POST/v1/public/submit GET/v1/public/verdict/{token} POST/v1/leads GET/v1/submissions GET/v1/submissions/{id} POST/v1/submissions/{id}/override GET/v1/indicators GET/v1/fraud-list POST/v1/fraud-list DELETE/v1/fraud-list/{id} GET/v1/domains POST/v1/domains GET/v1/domains/{id} DELETE/v1/domains/{id} PATCH/v1/domains/{id} POST/v1/domains/{id}/check POST/v1/domains/{id}/reply-identity POST/v1/domains/{id}/reply-identity/verify GET/v1/domains/{id}/forwarding-test POST/v1/domains/{id}/forwarding-test GET/v1/sending-sources POST/v1/sending-sources GET/v1/sending-sources/{id} DELETE/v1/sending-sources/{id} PATCH/v1/sending-sources/{id} POST/v1/sending-sources/{id}/rotate-secret POST/v1/sending-sources/{id}/test-event GET/v1/reply-templates PUT/v1/reply-templates/{outcome} POST/v1/reply-templates/{outcome}/preview POST/v1/reply-templates/{outcome}/test GET/v1/webhook-endpoints POST/v1/webhook-endpoints DELETE/v1/webhook-endpoints/{id} PATCH/v1/webhook-endpoints/{id} GET/v1/webhook-endpoints/{id}/deliveries POST/v1/webhook-endpoints/{id}/deliveries/{delivery_id}/replay POST/v1/webhook-endpoints/{id}/test GET/v1/stats GET/v1/me GET/v1/tenant PATCH/v1/tenant GET/v1/users POST/v1/users DELETE/v1/users/{id} PATCH/v1/users/{id} GET/v1/api-keys POST/v1/api-keys DELETE/v1/api-keys/{id} GET/v1/audit GET/v1/site-keys POST/v1/site-keys DELETE/v1/site-keys/{id} PATCH/v1/site-keys/{id} GET/v1/search GET/v1/setup ## Authentication ### ApiKey Tenant API key with fr_live_ or fr_test_ prefix. Security scheme type: http ### SiteKey Public site key for the verify widget. Bound to allowed origins. Security scheme type: apiKey Header parameter name: X-Site-Key ### Session Admin app or backoffice session. Security scheme type: apiKey Cookie parameter name: fromenance.session_token --- Page: Health check URL: https://docs.fromenance.com/api/operations/healthz # Health check GET /healthzFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/healthz ``` - Production Returns ok when the Worker can reach D1. Neon is checked only when ?deep=1 . ## Parameters ### Query Parameters deep string ## Responses ### 200 Healthy application/json object status required string Allowed values: ok degraded version required string environment required string checks required object neon required string Allowed values: ok error skipped d1 required string Allowed values: ok error time required string Example ```json { "status": "ok", "checks": { "neon": "ok", "d1": "ok" } } ``` --- Page: Overview URL: https://docs.fromenance.com/api/operations/tags/communications # Overview ## Communications Registration of sent communications. ## Operations GET/v1/communications POST/v1/communications POST/v1/communications/batch POST/v1/communications/reserve GET/v1/communications/{id} PATCH/v1/communications/{id} --- Page: Search registrations URL: https://docs.fromenance.com/api/operations/v1communications/get # Search registrations GET /v1/communicationsFetchcURL ```sh curl --request GET \ --url 'https://api.fromenance.com/v1/communications?status=registered' \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Parameters ### Query Parameters code string message_id string template_id string status string Allowed values: registered reserved expired source_id string from string format: date-time to string format: date-time limit string ## Responses ### 200 Communications application/json object data required Array object id required string channel required string status required string recipient_hash required string | null message_id required string | null from_address required string | null sent_at required string | null verify_code required string subject_fingerprint required string | null content_fingerprint required object simhash required string /^0x[0-9a-fA-F]{1,16}$/ sha256 required string /^[0-9a-f]{64}$/ link_domains required Array template_id required string | null campaign_id required string | null provider required string | null source_id required string | null retro required boolean sandbox required boolean reserved_at required string | null completed_at required string | null expires_at required string created_at required string verifications Array object submission_id required string outcome required string rule required string received_at required string Example ```json { "data": [ { "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c" } } ] } ``` --- Page: Register one sent communication URL: https://docs.fromenance.com/api/operations/v1communications/post # Register one sent communication POST /v1/communicationsFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/communications \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "channel": "email", "recipient_hash": "example", "message_id": "example", "from_address": "example", "sent_at": "2026-04-15T12:00:00Z", "subject_fingerprint": "example", "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c", "sha256": "example" }, "link_domains": [ "example" ], "template_id": "example", "campaign_id": "example", "provider": "api", "expires_at": "2026-04-15T12:00:00Z", "verify_code": "example" }' ``` - Production Call at send time. Send only the recipient HMAC and the content fingerprint; never the address or the body. Returns the verify code to place in the footer. Supports Idempotency-Key . ## Authorizations - ApiKey register ## Request Body application/json object channel string Allowed values: email recipient_hash required HMAC-SHA256 of the lowercased, trimmed recipient address keyed with your tenant secret. string /^hmac-sha256:[0-9a-f]{64}$/ message_id required string >= 1 characters <= 998 characters from_address required string >= 3 characters <= 320 characters sent_at required string format: date-time subject_fingerprint string /^[0-9a-f]{64}$/ content_fingerprint object simhash required string /^0x[0-9a-fA-F]{1,16}$/ Example ```plaintext 0x9a4d3c2b1f0e8d7c ``` sha256 required string /^[0-9a-f]{64}$/ link_domains Array <= 50 items template_id string | null <= 120 characters campaign_id string | null <= 120 characters provider string Allowed values: api sendgrid ses resend postmark mailgun sfmc braze relay journal generic expires_at string format: date-time verify_code Only when completing a reserved code; otherwise omit and one is issued. string <= 9 characters ## Responses ### 201 Registered application/json object id required string verify_code required string expires_at required string sandbox required boolean Example ```json { "verify_code": "KX73-PQ9G" } ``` ### 400 Validation application/problem+json object type required string title required string status required integer detail string instance string errors Present on validation problems. Array object path required string message required string request_id string Example ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "recipient_hash must start with hmac-sha256:", "instance": "/v1/communications" } ``` ### 429 Rate limited application/problem+json object type required string title required string status required integer detail string instance string errors Present on validation problems. Array object path required string message required string request_id string Example ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "recipient_hash must start with hmac-sha256:", "instance": "/v1/communications" } ``` --- Page: Register up to 1,000 communications URL: https://docs.fromenance.com/api/operations/v1communicationsbatch # Register up to 1,000 communications POST /v1/communications/batchFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/communications/batch \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "items": [ { "channel": "email", "recipient_hash": "example", "message_id": "example", "from_address": "example", "sent_at": "2026-04-15T12:00:00Z", "subject_fingerprint": "example", "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c", "sha256": "example" }, "link_domains": [ "example" ], "template_id": "example", "campaign_id": "example", "provider": "api", "expires_at": "2026-04-15T12:00:00Z", "verify_code": "example" } ] }' ``` - Production Per item results in input order. One bad item does not fail the batch. Rate limited at 6,000 items per minute. ## Authorizations - ApiKey register ## Request Body application/json object items required Array >= 1 items <= 1000 items object channel string Allowed values: email recipient_hash required HMAC-SHA256 of the lowercased, trimmed recipient address keyed with your tenant secret. string /^hmac-sha256:[0-9a-f]{64}$/ message_id required string >= 1 characters <= 998 characters from_address required string >= 3 characters <= 320 characters sent_at required string format: date-time subject_fingerprint string /^[0-9a-f]{64}$/ content_fingerprint object simhash required string /^0x[0-9a-fA-F]{1,16}$/ Example ```plaintext 0x9a4d3c2b1f0e8d7c ``` sha256 required string /^[0-9a-f]{64}$/ link_domains Array <= 50 items template_id string | null <= 120 characters campaign_id string | null <= 120 characters provider string Allowed values: api sendgrid ses resend postmark mailgun sfmc braze relay journal generic expires_at string format: date-time verify_code Only when completing a reserved code; otherwise omit and one is issued. string <= 9 characters ## Responses ### 200 Results application/json object results required Array object index required integer ok required boolean id string verify_code string expires_at string error string registered required integer failed required integer Examplegenerated ```json { "results": [ { "index": 1, "ok": true, "id": "example", "verify_code": "example", "expires_at": "example", "error": "example" } ], "registered": 1, "failed": 1 } ``` --- Page: Reserve a verify code before sending URL: https://docs.fromenance.com/api/operations/v1communicationsreserve # Reserve a verify code before sending POST /v1/communications/reserveFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/communications/reserve \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "recipient_hash": "example", "template_id": "example", "campaign_id": "example", "provider": "api", "from_address": "example", "expires_in_hours": 1 }' ``` - Production For ESPs that need the code as a merge field before the send event exists. Complete it with PATCH /v1/communications/{id} or through the ESP webhook. Unfinished reservations expire after 24 hours and count as a coverage gap. ## Authorizations - ApiKey register ## Request Body application/json object recipient_hash string /^hmac-sha256:[0-9a-f]{64}$/ template_id string | null <= 120 characters campaign_id string | null <= 120 characters provider string Allowed values: api sendgrid ses resend postmark mailgun sfmc braze relay journal generic from_address string <= 320 characters expires_in_hours integer >= 1 <= 72 ## Responses ### 201 Reserved application/json object id required string verify_code required string expires_at required string sandbox required boolean Example ```json { "verify_code": "KX73-PQ9G" } ``` --- Page: Registration detail with verifications against it URL: https://docs.fromenance.com/api/operations/v1communicationsid/get # Registration detail with verifications against it GET /v1/communications/{id}FetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/communications/com_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Parameters ### Path Parameters id required string /^com_[0-9A-Z]{26}$/ Example ```plaintext com_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Communication application/json object id required string channel required string status required string recipient_hash required string | null message_id required string | null from_address required string | null sent_at required string | null verify_code required string subject_fingerprint required string | null content_fingerprint required object simhash required string /^0x[0-9a-fA-F]{1,16}$/ sha256 required string /^[0-9a-f]{64}$/ link_domains required Array template_id required string | null campaign_id required string | null provider required string | null source_id required string | null retro required boolean sandbox required boolean reserved_at required string | null completed_at required string | null expires_at required string created_at required string verifications Array object submission_id required string outcome required string rule required string received_at required string Example ```json { "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c" } } ``` --- Page: Complete a reserved registration URL: https://docs.fromenance.com/api/operations/v1communicationsid/patch # Complete a reserved registration PATCH /v1/communications/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/communications/com_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "recipient_hash": "example", "message_id": "example", "from_address": "example", "sent_at": "2026-04-15T12:00:00Z", "subject_fingerprint": "example", "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c", "sha256": "example" }, "link_domains": [ "example" ], "template_id": "example", "campaign_id": "example", "expires_at": "2026-04-15T12:00:00Z" }' ``` - Production ## Authorizations - ApiKey register ## Parameters ### Path Parameters id required string /^com_[0-9A-Z]{26}$/ Example ```plaintext com_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object recipient_hash string /^hmac-sha256:[0-9a-f]{64}$/ message_id string >= 1 characters <= 998 characters from_address string >= 3 characters <= 320 characters sent_at string format: date-time subject_fingerprint string /^[0-9a-f]{64}$/ content_fingerprint object simhash required string /^0x[0-9a-fA-F]{1,16}$/ Example ```plaintext 0x9a4d3c2b1f0e8d7c ``` sha256 required string /^[0-9a-f]{64}$/ link_domains Array <= 50 items template_id string | null <= 120 characters campaign_id string | null <= 120 characters expires_at string format: date-time ## Responses ### 200 Communication application/json object id required string channel required string status required string recipient_hash required string | null message_id required string | null from_address required string | null sent_at required string | null verify_code required string subject_fingerprint required string | null content_fingerprint required object simhash required string /^0x[0-9a-fA-F]{1,16}$/ sha256 required string /^[0-9a-f]{64}$/ link_domains required Array template_id required string | null campaign_id required string | null provider required string | null source_id required string | null retro required boolean sandbox required boolean reserved_at required string | null completed_at required string | null expires_at required string created_at required string verifications Array object submission_id required string outcome required string rule required string received_at required string Example ```json { "content_fingerprint": { "simhash": "0x9a4d3c2b1f0e8d7c" } } ``` --- Page: Overview URL: https://docs.fromenance.com/api/operations/tags/public # Overview ## Public Web verify endpoints authenticated by site key. ## Operations POST/v1/public/submit GET/v1/public/verdict/{token} POST/v1/leads --- Page: Verify a message from the web page URL: https://docs.fromenance.com/api/operations/v1publicsubmit # Verify a message from the web page POST /v1/public/submitFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/public/submit \ --header 'Content-Type: application/json' \ --header 'X-Site-Key: ' \ --data '{ "text": "example", "eml_base64": "example", "image_base64": "example", "code": "example", "email": "hello@example.com", "turnstile_token": "example" }' ``` - Production Authenticated by site key and origin. Rate limited per IP and per key; a Turnstile token is required once a key exceeds its normal rate. Text and .eml return the verdict synchronously; screenshots return a poll token. ## Authorizations - SiteKey ## Request Body application/json object text Pasted message text or HTML. string <= 200000 characters eml_base64 A .eml file, base64. string <= 2000000 characters image_base64 A screenshot (PNG, JPEG, WebP), base64. Answered asynchronously. string <= 8000000 characters code Verify code typed by the customer. string <= 12 characters email The customer’s own address, hashed locally on our side and never stored in clear. string format: email turnstile_token string <= 4000 characters Examplegenerated ```json { "text": "example", "eml_base64": "example", "image_base64": "example", "code": "example", "email": "hello@example.com", "turnstile_token": "example" } ``` ## Responses ### 200 Verdict application/json object status required string Allowed values: decided pending poll_token required string | null submission_id required string outcome required string | null Allowed values: verified not_verified known_fraud rule required string | null matched_sent_at required string | null signals required object code_found required boolean recipient_match required boolean | null fingerprint_distance required number | null display required object institution_name required string logo_url required string | null accent required string | null fraud_contact required string | null support_url required string | null outcome_label required string verdict_text required string next_step required string Example ```json { "status": "decided", "outcome": "verified" } ``` ### 202 Pending (screenshot) application/json object status required string Allowed values: decided pending poll_token required string | null submission_id required string outcome required string | null Allowed values: verified not_verified known_fraud rule required string | null matched_sent_at required string | null signals required object code_found required boolean recipient_match required boolean | null fingerprint_distance required number | null display required object institution_name required string logo_url required string | null accent required string | null fraud_contact required string | null support_url required string | null outcome_label required string verdict_text required string next_step required string Example ```json { "status": "decided", "outcome": "verified" } ``` ### 428 Turnstile required ### 429 Rate limited --- Page: Poll a screenshot submission URL: https://docs.fromenance.com/api/operations/v1publicverdicttoken # Poll a screenshot submission GET /v1/public/verdict/{token}FetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/public/verdict/example \ --header 'X-Site-Key: ' ``` - Production ## Authorizations - SiteKey ## Parameters ### Path Parameters token required string >= 8 characters <= 64 characters ## Responses ### 200 Verdict or pending application/json object status required string Allowed values: decided pending poll_token required string | null submission_id required string outcome required string | null Allowed values: verified not_verified known_fraud rule required string | null matched_sent_at required string | null signals required object code_found required boolean recipient_match required boolean | null fingerprint_distance required number | null display required object institution_name required string logo_url required string | null accent required string | null fraud_contact required string | null support_url required string | null outcome_label required string verdict_text required string next_step required string Example ```json { "status": "decided", "outcome": "verified" } ``` --- Page: Submit a sales or pilot inquiry URL: https://docs.fromenance.com/api/operations/v1leads # Submit a sales or pilot inquiry POST /v1/leadsFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/leads \ --header 'Content-Type: application/json' \ --data '{ "name": "example", "email": "hello@example.com", "institution": "example", "role": "example", "segment": "bank", "customers": "<250K", "message": "example", "source": "example", "turnstile_token": "example", "website": "example" }' ``` - Production Used by the fromenance.com contact and pilot forms. Rate limited per IP. ## Request Body application/json object name required string >= 2 characters <= 120 characters email required string format: email <= 200 characters institution string <= 200 characters role string <= 120 characters segment string Allowed values: bank credit_union insurance utility other customers string Allowed values: <250K 250K-1M 1M-3M 3M+ message string <= 4000 characters source Page path the form was submitted from. string <= 200 characters turnstile_token string <= 4000 characters website Honeypot. Leave empty. string <= 200 characters ## Responses ### 202 Accepted application/json object ok required boolean id required string Example ```json { "ok": true } ``` ### 429 Rate limited --- Page: List submissions URL: https://docs.fromenance.com/api/operations/v1submissions # List submissions GET /v1/submissionsFetchcURL ```sh curl --request GET \ --url 'https://api.fromenance.com/v1/submissions?limit=50&outcome=verified&entry=forward&replay=true&has_headers=true' \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Parameters ### Query Parameters cursor Opaque cursor from a previous response. string Opaque cursor from a previous response. limit Page size, 1 to 200, default 50. string Example ```json 50 ``` Page size, 1 to 200, default 50. outcome string Allowed values: verified not_verified known_fraud entry string Allowed values: forward web api replay string Allowed values: true false has_headers string Allowed values: true false code string submitter_hash string from string to string campaign_id string ## Responses ### 200 Submissions application/json object data required Array object id required string entry required string status required string received_at required string processed_at required string | null count required integer verify_code_found required string | null submitter_hash required string | null recipient_hash_found required string | null headers_present required boolean dkim required string | null arc required string | null trust required string | null from_domain required string | null imitated_template_id required string | null campaign_id required string | null sandbox required boolean verdict required object submission_id required string outcome required string Allowed values: verified not_verified known_fraud rule required string replay required boolean authoritative required boolean matched required object communication_id required string sent_at required string | null template_id required string | null signals required object key additional properties indicators required Array object kind required string value required string replied_at required string | null created_at required string override required object outcome required string by required string | null at required string note required string | null next_cursor required string | null Example ```json { "data": [ { "verdict": { "outcome": "verified" } } ] } ``` --- Page: Submission detail URL: https://docs.fromenance.com/api/operations/v1submissionsid # Submission detail GET /v1/submissions/{id}FetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/submissions/sub_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production Extracted signals, verdict and rule, indicators with fraud list status, header summary, and a text preview with links disabled. ## Authorizations - Session - ApiKey read ## Parameters ### Path Parameters id required string /^sub_[0-9A-Z]{26}$/ Example ```plaintext sub_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Detail application/json object id required string entry required string status required string received_at required string processed_at required string | null count required integer verify_code_found required string | null submitter_hash required string | null recipient_hash_found required string | null headers_present required boolean dkim required string | null arc required string | null trust required string | null from_domain required string | null imitated_template_id required string | null campaign_id required string | null sandbox required boolean verdict required object submission_id required string outcome required string Allowed values: verified not_verified known_fraud rule required string replay required boolean authoritative required boolean matched required object communication_id required string sent_at required string | null template_id required string | null signals required object key additional properties indicators required Array object kind required string value required string replied_at required string | null created_at required string override required object outcome required string by required string | null at required string note required string | null subject required string | null preview_text required Plain text of the forwarded message with every link replaced by its bare domain. string | null headers_summary required object received required Array dkim required string | null arc required string | null spf required string | null trust required string | null indicators required Array object id required string kind required string value required string on_fraud_list required boolean submission_count required integer raw_available required boolean Example ```json { "verdict": { "outcome": "verified" } } ``` --- Page: Analyst override URL: https://docs.fromenance.com/api/operations/v1submissionsidoverride # Analyst override POST /v1/submissions/{id}/overrideFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/submissions/sub_01J8Z9X0Q3K4M5N6P7R8S9T0V1/override \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "outcome": "fraud", "note": "example" }' ``` - Production Marking fraud adds the submission’s indicators and fingerprint to the tenant fraud list so later submissions resolve to Known fraud. Marking legitimate creates a registration retroactively and flags a coverage gap on the sending source. ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^sub_[0-9A-Z]{26}$/ Example ```plaintext sub_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object outcome required string Allowed values: fraud legitimate note string <= 2000 characters ## Responses ### 200 Updated submission application/json object id required string entry required string status required string received_at required string processed_at required string | null count required integer verify_code_found required string | null submitter_hash required string | null recipient_hash_found required string | null headers_present required boolean dkim required string | null arc required string | null trust required string | null from_domain required string | null imitated_template_id required string | null campaign_id required string | null sandbox required boolean verdict required object submission_id required string outcome required string Allowed values: verified not_verified known_fraud rule required string replay required boolean authoritative required boolean matched required object communication_id required string sent_at required string | null template_id required string | null signals required object key additional properties indicators required Array object kind required string value required string replied_at required string | null created_at required string override required object outcome required string by required string | null at required string note required string | null Example ```json { "verdict": { "outcome": "verified" } } ``` --- Page: List extracted indicators URL: https://docs.fromenance.com/api/operations/v1indicators # List extracted indicators GET /v1/indicatorsFetchcURL ```sh curl --request GET \ --url 'https://api.fromenance.com/v1/indicators?limit=50&kind=url&on_fraud_list=true&priority=low' \ --cookie fromenance.session_token= ``` - Production Set Accept: text/csv or Accept: application/stix+json on /v1/indicators/export for exports. ## Authorizations - Session - ApiKey read ## Parameters ### Query Parameters cursor Opaque cursor from a previous response. string Opaque cursor from a previous response. limit Page size, 1 to 200, default 50. string Example ```json 50 ``` Page size, 1 to 200, default 50. kind string Allowed values: url domain email phone ip q string on_fraud_list string Allowed values: true false since string priority string Allowed values: low normal high ## Responses ### 200 Indicators application/json object data required Array object id required string kind required string value required string first_seen required string last_seen required string submission_count required integer priority required string on_fraud_list required boolean next_cursor required string | null Examplegenerated ```json { "data": [ { "id": "example", "kind": "example", "value": "example", "first_seen": "example", "last_seen": "example", "submission_count": 1, "priority": "example", "on_fraud_list": true } ], "next_cursor": "example" } ``` --- Page: The tenant's block list URL: https://docs.fromenance.com/api/operations/v1fraud-list/get # The tenant's block list GET /v1/fraud-listFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/fraud-list \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Responses ### 200 Entries application/json object data required Array object id required string kind required string value required string source required string added_by required string | null note required string | null submission_id required string | null created_at required string Examplegenerated ```json { "data": [ { "id": "example", "kind": "example", "value": "example", "source": "example", "added_by": "example", "note": "example", "submission_id": "example", "created_at": "example" } ] } ``` --- Page: Add indicators to the fraud list URL: https://docs.fromenance.com/api/operations/v1fraud-list/post # Add indicators to the fraud list POST /v1/fraud-listFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/fraud-list \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "items": [ { "kind": "url", "value": "example", "note": "example" } ] }' ``` - Production Entries come only from analyst action; nothing is added automatically from a single submission. ## Authorizations - Session - ApiKey ## Request Body application/json object items required Array >= 1 items <= 500 items object kind required string Allowed values: url domain email phone ip value required string >= 2 characters <= 2000 characters note string <= 500 characters ## Responses ### 200 Added application/json object added required integer data required Array object id required string kind required string value required string source required string added_by required string | null note required string | null submission_id required string | null created_at required string Examplegenerated ```json { "added": 1, "data": [ { "id": "example", "kind": "example", "value": "example", "source": "example", "added_by": "example", "note": "example", "submission_id": "example", "created_at": "example" } ] } ``` --- Page: Remove an entry URL: https://docs.fromenance.com/api/operations/v1fraud-listid # Remove an entry DELETE /v1/fraud-list/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/fraud-list/example \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string ## Responses ### 200 Removed application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: List domains URL: https://docs.fromenance.com/api/operations/v1domains/get # List domains GET /v1/domainsFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/domains \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Domains application/json object data required Array object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "data": [ { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ] } ``` --- Page: Add a domain URL: https://docs.fromenance.com/api/operations/v1domains/post # Add a domain POST /v1/domainsFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/domains \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "domain": "northfieldbank.com", "roles": [ "trust" ] }' ``` - Production Returns the TXT record to publish at _fromenance. . The domain is polled every hour for 24 hours, then daily once verified. ## Authorizations - Session - ApiKey ## Request Body application/json object domain required string >= 3 characters <= 253 characters Example ```plaintext northfieldbank.com ``` roles Array Allowed values: trust sender reply ## Responses ### 201 Domain application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Domain detail URL: https://docs.fromenance.com/api/operations/v1domainsid/get # Domain detail GET /v1/domains/{id}FetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Domain application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Remove a domain URL: https://docs.fromenance.com/api/operations/v1domainsid/delete # Remove a domain DELETE /v1/domains/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Removed application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: Change domain roles URL: https://docs.fromenance.com/api/operations/v1domainsid/patch # Change domain roles PATCH /v1/domains/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "roles": [ "trust" ] }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object roles required Array >= 1 items Allowed values: trust sender reply ## Responses ### 200 Domain application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Re-check the TXT record now URL: https://docs.fromenance.com/api/operations/v1domainsidcheck # Re-check the TXT record now POST /v1/domains/{id}/checkFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1/check \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Domain after the check application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Create the branded reply identity URL: https://docs.fromenance.com/api/operations/v1domainsidreply-identity # Create the branded reply identity POST /v1/domains/{id}/reply-identityFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1/reply-identity \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "subdomain": "example" }' ``` - Production Creates verify. (or the given subdomain) as a Resend sending domain and returns the DKIM, SPF, and MX records to publish. Replies then come from verify@verify. . ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object subdomain string /^[a-z0-9-]{1,30}$/ Examplegenerated ```json { "subdomain": "example" } ``` ## Responses ### 200 Domain with reply identity records application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Ask Resend to verify the reply identity records URL: https://docs.fromenance.com/api/operations/v1domainsidreply-identityverify # Ask Resend to verify the reply identity records POST /v1/domains/{id}/reply-identity/verifyFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1/reply-identity/verify \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Domain application/json object id required string domain required string status required Any of: - string - string string Allowed values: pending verified suspended roles required Array txt_record required object name required string type required string Allowed values: TXT value required string verified_at required string | null last_checked_at required string | null last_check_result required string | null reply_identity required object resend_domain_id required string | null status required string | null records required Array | null object record required string name required string type required string value required string priority number status string created_at required string Example ```json { "status": "pending", "roles": [ "trust" ], "txt_record": { "type": "TXT" } } ``` --- Page: Forwarding test status URL: https://docs.fromenance.com/api/operations/v1domainsidforwarding-test/get # Forwarding test status GET /v1/domains/{id}/forwarding-testFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1/forwarding-test \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Status application/json object status required string Allowed values: not_started sent working failed sent_at required string | null passed_at required string | null result required string | null to required string Example ```json { "status": "not_started" } ``` --- Page: Send the forwarding test message to verify@ URL: https://docs.fromenance.com/api/operations/v1domainsidforwarding-test/post # Send the forwarding test message to verify@ POST /v1/domains/{id}/forwarding-testFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/domains/dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1/forwarding-test \ --cookie fromenance.session_token= ``` - Production The message carries a token. When the tenant’s redirect rule delivers it to the tenant inbox with a DKIM or ARC pass for the domain, the rule is marked Working. Poll GET on the same path. ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^dom_[0-9A-Z]{26}$/ Example ```plaintext dom_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Status application/json object status required string Allowed values: not_started sent working failed sent_at required string | null passed_at required string | null result required string | null to required string Example ```json { "status": "not_started" } ``` --- Page: List sending sources with health URL: https://docs.fromenance.com/api/operations/v1sending-sources/get # List sending sources with health GET /v1/sending-sourcesFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/sending-sources \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Sources application/json object data required Array object id required string name required string kind required string status required string webhook_url required string | null needs_reserve required boolean footer_merge_tag required string | null instructions required Array expected_daily_volume required integer | null silence_alert_minutes required integer health required object last_event_at required string | null events_last_hour required integer signature_failures required integer reserved required integer completed required integer reserve_to_complete_ratio required number | null coverage_gaps required integer silent required boolean registered_today required integer created_at required string Examplegenerated ```json { "data": [ { "id": "example", "name": "example", "kind": "example", "status": "example", "webhook_url": "example", "needs_reserve": true, "footer_merge_tag": "example", "instructions": [ "example" ], "expected_daily_volume": 1, "silence_alert_minutes": 1, "health": { "last_event_at": "example", "events_last_hour": 1, "signature_failures": 1, "reserved": 1, "completed": 1, "reserve_to_complete_ratio": 1, "coverage_gaps": 1, "silent": true, "registered_today": 1 }, "created_at": "example" } ] } ``` --- Page: Create a sending source URL: https://docs.fromenance.com/api/operations/v1sending-sources/post # Create a sending source POST /v1/sending-sourcesFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/sending-sources \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "kind": "api", "config": { "additionalProperty": "example" }, "expected_daily_volume": 1, "silence_alert_minutes": 1 }' ``` - Production Returns the webhook URL and the secret to configure at the provider. The secret is shown once. For Postmark the secret is the basic auth pair user:password. ## Authorizations - Session - ApiKey ## Request Body application/json object name required string >= 1 characters <= 80 characters kind required string Allowed values: api journal resend sendgrid ses generic postmark mailgun sfmc braze config Adapter specific, encrypted at rest. For example api_key for Resend body retrieval or server_token for Postmark. object key additional properties string expected_daily_volume integer | null silence_alert_minutes integer >= 5 <= 20160 ## Responses ### 201 Source application/json object id required string name required string kind required string status required string webhook_url required string | null needs_reserve required boolean footer_merge_tag required string | null instructions required Array expected_daily_volume required integer | null silence_alert_minutes required integer health required object last_event_at required string | null events_last_hour required integer signature_failures required integer reserved required integer completed required integer reserve_to_complete_ratio required number | null coverage_gaps required integer silent required boolean registered_today required integer created_at required string secret required string | null Examplegenerated ```json { "id": "example", "name": "example", "kind": "example", "status": "example", "webhook_url": "example", "needs_reserve": true, "footer_merge_tag": "example", "instructions": [ "example" ], "expected_daily_volume": 1, "silence_alert_minutes": 1, "health": { "last_event_at": "example", "events_last_hour": 1, "signature_failures": 1, "reserved": 1, "completed": 1, "reserve_to_complete_ratio": 1, "coverage_gaps": 1, "silent": true, "registered_today": 1 }, "created_at": "example", "secret": "example" } ``` --- Page: Source detail URL: https://docs.fromenance.com/api/operations/v1sending-sourcesid/get # Source detail GET /v1/sending-sources/{id}FetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/sending-sources/src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^src_[0-9A-Z]{26}$/ Example ```plaintext src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Source application/json object id required string name required string kind required string status required string webhook_url required string | null needs_reserve required boolean footer_merge_tag required string | null instructions required Array expected_daily_volume required integer | null silence_alert_minutes required integer health required object last_event_at required string | null events_last_hour required integer signature_failures required integer reserved required integer completed required integer reserve_to_complete_ratio required number | null coverage_gaps required integer silent required boolean registered_today required integer created_at required string Examplegenerated ```json { "id": "example", "name": "example", "kind": "example", "status": "example", "webhook_url": "example", "needs_reserve": true, "footer_merge_tag": "example", "instructions": [ "example" ], "expected_daily_volume": 1, "silence_alert_minutes": 1, "health": { "last_event_at": "example", "events_last_hour": 1, "signature_failures": 1, "reserved": 1, "completed": 1, "reserve_to_complete_ratio": 1, "coverage_gaps": 1, "silent": true, "registered_today": 1 }, "created_at": "example" } ``` --- Page: Delete a source URL: https://docs.fromenance.com/api/operations/v1sending-sourcesid/delete # Delete a source DELETE /v1/sending-sources/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/sending-sources/src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^src_[0-9A-Z]{26}$/ Example ```plaintext src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Deleted application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: Update a source URL: https://docs.fromenance.com/api/operations/v1sending-sourcesid/patch # Update a source PATCH /v1/sending-sources/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/sending-sources/src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "status": "active", "config": { "additionalProperty": "example" }, "expected_daily_volume": 1, "silence_alert_minutes": 1 }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^src_[0-9A-Z]{26}$/ Example ```plaintext src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object name string >= 1 characters <= 80 characters status string Allowed values: active paused config object key additional properties string expected_daily_volume integer | null silence_alert_minutes integer >= 5 <= 20160 ## Responses ### 200 Source application/json object id required string name required string kind required string status required string webhook_url required string | null needs_reserve required boolean footer_merge_tag required string | null instructions required Array expected_daily_volume required integer | null silence_alert_minutes required integer health required object last_event_at required string | null events_last_hour required integer signature_failures required integer reserved required integer completed required integer reserve_to_complete_ratio required number | null coverage_gaps required integer silent required boolean registered_today required integer created_at required string Examplegenerated ```json { "id": "example", "name": "example", "kind": "example", "status": "example", "webhook_url": "example", "needs_reserve": true, "footer_merge_tag": "example", "instructions": [ "example" ], "expected_daily_volume": 1, "silence_alert_minutes": 1, "health": { "last_event_at": "example", "events_last_hour": 1, "signature_failures": 1, "reserved": 1, "completed": 1, "reserve_to_complete_ratio": 1, "coverage_gaps": 1, "silent": true, "registered_today": 1 }, "created_at": "example" } ``` --- Page: Rotate the webhook secret URL: https://docs.fromenance.com/api/operations/v1sending-sourcesidrotate-secret # Rotate the webhook secret POST /v1/sending-sources/{id}/rotate-secretFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/sending-sources/src_01J8Z9X0Q3K4M5N6P7R8S9T0V1/rotate-secret \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^src_[0-9A-Z]{26}$/ Example ```plaintext src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 New secret, shown once application/json object secret required string Examplegenerated ```json { "secret": "example" } ``` --- Page: Run a synthetic send event through the adapter URL: https://docs.fromenance.com/api/operations/v1sending-sourcesidtest-event # Run a synthetic send event through the adapter POST /v1/sending-sources/{id}/test-eventFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/sending-sources/src_01J8Z9X0Q3K4M5N6P7R8S9T0V1/test-event \ --cookie fromenance.session_token= ``` - Production Registers a sandbox communication for a test recipient so the integration engineer can see the source turn green without touching the provider. ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^src_[0-9A-Z]{26}$/ Example ```plaintext src_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Result application/json object ok required boolean communication_id required string verify_code required string Example ```json { "ok": true } ``` --- Page: All four reply templates URL: https://docs.fromenance.com/api/operations/v1reply-templates # All four reply templates GET /v1/reply-templatesFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/reply-templates \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Templates application/json object data required Array object outcome required string subject required string body_mjml required string locked_block_version required integer locked_block_text required string updated_at required string is_default required boolean variables required Array Examplegenerated ```json { "data": [ { "outcome": "example", "subject": "example", "body_mjml": "example", "locked_block_version": 1, "locked_block_text": "example", "updated_at": "example", "is_default": true } ], "variables": [ "example" ] } ``` --- Page: Update a template URL: https://docs.fromenance.com/api/operations/v1reply-templatesoutcome # Update a template PUT /v1/reply-templates/{outcome}FetchcURL ```sh curl --request PUT \ --url https://api.fromenance.com/v1/reply-templates/verified \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "subject": "example", "body_mjml": "example" }' ``` - Production The body must include {{verdict_block}}; the locked verdict text cannot be edited and vouching language is refused. ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters outcome required string Allowed values: verified not_verified not_verified_authoritative known_fraud ## Request Body application/json object subject required string >= 1 characters <= 200 characters body_mjml required string >= 20 characters <= 50000 characters Examplegenerated ```json { "subject": "example", "body_mjml": "example" } ``` ## Responses ### 200 Template application/json object outcome required string subject required string body_mjml required string locked_block_version required integer locked_block_text required string updated_at required string is_default required boolean Examplegenerated ```json { "outcome": "example", "subject": "example", "body_mjml": "example", "locked_block_version": 1, "locked_block_text": "example", "updated_at": "example", "is_default": true } ``` --- Page: Render a template with sample data URL: https://docs.fromenance.com/api/operations/v1reply-templatesoutcomepreview # Render a template with sample data POST /v1/reply-templates/{outcome}/previewFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/reply-templates/verified/preview \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "subject": "example", "body_mjml": "example" }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters outcome required string Allowed values: verified not_verified not_verified_authoritative known_fraud ## Request Body application/json object subject string body_mjml string Examplegenerated ```json { "subject": "example", "body_mjml": "example" } ``` ## Responses ### 200 Rendered application/json object subject required string html required string text required string errors required Array from required string Examplegenerated ```json { "subject": "example", "html": "example", "text": "example", "errors": [ "example" ], "from": "example" } ``` --- Page: Send a test reply to yourself URL: https://docs.fromenance.com/api/operations/v1reply-templatesoutcometest # Send a test reply to yourself POST /v1/reply-templates/{outcome}/testFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/reply-templates/verified/test \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "to": "hello@example.com" }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters outcome required string Allowed values: verified not_verified not_verified_authoritative known_fraud ## Request Body application/json object to string format: email Examplegenerated ```json { "to": "hello@example.com" } ``` ## Responses ### 200 Sent application/json object ok required boolean to required string from required string id required string Example ```json { "ok": true } ``` --- Page: List endpoints URL: https://docs.fromenance.com/api/operations/v1webhook-endpoints/get # List endpoints GET /v1/webhook-endpointsFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/webhook-endpoints \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Endpoints application/json object data required Array object id required string url required string events required Array status required string description required string | null last_delivery_at required string | null last_status required integer | null created_at required string events required Array Examplegenerated ```json { "data": [ { "id": "example", "url": "example", "events": [ "example" ], "status": "example", "description": "example", "last_delivery_at": "example", "last_status": 1, "created_at": "example" } ], "events": [ "example" ] } ``` --- Page: Create an endpoint URL: https://docs.fromenance.com/api/operations/v1webhook-endpoints/post # Create an endpoint POST /v1/webhook-endpointsFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/webhook-endpoints \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "url": "https://example.com", "events": [ "verdict.created" ], "description": "example" }' ``` - Production Deliveries are signed with HMAC-SHA256 over timestamp.body using the returned secret (shown once), sent as X-Fromenance-Signature: v1= with X-Fromenance-Timestamp. Retried with backoff for 24 hours. ## Authorizations - Session - ApiKey ## Request Body application/json object url required string format: uri <= 500 characters events Array >= 1 items Allowed values: verdict.created submission.replay_detected indicator.new campaign.detected description string <= 200 characters ## Responses ### 201 Endpoint with secret application/json object id required string url required string events required Array status required string description required string | null last_delivery_at required string | null last_status required integer | null created_at required string secret required string Examplegenerated ```json { "id": "example", "url": "example", "events": [ "example" ], "status": "example", "description": "example", "last_delivery_at": "example", "last_status": 1, "created_at": "example", "secret": "example" } ``` --- Page: Delete an endpoint URL: https://docs.fromenance.com/api/operations/v1webhook-endpointsid/delete # Delete an endpoint DELETE /v1/webhook-endpoints/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/webhook-endpoints/whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^whe_[0-9A-Z]{26}$/ Example ```plaintext whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Deleted application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: Update an endpoint URL: https://docs.fromenance.com/api/operations/v1webhook-endpointsid/patch # Update an endpoint PATCH /v1/webhook-endpoints/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/webhook-endpoints/whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "url": "https://example.com", "events": [ "verdict.created" ], "status": "active", "description": "example" }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^whe_[0-9A-Z]{26}$/ Example ```plaintext whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object url string format: uri <= 500 characters events Array >= 1 items Allowed values: verdict.created submission.replay_detected indicator.new campaign.detected status string Allowed values: active paused description string | null <= 200 characters ## Responses ### 200 Endpoint application/json object id required string url required string events required Array status required string description required string | null last_delivery_at required string | null last_status required integer | null created_at required string Examplegenerated ```json { "id": "example", "url": "example", "events": [ "example" ], "status": "example", "description": "example", "last_delivery_at": "example", "last_status": 1, "created_at": "example" } ``` --- Page: Delivery log URL: https://docs.fromenance.com/api/operations/v1webhook-endpointsiddeliveries # Delivery log GET /v1/webhook-endpoints/{id}/deliveriesFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/webhook-endpoints/whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1/deliveries \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^whe_[0-9A-Z]{26}$/ Example ```plaintext whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Deliveries application/json object data required Array object id required string endpoint_id required string event required string status required string attempts required integer last_status_code required integer | null last_error required string | null next_attempt_at required string | null delivered_at required string | null replay_of required string | null payload required object key additional properties created_at required string Examplegenerated ```json { "data": [ { "id": "example", "endpoint_id": "example", "event": "example", "status": "example", "attempts": 1, "last_status_code": 1, "last_error": "example", "next_attempt_at": "example", "delivered_at": "example", "replay_of": "example", "payload": { "additionalProperty": "example" }, "created_at": "example" } ] } ``` --- Page: Replay a delivery URL: https://docs.fromenance.com/api/operations/v1webhook-endpointsiddeliveriesdelivery_idreplay # Replay a delivery POST /v1/webhook-endpoints/{id}/deliveries/{delivery_id}/replayFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/webhook-endpoints/example/deliveries/example/replay \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string delivery_id required string ## Responses ### 200 New delivery application/json object id required string endpoint_id required string event required string status required string attempts required integer last_status_code required integer | null last_error required string | null next_attempt_at required string | null delivered_at required string | null replay_of required string | null payload required object key additional properties created_at required string Examplegenerated ```json { "id": "example", "endpoint_id": "example", "event": "example", "status": "example", "attempts": 1, "last_status_code": 1, "last_error": "example", "next_attempt_at": "example", "delivered_at": "example", "replay_of": "example", "payload": { "additionalProperty": "example" }, "created_at": "example" } ``` --- Page: Send a test verdict.created event URL: https://docs.fromenance.com/api/operations/v1webhook-endpointsidtest # Send a test verdict.created event POST /v1/webhook-endpoints/{id}/testFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/webhook-endpoints/whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1/test \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^whe_[0-9A-Z]{26}$/ Example ```plaintext whe_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Delivery ids application/json object delivery_ids required Array Examplegenerated ```json { "delivery_ids": [ "example" ] } ``` --- Page: Daily counts: registered, submitted, by outcome, by source, reply p95 URL: https://docs.fromenance.com/api/operations/v1stats # Daily counts: registered, submitted, by outcome, by source, reply p95 GET /v1/statsFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/stats \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Parameters ### Query Parameters days 1 to 365, default 30 string 1 to 365, default 30 ## Responses ### 200 Stats application/json object range required object from required string to required string days required integer totals required object registered required integer submitted required integer verified required integer not_verified required integer known_fraud required integer replays required integer forwards required integer web required integer reply_p95_ms required integer | null reply_avg_ms required integer | null coverage_gaps required integer code_found_rate required number | null today required object day required string registered required integer submitted required integer verified required integer not_verified required integer known_fraud required integer replays required integer forwards required integer web required integer reply_p95_ms required integer | null by_source required object key additional properties number days required Array object day required string registered required integer submitted required integer verified required integer not_verified required integer known_fraud required integer replays required integer forwards required integer web required integer reply_p95_ms required integer | null by_source required object key additional properties number coverage required Array object source_id required string name required string kind required string registered required integer expected required integer | null coverage required number | null top_indicators required Array object kind required string value required string submission_count required integer last_seen required string marked_fraud required boolean authoritative_mode required boolean Examplegenerated ```json { "range": { "from": "example", "to": "example", "days": 1 }, "totals": { "registered": 1, "submitted": 1, "verified": 1, "not_verified": 1, "known_fraud": 1, "replays": 1, "forwards": 1, "web": 1, "reply_p95_ms": 1, "reply_avg_ms": 1, "coverage_gaps": 1, "code_found_rate": 1 }, "today": { "day": "example", "registered": 1, "submitted": 1, "verified": 1, "not_verified": 1, "known_fraud": 1, "replays": 1, "forwards": 1, "web": 1, "reply_p95_ms": 1, "by_source": { "additionalProperty": 1 } }, "days": [ { "day": "example", "registered": 1, "submitted": 1, "verified": 1, "not_verified": 1, "known_fraud": 1, "replays": 1, "forwards": 1, "web": 1, "reply_p95_ms": 1, "by_source": { "additionalProperty": 1 } } ], "coverage": [ { "source_id": "example", "name": "example", "kind": "example", "registered": 1, "expected": 1, "coverage": 1 } ], "top_indicators": [ { "kind": "example", "value": "example", "submission_count": 1, "last_seen": "example", "marked_fraud": true } ], "authoritative_mode": true } ``` --- Page: Current user and tenant memberships URL: https://docs.fromenance.com/api/operations/v1me # Current user and tenant memberships GET /v1/meFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/me \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session ## Responses ### 200 Me application/json object user required object id required string email required string name required string | null platform required boolean memberships required Array object membership_id required string tenant_id required string tenant_slug required string tenant_name required string role required string Examplegenerated ```json { "user": { "id": "example", "email": "example", "name": "example" }, "platform": true, "memberships": [ { "membership_id": "example", "tenant_id": "example", "tenant_slug": "example", "tenant_name": "example", "role": "example" } ] } ``` --- Page: Current tenant URL: https://docs.fromenance.com/api/operations/v1tenant/get # Current tenant GET /v1/tenantFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/tenant \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Tenant application/json object id required string slug required string name required string plan required string status required string authoritative_mode required boolean authoritative_since required string | null retention_days required integer reply_mode required Any of: - string - string string Allowed values: minimal branded reply_from_name required string | null reply_domain_id required string | null fraud_contact required string | null verify_page_url required string | null verify_address required string journal_address required string minimal_reply_address required string branding required object key additional properties match_threshold required integer sandbox required boolean expected_daily_volume required integer | null created_at required string Example ```json { "reply_mode": "minimal" } ``` ### 401 Unauthorized application/problem+json object type required string title required string status required integer detail string instance string errors Present on validation problems. Array object path required string message required string request_id string Example ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "recipient_hash must start with hmac-sha256:", "instance": "/v1/communications" } ``` --- Page: Update tenant settings URL: https://docs.fromenance.com/api/operations/v1tenant/patch # Update tenant settings PATCH /v1/tenantFetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/tenant \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "reply_mode": "minimal", "reply_from_name": "example", "reply_domain_id": "example", "fraud_contact": "example", "verify_page_url": "https://example.com", "branding": { "logoUrl": "https://example.com", "accent": "example", "displayName": "example", "supportUrl": "https://example.com" }, "match_threshold": 1, "retention_days": 1, "expected_daily_volume": 1 }' ``` - Production Authoritative Mode and plan are read only here; a backoffice operator sets them. ## Authorizations - Session - ApiKey ## Request Body application/json object name string >= 2 characters <= 120 characters reply_mode string Allowed values: minimal branded reply_from_name string <= 120 characters reply_domain_id string | null fraud_contact string <= 200 characters verify_page_url string format: uri <= 300 characters branding object logoUrl string format: uri accent string <= 20 characters displayName string <= 120 characters supportUrl string format: uri match_threshold integer <= 20 retention_days integer >= 7 <= 730 expected_daily_volume integer | null ## Responses ### 200 Tenant application/json object id required string slug required string name required string plan required string status required string authoritative_mode required boolean authoritative_since required string | null retention_days required integer reply_mode required Any of: - string - string string Allowed values: minimal branded reply_from_name required string | null reply_domain_id required string | null fraud_contact required string | null verify_page_url required string | null verify_address required string journal_address required string minimal_reply_address required string branding required object key additional properties match_threshold required integer sandbox required boolean expected_daily_volume required integer | null created_at required string Example ```json { "reply_mode": "minimal" } ``` ### 403 Forbidden application/problem+json object type required string title required string status required integer detail string instance string errors Present on validation problems. Array object path required string message required string request_id string Example ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "recipient_hash must start with hmac-sha256:", "instance": "/v1/communications" } ``` --- Page: List tenant users URL: https://docs.fromenance.com/api/operations/v1users/get # List tenant users GET /v1/usersFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/users \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Users application/json object data required Array object id required string email required string name required string | null role required string Allowed values: owner admin analyst integrator viewer status required string invited_at required string | null accepted_at required string | null last_seen_at required string | null created_at required string Example ```json { "data": [ { "role": "owner" } ] } ``` --- Page: Invite a user URL: https://docs.fromenance.com/api/operations/v1users/post # Invite a user POST /v1/usersFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/users \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "email": "hello@example.com", "role": "owner", "name": "example" }' ``` - Production Creates the membership and emails a sign in link. The invitee signs in with a magic link or passkey. ## Authorizations - Session - ApiKey ## Request Body application/json object email required string format: email <= 320 characters role string Allowed values: owner admin analyst integrator viewer name string <= 120 characters ## Responses ### 201 Invited application/json object id required string email required string name required string | null role required string Allowed values: owner admin analyst integrator viewer status required string invited_at required string | null accepted_at required string | null last_seen_at required string | null created_at required string Example ```json { "role": "owner" } ``` ### 409 Already a member application/problem+json object type required string title required string status required integer detail string instance string errors Present on validation problems. Array object path required string message required string request_id string Example ```json { "type": "https://docs.fromenance.com/api/errors#validation", "title": "Validation failed", "status": 400, "detail": "recipient_hash must start with hmac-sha256:", "instance": "/v1/communications" } ``` --- Page: Remove a user from the tenant URL: https://docs.fromenance.com/api/operations/v1usersid/delete # Remove a user from the tenant DELETE /v1/users/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/users/usr_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^usr_[0-9A-Z]{26}$/ Example ```plaintext usr_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Removed application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: Change a user's role URL: https://docs.fromenance.com/api/operations/v1usersid/patch # Change a user's role PATCH /v1/users/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/users/usr_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "role": "owner" }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^usr_[0-9A-Z]{26}$/ Example ```plaintext usr_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object role required string Allowed values: owner admin analyst integrator viewer ## Responses ### 200 User application/json object id required string email required string name required string | null role required string Allowed values: owner admin analyst integrator viewer status required string invited_at required string | null accepted_at required string | null last_seen_at required string | null created_at required string Example ```json { "role": "owner" } ``` --- Page: List API keys URL: https://docs.fromenance.com/api/operations/v1api-keys/get # List API keys GET /v1/api-keysFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/api-keys \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Keys application/json object data required Array object id required string name required string prefix required string mode required string Allowed values: live test scopes required Array Allowed values: register read admin last_used_at required string | null revoked_at required string | null created_at required string Example ```json { "data": [ { "mode": "live", "scopes": [ "register" ] } ] } ``` --- Page: Mint an API key URL: https://docs.fromenance.com/api/operations/v1api-keys/post # Mint an API key POST /v1/api-keysFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/api-keys \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "mode": "live", "scopes": [ "register" ] }' ``` - Production The secret is returned once and never stored. Test keys write to a sandbox partition and never send replies to real addresses. ## Authorizations - Session - ApiKey ## Request Body application/json object name required string >= 1 characters <= 80 characters mode string Allowed values: live test scopes Array >= 1 items Allowed values: register read admin ## Responses ### 201 Key application/json object id required string name required string prefix required string mode required string Allowed values: live test scopes required Array Allowed values: register read admin last_used_at required string | null revoked_at required string | null created_at required string secret required string Example ```json { "mode": "live", "scopes": [ "register" ] } ``` --- Page: Revoke an API key URL: https://docs.fromenance.com/api/operations/v1api-keysid # Revoke an API key DELETE /v1/api-keys/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/api-keys/key_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^key_[0-9A-Z]{26}$/ Example ```plaintext key_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Revoked application/json object id required string name required string prefix required string mode required string Allowed values: live test scopes required Array Allowed values: register read admin last_used_at required string | null revoked_at required string | null created_at required string Example ```json { "mode": "live", "scopes": [ "register" ] } ``` --- Page: Audit log URL: https://docs.fromenance.com/api/operations/v1audit # Audit log GET /v1/auditFetchcURL ```sh curl --request GET \ --url 'https://api.fromenance.com/v1/audit?limit=50' \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Query Parameters cursor Opaque cursor from a previous response. string Opaque cursor from a previous response. limit Page size, 1 to 200, default 50. string Example ```json 50 ``` Page size, 1 to 200, default 50. action string ## Responses ### 200 Entries application/json object data required Array object id required string actor_id required string | null actor_email required string | null actor_kind required string action required string target required string | null target_id required string | null before after impersonating required boolean at required string next_cursor required string | null Examplegenerated ```json { "data": [ { "id": "example", "actor_id": "example", "actor_email": "example", "actor_kind": "example", "action": "example", "target": "example", "target_id": "example", "before": "example", "after": "example", "impersonating": true, "at": "example" } ], "next_cursor": "example" } ``` --- Page: List site keys with embed snippets URL: https://docs.fromenance.com/api/operations/v1site-keys/get # List site keys with embed snippets GET /v1/site-keysFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/site-keys \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Site keys application/json object data required Array object id required string name required string public_key required string allowed_origins required Array theme required object key additional properties string turnstile_required required boolean normal_rate_per_minute required integer revoked_at required string | null embed required object script required string iframe required string created_at required string Examplegenerated ```json { "data": [ { "id": "example", "name": "example", "public_key": "example", "allowed_origins": [ "example" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1, "revoked_at": "example", "embed": { "script": "example", "iframe": "example" }, "created_at": "example" } ] } ``` --- Page: Create a site key URL: https://docs.fromenance.com/api/operations/v1site-keys/post # Create a site key POST /v1/site-keysFetchcURL ```sh curl --request POST \ --url https://api.fromenance.com/v1/site-keys \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "allowed_origins": [ "https://example.com" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1 }' ``` - Production ## Authorizations - Session - ApiKey ## Request Body application/json object name string >= 1 characters <= 80 characters allowed_origins Array <= 20 items theme object key additional properties string turnstile_required boolean normal_rate_per_minute integer >= 5 <= 6000 Examplegenerated ```json { "name": "example", "allowed_origins": [ "https://example.com" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1 } ``` ## Responses ### 201 Site key application/json object id required string name required string public_key required string allowed_origins required Array theme required object key additional properties string turnstile_required required boolean normal_rate_per_minute required integer revoked_at required string | null embed required object script required string iframe required string created_at required string Examplegenerated ```json { "id": "example", "name": "example", "public_key": "example", "allowed_origins": [ "example" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1, "revoked_at": "example", "embed": { "script": "example", "iframe": "example" }, "created_at": "example" } ``` --- Page: Revoke a site key URL: https://docs.fromenance.com/api/operations/v1site-keysid/delete # Revoke a site key DELETE /v1/site-keys/{id}FetchcURL ```sh curl --request DELETE \ --url https://api.fromenance.com/v1/site-keys/sk_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^sk_[0-9A-Z]{26}$/ Example ```plaintext sk_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Responses ### 200 Revoked application/json object ok required boolean Example ```json { "ok": true } ``` --- Page: Update a site key URL: https://docs.fromenance.com/api/operations/v1site-keysid/patch # Update a site key PATCH /v1/site-keys/{id}FetchcURL ```sh curl --request PATCH \ --url https://api.fromenance.com/v1/site-keys/sk_01J8Z9X0Q3K4M5N6P7R8S9T0V1 \ --header 'Content-Type: application/json' \ --cookie fromenance.session_token= \ --data '{ "name": "example", "allowed_origins": [ "https://example.com" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1 }' ``` - Production ## Authorizations - Session - ApiKey ## Parameters ### Path Parameters id required string /^sk_[0-9A-Z]{26}$/ Example ```plaintext sk_01J8Z9X0Q3K4M5N6P7R8S9T0V1 ``` ## Request Body application/json object name string >= 1 characters <= 80 characters allowed_origins Array <= 20 items theme object key additional properties string turnstile_required boolean normal_rate_per_minute integer >= 5 <= 6000 Examplegenerated ```json { "name": "example", "allowed_origins": [ "https://example.com" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1 } ``` ## Responses ### 200 Site key application/json object id required string name required string public_key required string allowed_origins required Array theme required object key additional properties string turnstile_required required boolean normal_rate_per_minute required integer revoked_at required string | null embed required object script required string iframe required string created_at required string Examplegenerated ```json { "id": "example", "name": "example", "public_key": "example", "allowed_origins": [ "example" ], "theme": { "additionalProperty": "example" }, "turnstile_required": true, "normal_rate_per_minute": 1, "revoked_at": "example", "embed": { "script": "example", "iframe": "example" }, "created_at": "example" } ``` --- Page: Search submissions, registrations, and indicators URL: https://docs.fromenance.com/api/operations/v1search # Search submissions, registrations, and indicators GET /v1/searchFetchcURL ```sh curl --request GET \ --url 'https://api.fromenance.com/v1/search?q=example' \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey read ## Parameters ### Query Parameters q required string >= 2 characters <= 200 characters ## Responses ### 200 Hits application/json object hits required Array object kind required string Allowed values: submission communication indicator id required string title required string subtitle required string href required string Example ```json { "hits": [ { "kind": "submission" } ] } ``` --- Page: Onboarding progress derived from tenant state URL: https://docs.fromenance.com/api/operations/v1setup # Onboarding progress derived from tenant state GET /v1/setupFetchcURL ```sh curl --request GET \ --url https://api.fromenance.com/v1/setup \ --cookie fromenance.session_token= ``` - Production ## Authorizations - Session - ApiKey ## Responses ### 200 Progress application/json object steps required object tenant required boolean domain_verified required boolean forwarding_working required boolean reply_identity required boolean first_stream required boolean footer_deployed required boolean live required boolean domains required Array object id required string domain required string status required string forwarding required object status required string Allowed values: not_started sent working failed sent_at required string | null passed_at required string | null result required string | null to required string Example ```json { "domains": [ { "forwarding": { "status": "not_started" } } ] } ``` --- Page: Security URL: https://docs.fromenance.com/security # Security Fromenance is a communication provenance platform that asks institutions to route customer mail through it, so the security posture has to be finished before the first pilot rather than after. This page is the public overview; a DPA and answers to a full vendor questionnaire are available from your Fromenance contact. By the end you will know how tenants are isolated, what is encrypted with which key, why the verdict path is deterministic, who the subprocessors are, and how to report a vulnerability. ## Threat model in one paragraph The customer facing question is “did my institution send this?”. The attacker’s goals are to make a lure verify, to make a real message fail, to learn who a message was sent to, or to read other institutions’ data. The design answers each: verify codes are recipient bound and checksummed, so a copied footer resolves to Not verified and is flagged as a replay; matching is deterministic, so there is no model to prompt; recipients exist only as keyed hashes; and every tenant owned row carries a tenant id enforced in the data layer. ## Tenant isolation - Every tenant owned table has tenant_id , and every query runs through a scoped repository that requires it. Route handlers cannot build cross tenant queries. - Object storage keys are prefixed per tenant. API keys and site keys are bound to one tenant. - An automated suite asserts no cross tenant read on every endpoint on every change. - Fromenance operators can see per tenant health, rejected inbound reasons, and cross tenant indicator counts. They cannot see submissions or raw mail through those views. ## Encryption and keys | Data | Protection | | Submitter addresses, subjects, source configuration, webhook and source secrets, the recipient hashing secret | AES-256-GCM under a per tenant data key | | Raw MIME and screenshots | AES-256-GCM under the tenant data key, stored in Cloudflare R2 under a per tenant prefix, deleted at the end of the retention window with an audit record | | Per tenant data keys | Wrapped by a master key that exists only in the Worker runtime’s secret store, never in code or configuration files | | API keys | SHA-256 hashed at rest, shown once, scoped, revocable, with last use tracking | | Recipient addresses | Never stored. Registrations carry hmac-sha256: computed with a per tenant secret; the raw address is hashed in your process before the call and rejected by the API if sent in clear | | Content | Never stored in normalized form. A 64 bit SimHash and a SHA-256 are one way | | Transport | TLS everywhere; the API is served only over HTTPS | Secrets rotate: the master key yearly, database and object storage credentials quarterly. There are no long lived credentials in the repository or in CI configuration. ## No model on the verdict path Extraction and matching are regular expressions, MIME parsing, SimHash, and HMAC comparison. Where a vision model is used to read a screenshot, its output is treated as untrusted text that only feeds those same deterministic extractors; it cannot change a verdict. If a language model is added later for lure classification, its output will be a label in a fixed schema that can never change a verdict from Verified. Prompt injection through a forwarded message has no target. ## Inbound trust Mail to a tenant inbox is processed only when it authenticates for a domain the tenant owns: a DKIM signature that verifies for the domain or an ARC chain whose last seal is by it, checked by Fromenance itself on the raw message, and a _fromenance. TXT record whose token matches the tenant. Anything else is held for 24 hours for diagnosis and never answered. Details in Domains and mail . Replies never quote the forwarded message, never include its links or its sender, state only whether a registered communication matches, and are sent only to the customer who asked, from the tenant’s reply identity. ## Abuse handling - The verify page is rate limited per IP and per site key, requires an allowed origin, and escalates to Cloudflare Turnstile when a key exceeds its normal rate. - Anyone can forward anything through a tenant’s rule, including attempts to poison the fraud list. Fraud list entries come only from analyst action or confirmed campaign clustering, never from a single submission. - Footer replay is reported to the customer as Not verified and flagged internally with high priority. - Provider webhook signatures are verified on every request with a 5 minute replay window where the provider supplies a timestamp. Outbound webhooks are HMAC-SHA256 signed with a per endpoint secret. ## Platform | Layer | Provider | | API, inbound processing, queues, cron, edge lookups, object storage, OCR | Cloudflare Workers, Queues, D1, R2, Workers AI | | System of record | Neon Postgres | | Email in and out | Resend | | Billing | Stripe | | Error monitoring | Sentry | Admin sign in is by magic link or passkey through Better Auth. Operator access sits behind Cloudflare Access with a hardware key requirement on top of application auth, and every operator action is audited. Deployments run only from CI on the main branch, with dependency scanning. ## Subprocessors Cloudflare (compute, storage, queues, edge network), Neon (Postgres), Resend (email delivery and inbound), Stripe (billing). Sentry receives error events that contain request ids and stack traces, not message content. The DPA lists each with its role and region. ## Data residency and retention All storage and processing is in the United States. Retention defaults to 90 days and is tenant configurable from 7 to 730 days; submitter addresses are purged 30 days after the reply; rejected inbound is deleted after 24 hours. Full detail on Retention and privacy . ## Compliance roadmap SOC 2 Type 1 is targeted within 12 months of the first paying tenant. Until then the controls above, the DPA, and the tenant isolation test suite results are available for review. ## Responsible disclosure Report vulnerabilities to security@fromenance.com . Include the request id or URL, steps to reproduce, and the impact you believe it has. You will get an acknowledgement within two business days and a status update when the issue is triaged. Please do not test against tenants you do not own, do not access data that is not yours, and give us reasonable time to fix before disclosing publicly. We do not pursue legal action against good faith research that follows these rules. --- Page: Changelog URL: https://docs.fromenance.com/changelog # Changelog Fromenance is a communication provenance platform, and this changelog records what shipped in each release of the API, the verify widget, and the admin app. Dates are the month the release went live. By the end you will know what the current release contains and where breaking changes will be announced. ## 2026-09: initial release The whole of Phase 1, verification, in one release. Foundation and core - Bun monorepo with the API, admin app, marketing site, docs site, and the shared core, database, UI, and verify widget packages. - Deterministic normalization, 64 bit SimHash over word 3-gram shingles, Hamming distance matching, checksummed Crockford verify codes, recipient HMAC, and indicator extraction, producing identical results in Node, Bun, and Cloudflare Workers. - Drizzle schema for every table with tenant_id on every tenant owned table and no raw recipient column; tenant scoped repository; migrations; demo tenant seed. API and registry (Gate 1) - API keys with fr_live_ and fr_test_ prefixes and register , read , admin scopes; site keys with origin checks; magic link and passkey sessions. - Tenants, users, five roles, audit log on every write. - Domains with the _fromenance. TXT record, one minute polling, daily re-check, suspension on a missing record, and the roles trust , sender , reply . - POST /v1/communications , /batch (1,000 items), /reserve , PATCH complete, Idempotency-Key stored 24 hours, per key rate limits. - Edge mirror of tenants, domains, keys, and active verify codes for sub 150 ms registration and lookups. - Dependency free reference implementations of recipient hashing and content fingerprinting, published in the docs and tested against the API on every build. Inbound and verdicts (Gate 2) - Inbound mail on verify.fromenance.com and ingest.fromenance.com , raw MIME encrypted in object storage under a tenant prefix. - DKIM, ARC, and SPF verification in the Worker; the trust check with eight rejection reasons and a 24 hour holding area. - Queue pipeline for ingest, match, reply, and intelligence with dead letter handling. - Extraction of code, recipient, fingerprint, headers, and indicators; the pure decide() verdict function covering every row of the matching table including footer replay; idempotent repeat submissions. - MJML reply templates with the locked verdict block, branded reply identities through Resend with DNS records returned to the tenant, and the minimal reply identity for pilots. - Analyst overrides, fraud list, fraud fingerprints, retro registration with coverage gap flagging. - Journal and BCC ingestion at journal-@ingest.fromenance.com . Web verify and adapters (Gate 3) - POST /v1/public/submit with site key and origin checks, per IP and per key rate limits, and Turnstile escalation; synchronous verdicts for text and .eml , poll tokens for screenshots. - Screenshot OCR feeding the same deterministic extractors. - The embeddable widget on cdn.fromenance.com/verify.js and embed.html , with tenant theming, script and iframe modes. - ESP adapters behind one interface: Resend, SendGrid, Amazon SES, Postmark, Mailgun, Salesforce Marketing Cloud, Braze, and a generic signed webhook; reserve flow with 24 hour expiry; source health with silence alerts. - Outbound webhooks verdict.created , submission.replay_detected , indicator.new , HMAC-SHA256 signed, retried with backoff for about a day, replayable. Admin app (Gate 4) - App shell with dark and light themes, tenant switcher, global search, dense tables with column pick, sort, saved filters, and CSV export. - Six step setup wizard with the forwarding test. - Overview, Submissions with detail drawer and override, Indicators with STIX 2.1 and CSV export, Fraud list, Campaigns placeholder, Registrations with coverage, Sources with health and test event, Domains, Verify page with live preview, Reply templates with preview and send test, Webhooks with delivery log and replay, Settings. Docs (Gate 5) - This site: quickstart, concepts, mail platform guides, one page per adapter, verify page, verifications, webhooks with signature samples in TypeScript, Python, and Go, admin, generated API reference, security, and this changelog. Every TypeScript sample is compiled in CI and executed against a sandbox tenant. ## Versioning The API is versioned in the path (/v1 ). Additive changes (new fields, new events, new optional parameters) ship without notice here beyond a changelog entry. Anything that removes or renames a field, changes a status code, or changes signature schemes will be announced on this page at least 90 days ahead and shipped under a new version prefix.