Skip to main content

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, Java and PHP SDKs wrap 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 } where payload is a JSON-encoded discriminated union and signature = 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

1

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

Customer mounts the dispatch route

A single POST route forwards the body: return notifizz.dispatch(req.body). Requires webhookSigningSecret in the constructor.
3

Customer declares the URL in the dashboard

The Notifizz dashboard stores the enricher URL per environment.
4

Backend discovers

On startup or refresh, the backend POSTs a kind: 'discovery' envelope. The customer responds with { ok: true, result: { enrichers, events? } }.
5

Orchestrator calls

When a campaign step 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> }.
6

Backend caches

Per the cache policy declared at registration. Cache key is (orgId, enricherName, params). On cache hit, the customer endpoint is not called.

Wire envelope

Every request from Notifizz has the same shape:
The 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. All three backend SDKs export signDispatchPayload(secret, payload) so tests and tools don’t duplicate the formula.

Anti-replay

The inner payload carries a numeric timestamp (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

No further fields. The customer responds with the unified catalogue:
input / output (enrichers) and schema (events) are JSON Schema Draft 2019-09. The Node SDK derives them from Zod schemas at registration time; the Java and PHP SDKs build them with their JsonSchema helper. 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. Notifizz switches on error.code to translate the failure back into a typed domain error. Codes are exported as the DispatchErrorCode union.

Cache policy

Declared at registration: Caching is server-side. The SDK only declares the policy — Notifizz enforces it. The cache key is built from the org id, the enricher name, and the serialised params.

Local development

The customer enricher service must be reachable from Notifizz. Locally, the first-party dev tunnel does it without a public URL; a public tunnel remains the fallback. Full guide: local development tunnel. In-process dev tunnel (Node SDK) — no public URL, no third-party binary. startDevTunnel() holds a connection open to Notifizz and hands the envelopes it receives straight to your dispatch():
It authenticates with the environment secrets you already pass to the constructor, and the returned handle exposes stop() for shutdown. Notifizz accepts the dev tunnel on non-production environments only — a production sdkSecretKey is rejected. The contract is unchanged: envelopes still carry their HMAC and dispatch() still verifies it. Only the transport differs. In-process dev tunnel (Java) — the same bridge, auto-configured. Add the notifizz-dev module in a dev or test scope and set notifizz.dev.enabled=true in your development profile; it opens on application start and needs no command of its own. A public tunnel — for stacks with no in-process bridge, or when you would rather expose a real URL: ngrok http 3000 or cloudflared tunnel --url http://localhost:3000, then register the public URL on the environment. To synthesise a signed envelope from a unit test:

FAQ

Two usual suspects: (1) 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.
Clock drift — the verifier tolerates ±5 minutes between the signing clock (Notifizz backend) and your verifying clock. NTP your enricher host. Containers with broken time sync are a common cause.
Two reasons. First, customer controllers stay one line: 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. Notifizz switches on error.code to translate failures back into typed domain errors. The HTTP status is no longer the contract.
Caching is by (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.
Depends on data freshness. If a profile change must reach a notification within seconds, use 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.
Notifizz retries with backoff. The handler must be idempotent for this reason — retried identical params should produce identical output. After a few attempts the orchestrator marks the step as failed; the campaign can branch on that or surface an implementation task.
Yes — the orchestrator receives the empty result and continues. If your campaign branches on the enriched value (e.g. only send if displayName is set), guard explicitly. There is no implicit “skip if empty”.
Yes, by (orgId, name, paramsHash). Same params across campaigns hit the same cached entry. Different params produce different entries.

See also

Enrichers tutorial

End-to-end: register, mount, debug.

Node.js SDK reference

client.enricher(), client.dispatch(), error classes.

Keys and environments

Where webhookSigningSecret comes from.

Error catalogue

Enricher error codes, full reference.