Enrichers protocol reference
An enricher is a server-side function the Notifizz orchestrator calls at notification time to fetch live data — a user profile, an order, a feature flag, anything you don’t want to copy into Notifizz. The Node SDK (@notifizz/nodejs) wraps the protocol; this page documents it so any HTTP-capable runtime can host enrichers.
TL;DR
- One endpoint, one body shape. Discovery and enricher execution both go through
POST {webhookUrl}with a body-only envelope. No headers, no query string. - Envelope:
{ payload, signature }wherepayloadis a JSON-encoded discriminated union andsignature = HMAC-SHA256(signingSecret, payload). - Always 200. The customer’s webhook responds with
{ ok: true, result }or{ ok: false, error: { code, message } }— errors are encoded in the body, never in the HTTP status. The customer’s controller is a one-liner. - Cache policy declared at registration:
false,{ ttl: number | string }, or default (1h). - Idempotent handlers — the backend may retry after a network failure.
End-to-end flow
Customer registers enrichers + events
client.enricher(name, options) and client.declareEvent(name, options) register locally. Each registration carries schemas, a cache policy (enrichers), and a handler.Customer mounts the dispatch route
return notifizz.dispatch(req.body). Requires webhookSigningSecret in the constructor.Customer declares the URL in the dashboard
Backend discovers
kind: 'discovery' envelope. The customer responds with { ok: true, result: { enrichers, events? } }.Orchestrator calls
enrichWith(name, params), the backend POSTs a kind: 'execute' envelope. The customer SDK verifies the HMAC, validates the input against the registered schema, runs the handler, and returns { ok: true, result: <output> }.Wire envelope
Every request from Notifizz has the same shape:payload field is a string, not a nested object. The signature covers it byte for byte, so signature stability does not depend on canonical JSON serialisation (object key order, whitespace, number encoding). The customer just forwards req.body to notifizz.dispatch().
Signature formula
payload is the verbatim string the SDK received as body.payload. No timestamp prefix, no header, no concatenation — the timestamp lives inside the payload (see below) and is therefore implicitly signed.
The Node SDK exports signDispatchPayload(secret, payload): string so tests and tools don’t duplicate the formula.
Anti-replay
The inner payload carries a numerictimestamp (millisecond epoch). The verifier rejects payloads whose timestamp is more than ±5 minutes off the local clock. The constant is exported as TIMESTAMP_TOLERANCE_MS.
Inner payload — discriminated union
After verifying the signature,dispatch() parses payload as JSON and dispatches on kind.
Discovery
input / output (enrichers) and schema (events) are JSON Schema Draft 2019-09. The Node SDK derives them from Zod schemas at registration time. events is optional and unrelated to enricher execution — it populates the dashboard catalog and the orchestrator AI context. Server-side events validation is audit-only (never blocking) — see Event Tracking.
Execute
params matches the registered input schema for the named enricher. The customer responds with the handler return value:
Error response
On any failure, dispatch returns{ ok: false, error: { code, message, details? } } with HTTP status 200. The Notifizz backend gateway switches on error.code to translate the failure back into a typed domain error.
error.code | Cause | Backend gateway translation |
|---|---|---|
envelope-invalid | Body shape unparseable, missing payload or signature, decoded payload missing fields. | EnricherHttpError (status 400). |
hmac-invalid | Signature does not match. | EnricherHttpError (status 401). |
stale-timestamp | Payload timestamp outside ±5min. | EnricherHttpError (status 401). |
kind-unknown | Payload kind is not 'discovery' nor 'execute'. | EnricherHttpError (status 400). |
enricher-not-found | Execute on a name not registered on the customer’s client instance. | EnricherHttpError (status 404). |
input-invalid | params failed Zod / Ajv validation against the registered input. details carries the issue tree. | EnricherHttpError (status 400). |
handler-error | The customer’s handler threw. | EnricherHttpError (status 500). |
internal | Anything not classified above. | EnricherHttpError (status 500). |
DispatchErrorCode union.
Cache policy
Declared at registration:| Value | Behaviour |
|---|---|
false | The customer endpoint is called every time. Use for “always live” data. |
{ ttl: number } | Cache for number milliseconds. |
{ ttl: "1h" } | Cache for the parsed duration ("30s", "5m", "1h", "24h", "7d"). |
undefined (no cache) | Default: 1 hour. |
Local development
The customer enricher service must be reachable from the Notifizz backend. For local development:- ngrok —
ngrok http 3000, point the dashboard at the public URL. - cloudflared —
cloudflared tunnel --url http://localhost:3000.
FAQ
HMAC fails with `hmac-invalid` — what's wrong?
HMAC fails with `hmac-invalid` — what's wrong?
webhookSigningSecret mismatch between dashboard and constructor; (2) the customer’s framework re-serialised the body before passing it to dispatch() — the SDK signs and verifies the verbatim payload string, so re-stringifying the parsed inner object would change the bytes. Forward req.body as-is. Modern framework body parsers preserve the envelope as JSON and never touch the inner payload string.`stale-timestamp` errors but my code is correct.
`stale-timestamp` errors but my code is correct.
Why is the body always 200 even on errors?
Why is the body always 200 even on errors?
return notifizz.dispatch(req.body). Their framework writes 200 from the return, no status-code juggling. Second, the protocol is framework-neutral — it works the same on Express, Fastify, NestJS, Hono, Cloudflare Workers, Vercel Edge. The Notifizz backend gateway switches on error.code to translate failures back into typed domain errors. The HTTP status is no longer the contract.My enricher is called every time despite `cache: { ttl: '1h' }`.
My enricher is called every time despite `cache: { ttl: '1h' }`.
(orgId, enricherName, paramsHash). If the params differ on every call (timestamp in the input, random ID, …), every call is a miss. Audit what your campaign passes — typically you want to cache by a stable user/order id, not by request-time data.Right TTL for a user-profile enricher?
Right TTL for a user-profile enricher?
false or a short TTL ("30s"). If “yesterday’s profile is fine”, "1h" keeps load off your DB without surprising users. Default is 1h — start there and tune from observability.Enricher times out — what does the backend do?
Enricher times out — what does the backend do?
Empty or null response — does the campaign still send?
Empty or null response — does the campaign still send?
displayName is set), guard explicitly. There is no implicit “skip if empty”.See also
Enrichers tutorial
Node.js SDK reference
client.enricher(), client.dispatch(), error classes.Keys and environments
webhookSigningSecret comes from.