Skip to content

Generic webhook

Fromenance is a communication provenance platform, and the generic adapter covers any system that can make an HTTP call after a send: a core banking platform, a print and mail vendor’s notification feed, an internal queue consumer. You post one JSON event per send, signed with the source secret, and Fromenance registers it.

By the end you will have a generic source, a signed POST in your send path, and events showing on the source’s health panel.

Terminal window
curl -X POST https://api.fromenance.com/v1/sending-sources \
-H "Authorization: Bearer fr_live_..." \
-H "Content-Type: application/json" \
-d '{"name":"Core banking notices","kind":"generic","expected_daily_volume":5000,"silence_alert_minutes":720}'

The response includes webhook_url (https://api.fromenance.com/v1/hooks/esp/src_...) and secret (whsec_...), shown once.

Body: one event, or { "events": [ ... ] }.

Field Required Notes
message_id yes Your message id or the RFC 5322 Message-ID
recipient yes The recipient address. Hashed with your tenant secret on arrival and never stored
sent_at no ISO 8601; defaults to now
from no The visible From address
code no A code you reserved and placed in the footer. Completes the reservation
template_id, campaign_id no Your identifiers
html, text, subject no The rendered message. Fingerprinted on arrival and not stored. Include it when you can; it is what lets a forward without a readable code still match

Header: X-Fromenance-Signature: sha256=<hex HMAC-SHA256 of the raw request body keyed with the source secret>. Sign the exact bytes you send; re-serializing JSON on either side changes the signature.

Response: { "ok": true, "registered": n, "completed": n, "ignored": n }. ignored counts events for a message and recipient already registered, so redelivery is harmless.

samples/generic-webhook.ts
/**
* Generic webhook adapter: post a send event to Fromenance from any system that can make an HTTP call.
* The raw body is signed with HMAC-SHA256 using the source secret and sent as
* X-Fromenance-Signature: sha256=<hex>
*
* Environment:
* FROMENANCE_SOURCE_WEBHOOK_URL https://api.fromenance.com/v1/hooks/esp/src_... (shown when the source is created)
* FROMENANCE_SOURCE_SECRET whsec_... (shown once when the source is created)
*/
import { createHmac } from "node:crypto";
export interface GenericSendEvent {
message_id: string;
recipient: string;
sent_at?: string;
from?: string;
/** A reserved verify code you placed in the footer. Completes the reservation. */
code?: string;
template_id?: string;
campaign_id?: string;
/** Optional rendered body so Fromenance can fingerprint it. The body is fingerprinted and not stored. */
html?: string;
text?: string;
subject?: string;
}
export function signGenericEvent(secret: string, rawBody: string): string {
return `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
}
export async function postSendEvents(
webhookUrl: string,
secret: string,
events: GenericSendEvent[],
f: typeof fetch = fetch,
): Promise<{ registered: number; completed: number; ignored: number }> {
const rawBody = JSON.stringify(events.length === 1 ? events[0] : { events });
const res = await f(webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-fromenance-signature": signGenericEvent(secret, rawBody),
},
body: rawBody,
});
if (!res.ok) throw new Error(`hook rejected: ${res.status} ${await res.text()}`);
return (await res.json()) as { registered: number; completed: number; ignored: number };
}
export async function main(): Promise<void> {
const url = process.env.FROMENANCE_SOURCE_WEBHOOK_URL;
const secret = process.env.FROMENANCE_SOURCE_SECRET;
if (!url || !secret) throw new Error("Set FROMENANCE_SOURCE_WEBHOOK_URL and FROMENANCE_SOURCE_SECRET");
const result = await postSendEvents(url, secret, [
{
message_id: `<${crypto.randomUUID()}@northfieldbank.example>`,
recipient: "jane.doe@example.com",
sent_at: new Date().toISOString(),
from: "alerts@northfieldbank.example",
template_id: "fraud-alert-v3",
html: "<p>We noticed a sign in from a new device.</p>",
},
]);
console.log(result);
}

Equivalent with curl:

Terminal window
BODY='{"message_id":"<n-1042@northfieldbank.example>","recipient":"jane.doe@example.com","sent_at":"2026-09-24T14:42:10Z","from":"notices@northfieldbank.example","template_id":"rate-change-v1"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$FROMENANCE_SOURCE_SECRET" | awk '{print $NF}')
curl -X POST "$FROMENANCE_SOURCE_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "X-Fromenance-Signature: sha256=$SIG" \
--data "$BODY"

Fromenance recomputes the HMAC over the raw body with the source secret and compares in constant time. A missing or wrong signature returns 401 and increments signature_failures on the source. There is no timestamp in this scheme; if replay of a captured event matters in your environment, include sent_at and treat the ignored count as the dedupe signal, or use the SDK path instead, which authenticates with your API key.

When the footer must carry the code before the send exists, reserve first and pass the code in the event:

  1. POST /v1/communications/reserve with recipient_hash and template_id, get verify_code.
  2. Render the footer with the code and send.
  3. Post the event with code set. The reservation is completed with the message id and time; if you include html or text, the fingerprint is added too.

If you render the message in code you control, @fromenance/sdk register() does all of this in one call without a source. The generic adapter is for the systems where that is not possible.

silent is raised when no event arrives within silence_alert_minutes (default 1440); set it to a value longer than your quietest sending window. coverage_gaps counts reservations that expired after 24 hours without an event. Rotate the secret with POST /v1/sending-sources/{id}/rotate-secret; the old secret stops working immediately.