Skip to content

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.

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

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.

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.

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.

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.

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.example
or enter code KX73-PQ9G at northfieldbank.example/verify. Reference: KX73-PQ9G

In 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.

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
}
samples/register-batch.ts
/**
* 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}`);
}

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.

samples/reserve-complete.ts
/**
* 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");
}

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.

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.