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, and how reservations and batches work.
Three paths in
Section titled “Three paths in”| Path | Fits | Body available for fingerprinting | Page |
|---|---|---|---|
API, ideally through @fromenance/sdk |
Your own application code | 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 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, up to 998 characters |
from_address |
yes | The visible From. Should be on a domain you own with the sender role |
sent_at |
yes | ISO 8601 UTC |
subject_fingerprint |
no | SHA-256 hex of the normalized subject |
content_fingerprint |
no | { simhash: "0x9a4d...", sha256: "e3b0..." } of the normalized body. Strongly recommended: without it the registration matches on code plus recipient only |
link_domains |
no | Up to 50 registered domains that appear in links, used to whitelist during matching |
template_id, campaign_id |
no | Your own identifiers, up to 120 characters, 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 | 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:" + 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. The SDK’s recipientHash(address, tenantSecret) is exported for callers that build the request themselves. The tenant secret is issued with your tenant and is different from your API keys; keep it in the same secret store.
Content fingerprint
Section titled “Content fingerprint”fingerprintContent({ html, text }) from the SDK returns simhashHex, sha256, and tokens. Pass the rendered email exactly as it goes out, before the footer is added (the normalizer strips the footer anyway). The SDK skips the fingerprint when tokens is 0, for example for an image only message; matching then relies on the code plus recipient.
The normalization pipeline is shared between the SDK and the API and produces identical bits in Node, Bun, and Workers, so a customer’s forward of the message lands within the Hamming threshold (6 by default) of what you registered. The normalized text is never persisted anywhere.
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. The SDK’s footer(code, { institutionName, verifyAddress, verifyPageUrl }) renders text and HTML:
Not sure this email is from Northfield Bank? Forward it to verify@northfieldbank.exampleor enter code KX73-PQ9G at northfieldbank.example/verify. Reference: KX73-PQ9GIn a template, put the code 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, %%=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.
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 SDK uses the message id as the default key for register(), so 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. * Each item is hashed and fingerprinted locally; per item results come back in input order. */import { Fromenance } from "@fromenance/sdk";
export interface StatementEmail { to: string; messageId: string; html: string;}
export async function registerStatementRun( fr: Fromenance, emails: StatementEmail[],): Promise<{ registered: number; failed: number; codesByMessageId: Map<string, string> }> { const codesByMessageId = new Map<string, string>(); let registered = 0; let failed = 0;
// 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 result = await fr.registerBatch( chunk.map((e) => ({ to: e.to, from: "statements@northfieldbank.example", messageId: e.messageId, subject: "Your September statement is ready", html: e.html, templateId: "monthly-statement-v7", sentAt: new Date(), })), { idempotencyKey: `statements-2026-09-batch-${i / 1000}` }, ); 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 fr = new Fromenance({ apiKey, tenantSecret, baseUrl: process.env.FROMENANCE_API_ORIGIN ?? "https://api.fromenance.com", }); const run = Array.from({ length: 3 }, (_, n) => ({ to: `customer${n}@example.com`, messageId: `<stmt-${crypto.randomUUID()}@northfieldbank.example>`, html: `<p>Your statement for September is ready. Balance: $${(n + 1) * 100}.00</p>`, })); const out = await registerStatementRun(fr, 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: 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 { Fromenance } from "@fromenance/sdk";
export async function reserveThenComplete(fr: Fromenance, to: string) { // 1. Reserve. The code is bound to this recipient and expires in 24 hours unless completed. const reserved = await fr.reserve({ to, 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()}@northfieldbank.example>`;
// 3. Complete. Not needed when the ESP webhook is connected and echoes the code back. const completed = await fr.complete(reserved.id, { to, messageId, from: "alerts@northfieldbank.example", sentAt: new Date(), html: "<p>We noticed a sign in from a new device.</p>", }); 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"); const fr = new Fromenance({ apiKey, tenantSecret, baseUrl: process.env.FROMENANCE_API_ORIGIN ?? "https://api.fromenance.com", }); await reserveThenComplete(fr, "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.