Registering communications
Fromenance is a communication provenance platform, and registration is the act of telling it, at send time, that a specific communication went to a specific recipient. Everything downstream depends on coverage, so there are three ways in and all three are first class.
By the end you will know which path fits each sending system, what a registration carries, how to hash and fingerprint correctly with no dependencies, and how reservations and batches work. Every request on this page is a plain HTTPS call; the Integration guide walks them in order.
Three paths in
Section titled “Three paths in”| Path | Fits | Body available for fingerprinting | Page |
|---|---|---|---|
| REST API from your own code | Your own application or sending pipeline | Yes, computed locally | This page |
| ESP webhook adapter | SendGrid, Amazon SES, Resend, Postmark, Mailgun, Salesforce Marketing Cloud, Braze, or anything that can call a webhook | Depends on the provider; see each adapter page | Resend, SendGrid, Amazon SES, Postmark, Mailgun, Salesforce Marketing Cloud, Braze, Generic webhook |
Journal or BCC copy to journal-<slug>@ingest.fromenance.com |
Core banking and statement vendors you cannot change | Yes, from the copy | Below |
Authentication
Section titled “Authentication”Registration calls use an API key with the register scope as a bearer token: Authorization: Bearer fr_live_.... Keys are minted in Settings, API keys, shown once, hashed at rest, and revocable. fr_test_ keys write to a sandbox partition: registrations and submissions are marked sandbox: true, and replies never reach a real customer. Use a test key while integrating and a live key for production.
What a registration carries
Section titled “What a registration carries”POST /v1/communications accepts:
| Field | Required | Notes |
|---|---|---|
channel |
no | email, the default and the only value in v1 |
recipient_hash |
yes | hmac-sha256:<64 lowercase hex> of the canonical recipient address keyed with your tenant secret |
message_id |
yes | The RFC 5322 Message-ID or the ESP’s message id, 1 to 998 characters |
from_address |
yes | The visible From, 3 to 320 characters. Should be on a domain you own with the sender role |
sent_at |
yes | ISO 8601 with offset or Z |
subject_fingerprint |
no | 64 hex: SHA-256 of the normalized subject |
content_fingerprint |
no | { "simhash": "0x9a4d3c2b1f0e8d7c", "sha256": "<64 hex>" } of the normalized body. Both fields are required when the object is present; there is no way to send the raw body instead. Strongly recommended: without it the registration matches on code plus recipient only |
link_domains |
no | Up to 50 registered domains (253 characters each) that appear in links, used to whitelist during matching |
template_id, campaign_id |
no | Your own identifiers, up to 120 characters or null, shown in the admin app and returned on verdicts |
provider |
no | api (default), sendgrid, ses, resend, postmark, mailgun, sfmc, braze, relay, journal, generic. Groups the registration under the matching source for coverage |
expires_at |
no | ISO 8601. Defaults to now plus your retention setting (90 days by default) |
verify_code |
no | Only when completing a code you reserved earlier; otherwise omit and one is issued |
Response, 201 Created:
{ "id": "com_01J...", "verify_code": "KX73-PQ9G", "expires_at": "2026-12-26T14:42:10Z", "sandbox": false }Every registration is unique on (tenant, verify code). Rate limit: 600 requests per minute per key on register and reserve; a 429 carries Retry-After.
Recipient hashing
Section titled “Recipient hashing”The API never receives a recipient address. Compute:
recipient_hash = "hmac-sha256:" + lowercase_hex( HMAC-SHA256( key = tenant secret, message = canonical(address) ) )canonical(address) = trim, take the part inside <> if a display name is present, lowercase, strip a leading "mailto:""Jane Doe" <Jane@Bank.com > and jane@bank.com hash identically. WebCrypto, Python hmac, and openssl dgst -sha256 -hmac all produce the same value; code for each is on the Integration guide. The tenant secret is shown in Settings, is different from your API keys, and never travels to the API; keep it in the same secret store as a live key.
Content fingerprint
Section titled “Content fingerprint”The fingerprint is two hashes over the same normalized text: a 64 bit SimHash over word 3-gram shingles hashed with FNV-1a 64 (sent as 0x plus 16 hex characters), and a SHA-256 (64 hex characters). Normalization strips HTML to text, unwraps gateway rewritten links, removes forward chrome and the Fromenance footer, cuts at a signature delimiter, replaces every URL with the registered domain of its host, lowercases, and collapses whitespace. Fromenance runs the identical pipeline on the customer’s forward at verify time, so a forward lands within the Hamming threshold (6 by default) of what you registered. The normalized text is never persisted anywhere.
Pass the rendered email exactly as it goes out, before the footer is added (the normalizer strips the footer anyway). When the normalized text is empty, for example an image only message, omit content_fingerprint; matching then relies on the code plus recipient.
A dependency free TypeScript implementation, tested bit for bit against the reference, is on the Integration guide. It is the same file the samples on this page import.
The footer and the {{fromenance_code}} merge tag
Section titled “The footer and the {{fromenance_code}} merge tag”Place the verify code in the message where your templating system substitutes it. The generic placeholder is {{fromenance_code}}; each ESP has its own syntax, listed on its adapter page (-fromenance_code- on SendGrid legacy templates, %%=v(@fromenance_code)=%% on Marketing Cloud, a Liquid attribute on Braze). The code appears twice on purpose so one copy survives rewrapping and screenshot OCR.
Not sure this email is from JT Merlin Bank? Forward it to verify@jtmerlin.exampleor enter code {{fromenance_code}} at jtmerlin.example/verify. Reference: {{fromenance_code}}An HTML version with the address and page as links is fine; keep the sentence and the bare Reference: line as visible text.
Idempotency
Section titled “Idempotency”Send Idempotency-Key (up to 200 characters) on every creating POST. Fromenance stores the response for 24 hours per (tenant, key). A replay with the same body returns the stored response with the header idempotent-replayed: true; a replay with a different body is rejected with 422 idempotency-mismatch. The message id is a good key: a retried send registers once.
POST /v1/communications/batch takes { "items": [ ...1 to 1,000 registrations ] } and returns per item results in input order. One bad item does not fail the batch. Rate limit: 6,000 items per minute per key. Codes are allocated in one pass, so a 1,000 item statement run completes in about a second.
{ "results": [ { "index": 0, "ok": true, "id": "com_01J...", "verify_code": "KX73-PQ9G", "expires_at": "2026-12-26T14:42:10Z" }, { "index": 1, "ok": false, "error": "invalid verify code" } ], "registered": 1, "failed": 1}/** * Register a statement run: up to 1,000 rendered emails in one call, with plain fetch. Nothing to install. * Each item is hashed and fingerprinted locally; per item results come back in input order. */import { fingerprintContent, fingerprintSubject } from "./fingerprint.ts";import { recipientHash } from "./recipient-hash.ts";
export interface StatementEmail { to: string; messageId: string; html: string;}
export interface BatchItemResult { index: number; ok: boolean; id?: string; verify_code?: string; expires_at?: string; error?: string;}
export interface BatchResponse { results: BatchItemResult[]; registered: number; failed: number;}
const API = (process.env.FROMENANCE_API_ORIGIN ?? "https://api.fromenance.com").replace(/\/$/, "");
export async function registerStatementRun( apiKey: string, tenantSecret: string, emails: StatementEmail[],): Promise<{ registered: number; failed: number; codesByMessageId: Map<string, string> }> { const codesByMessageId = new Map<string, string>(); let registered = 0; let failed = 0; const subject = "Your September statement is ready";
// Chunk at 1,000 items, the batch limit. 6,000 items per minute are accepted per key. for (let i = 0; i < emails.length; i += 1000) { const chunk = emails.slice(i, i + 1000); const items = await Promise.all( chunk.map(async (e) => { const fp = await fingerprintContent({ html: e.html }); return { channel: "email", recipient_hash: await recipientHash(e.to, tenantSecret), message_id: e.messageId, from_address: "statements@jtmerlin.example", sent_at: new Date().toISOString(), subject_fingerprint: await fingerprintSubject(subject), content_fingerprint: fp.tokens > 0 ? { simhash: fp.simhash, sha256: fp.sha256 } : undefined, template_id: "monthly-statement-v7", provider: "api", }; }), ); const res = await fetch(`${API}/v1/communications/batch`, { method: "POST", headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json", "idempotency-key": `statements-2026-09-batch-${i / 1000}`, }, body: JSON.stringify({ items }), }); if (!res.ok) throw new Error(`batch rejected: ${res.status} ${await res.text()}`); const result = (await res.json()) as BatchResponse; registered += result.registered; failed += result.failed; for (const item of result.results) { const source = chunk[item.index]; if (item.ok && item.verify_code && source) codesByMessageId.set(source.messageId, item.verify_code); else if (!item.ok) console.warn(`item ${item.index} rejected: ${item.error}`); } } return { registered, failed, codesByMessageId };}
export async function main(): Promise<void> { 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"); const run = Array.from({ length: 3 }, (_, n) => ({ to: `customer${n}@example.com`, messageId: `<stmt-${crypto.randomUUID()}@jtmerlin.example>`, html: `<p>Your statement for September is ready. Balance: $${(n + 1) * 100}.00</p>`, })); const out = await registerStatementRun(apiKey, tenantSecret, run); console.log(`registered ${out.registered}, failed ${out.failed}`);}Reserve
Section titled “Reserve”Use POST /v1/communications/reserve when the code must exist before the send does: ESPs that merge fields at send time and cannot hand back the rendered body. The body is optional; pass what you know:
| Field | Notes |
|---|---|
recipient_hash |
Bind the code to the recipient now so a send event with a different recipient cannot claim it |
template_id, campaign_id, from_address, provider |
Recorded on the reservation |
expires_in_hours |
1 to 72, default 24 |
Response is the same shape as register with the code to inject. The reservation is completed either by the ESP’s send event carrying the code (each adapter reads it from the provider’s custom field), or by PATCH /v1/communications/{id} with message_id, sent_at, and optionally the fingerprint. A reservation not completed before it expires is marked expired, counts as a coverage gap on the source, and a later PATCH returns 409.
/** * Reserve flow with plain fetch: get a verify code before the send exists, inject it through your ESP's merge * field, then complete the registration once the message id and send time are known. ESP adapters complete * reservations automatically when the send event carries the code. */import { fingerprintContent } from "./fingerprint.ts";import { recipientHash } from "./recipient-hash.ts";
const API = (process.env.FROMENANCE_API_ORIGIN ?? "https://api.fromenance.com").replace(/\/$/, "");
async function call<T>(apiKey: string, method: string, path: string, body: unknown): Promise<T> { const res = await fetch(`${API}${path}`, { method, headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${method} ${path} failed: ${res.status} ${await res.text()}`); return (await res.json()) as T;}
export async function reserveThenComplete(apiKey: string, tenantSecret: string, to: string) { const recipient_hash = await recipientHash(to, tenantSecret);
// 1. Reserve. The code is bound to this recipient and expires in 24 hours unless completed. const reserved = await call<{ id: string; verify_code: string; expires_at: string }>( apiKey, "POST", "/v1/communications/reserve", { recipient_hash, template_id: "fraud-alert-v3", provider: "sendgrid" }, ); console.log(`reserved ${reserved.id} code ${reserved.verify_code}`);
// 2. Send through your ESP with the code in the footer merge field, for example // custom_args.fromenance_code = reserved.verify_code on SendGrid. const messageId = `<${crypto.randomUUID()}@jtmerlin.example>`; const html = "<p>We noticed a sign in from a new device.</p>";
// 3. Complete. Not needed when the ESP webhook is connected and echoes the code back. const fp = await fingerprintContent({ html }); const completed = await call<{ status: string; completed_at: string | null }>( apiKey, "PATCH", `/v1/communications/${reserved.id}`, { message_id: messageId, from_address: "alerts@jtmerlin.example", sent_at: new Date().toISOString(), content_fingerprint: fp.tokens > 0 ? { simhash: fp.simhash, sha256: fp.sha256 } : undefined, }, ); console.log(`status ${completed.status}, completed_at ${completed.completed_at}`); return completed;}
export async function main(): Promise<void> { 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"); await reserveThenComplete(apiKey, tenantSecret, "jane.doe@example.com");}Journal or BCC
Section titled “Journal or BCC”Add journal-<slug>@ingest.fromenance.com (journal_address on GET /v1/tenant) as a BCC or journaling target on the sending system. Fromenance receives the full copy, checks that the From is on a domain with the sender role, hashes the customer recipient (the To address that is not the journal inbox), fingerprints the body, registers it under the Journal source, and deletes the raw copy. If the footer already carries a reserved code, the reservation is completed; if a static per template code is reused for another recipient, a fresh code bound to that recipient is registered instead.
The reply to the customer still needs a code in the footer, so this path pairs with the reserve flow or with a static per template code. A copy whose From is not on a sender domain is dropped and logged as rejected on the source.
Reading a registration back
Section titled “Reading a registration back”GET /v1/communications/{id} (scope read) returns the registration with status (reserved, registered, expired), retro (created by an analyst override), and every verification made against it with its submission id, outcome, rule, and time. GET /v1/communications searches by code, message id, template, and date.