Skip to main content

Enrichers tutorial

This guide walks through registering an enricher with @notifizz/nodejs, making it reachable through the body-only dispatch() entry point, and watching the Notifizz orchestrator call it back at notification time. By the end you’ll have a fetchUser enricher that the campaign orchestrator can invoke whenever it needs the live user profile. The wire spec — envelope shape, HMAC, error codes — lives at enrichers protocol reference. This page is task-oriented; jump there when you need the details.

TL;DR

  • Node, Java and PHP all host enrichers — same wire protocol, same one-line dispatch route. This page uses the Node SDK; the Java and PHP calls mirror it.
  • One controller, one line — return notifizz.dispatch(req.body). Discovery and execution share the same route, the same body shape.
  • Enrichers must be idempotent — the backend may retry after a network failure with identical params.
  • Cache policy is declared at registration; Notifizz enforces it server-side.

Prerequisites

  • A Notifizz account with at least one environment.
  • A backend service reachable from Notifizz — a public hostname in production, and locally the dev tunnel the SDK opens for you (ngrok or cloudflared if your stack has no in-process bridge).
  • The webhookSigningSecret from the dashboard environment settings.

Step 1 — Register the enricher

The input and output schemas serve two jobs. The SDK validates incoming requests against input at runtime; both schemas are emitted to the discovery payload as JSON Schema for the orchestrator to introspect.
Always import z from @notifizz/nodejs, not from zod directly. Importing zod separately in a Vite-bundled app produces “two Zod copies” — instanceof ZodType then fails across prototype chains and your enricher schemas refuse to validate. The SDK re-exports the bundled Zod for this reason.

Step 2 — Mount the dispatch route

Your controller is one line, regardless of framework. It forwards req.body to notifizz.dispatch() and returns the result. Discovery and enricher execution go through the same route — the SDK demultiplexes from the envelope’s kind field.
dispatch() requires webhookSigningSecret to have been passed to the constructor — it throws synchronously on the first call if the secret is missing. The route accepts a single body shape: { payload, signature } where payload is a JSON-encoded discriminated union. The customer never has to inspect or build it — the SDK does. See the protocol reference if you need to call dispatch by hand from a test.

Step 3 — Make it reachable

Notifizz must be able to reach your dispatch route. Production — deploy your service behind a public hostname. The full URL is the domain plus the route you mounted on (https://api.example.com/notifizz for the example above). Local dev, Node — skip the public URL entirely. startDevTunnel() holds a connection open to Notifizz and feeds the envelopes it receives to the same dispatch() you mounted in step 2:
It authenticates with the environment secrets the client already holds — nothing to paste in the dashboard, nothing to re-paste when your tunnel restarts on a new URL. The call returns a handle whose stop() you call on shutdown. Keep the NODE_ENV guard: Notifizz accepts the dev tunnel on non-production environments only and rejects a production sdkSecretKey. Local dev, Java — same idea, nothing to launch. Add the notifizz-dev module in a dev or test scope, set notifizz.dev.enabled=true in your development profile, and the bridge opens when the application reports ready. Local dev, anything else — expose the route through a public tunnel, then register the URL as in step 4:
Which option applies to your stack, what the bridge registers on your behalf and what it deliberately does not: local development tunnel. Discovery is signed too — there is no public unauthenticated path. To verify a tunnel manually, synthesise a signed envelope with signDispatchPayload (see the protocol reference).

Step 4 — Register the URL in the dashboard

Running the dev tunnel — startDevTunnel() on Node, the notifizz-dev module on Java? Skip this step. The bridge registers its own entry under the endpoint name you gave it, and discovery routes there while it is running. It registers a transport entry, not a public URL: when you deploy, you still come back and register the real one.
In the Notifizz dashboard:
  1. Open environment settings → Enrichers.
  2. Paste the public URL.
  3. Click “Discover”. The dashboard POSTs a signed kind: 'discovery' envelope and lists every registered enricher with its schemas.
  4. Approve the enrichers you want available to campaigns.
The discovery is repeated periodically; redeploys with new enrichers don’t need a manual refresh.

Or let the SDK announce the URL for you

Instead of pasting the URL by hand, declare it once in the SDK and it turns up in the dashboard ready to validate. Pass discoveryUrl when you construct the client (or set NOTIFIZZ_DISCOVERY_URL):
On boot the SDK announces that URL to Notifizz. It shows up in your environment settings flagged as proposed — waiting for you to validate it. Approve it and discovery runs exactly as if you’d pasted the URL by hand (steps 3–4 above). Until you approve, Notifizz never calls the URL, so a misconfigured or leaked key can’t point discovery somewhere on its own. Reach for this when the team configuring the SDK isn’t the one with dashboard access: the URL lands in Notifizz on its own, and whoever owns the environment just clicks to validate.
  • Set it after construction with notifizz.config({ discoveryUrl }) when the public URL is only known once the server is listening — a dynamic port, or a tunnel resolved at startup.
  • webhookName (optional, constructor or config()) names the proposed entry; it defaults to the URL host.
  • No discoveryUrl configured → nothing changes. Announcing is opt-in; the manual flow above always works.

Re-discover after a local change

When you add an enricher — or a field the orchestrator flagged as a implementation task (missing event property, undefined enricher, missing enricher field) — Notifizz has to re-run discovery to see it. In production the periodic discovery covers this on its own. While you iterate locally, call ready() once your server is listening so the change is picked up right away instead of on the next periodic pass:
ready() signals that your dispatch endpoint is now serving its current set of enrichers and events. It runs every time your dev server restarts, so the loop becomes: resolve the implementation task locally → save → your server reloads → discovery re-runs → the task clears. No dashboard refresh, no redeploy. Call it after the server is listening, not at module top level — that’s the moment discovery can read the enrichers you actually registered (the same reason discovery returns an empty list when dispatch() runs before your enricher(...) calls). Safe to leave in for production: there it does nothing beyond the boot signal Notifizz already receives. ready() exists in the Node and Java SDKs. PHP has no deferred execution and no ready(): its boot signal goes out inline on the first track() of the process and does not ask for a re-discovery, so a PHP service picks up a newly added enricher on the next periodic pass rather than immediately.

Step 5 — Use the enricher in a campaign

Once approved, the enricher is callable from any campaign’s orchestrator code:
The orchestrator code is generated by AI from the campaign description (and editable). Read orchestrator concept for how the AI authoring loop works.

Step 6 — Watch it fire

Trigger a campaign that uses fetchUser (client.track("user.signed_up", { userId: "u_42" })), then:
  1. Check the dashboard delivery history — the campaign should show one workflow instance per matching campaign.
  2. Click into the instance — it lists every step, including enrichWith("fetchUser", ...) with timing.
  3. On a cache hit, your handler() is not called. On a miss, your service receives a POST to the dispatch route with kind: 'execute'.
Tail your application logs to confirm the call lands. The first call is always a miss; subsequent calls within the cache TTL are hits.

Cache policy — what to pick

Declared at registration: Cache key is (orgId, enricherName, paramsHash). Same params → same cached entry across campaigns.

Pitfalls

The backend may retry after a transient network error with identical params. Your handler must produce the same output for the same input — no side effects, no random IDs, no timestamps in the output.
Enricher outputs are persisted on the workflow instance for traceability. Multi-megabyte responses stress the queue and the dashboard. Slim your output — return only the fields the campaign actually uses.
The SDK signs and verifies the verbatim payload string inside the envelope. As long as you forward the parsed body to dispatch() (req.body, await c.req.json(), etc.), the inner payload string is preserved byte for byte. Modern framework body parsers do this correctly. Don’t roll your own pre-processing layer.
A cache: false enricher called from a popular campaign hits your DB on every notification. Audit query patterns; cache 1h+ unless you genuinely need real-time data.

Debugging

FAQ

Yes. enricher(), declareEvent() and dispatch() exist in all three backend SDKs, over the same wire protocol — one route, one line, same envelope. The only shape difference is where the schemas come from: Node derives them from Zod, Java and PHP build them with their JsonSchema helper. And since the protocol is plain HTTP with an HMAC over the body, a manual implementation in any other language works too — see enrichers protocol reference.
Sub-second is the goal. The orchestrator times out individual enricher calls after a few seconds; the campaign-level retry policy then decides whether to retry or fail. Slow enrichers cascade into delayed notifications; cache aggressively or push expensive lookups into a separate “warm-up” event.
Yes. On Node, startDevTunnel() (step 3) needs no public URL at all; on Java, the notifizz-dev module does the same from your development profile. Any other stack falls back to ngrok or cloudflared — see local development tunnel. For unit tests, all three SDKs export signDispatchPayload(secret, payload) so you can synthesise a signed envelope and pass it to client.dispatch(envelope) in-process — no HTTP server needed.
Match the data freshness needs of your campaigns. If the campaign sends “your monthly report is ready”, a 1h TTL is plenty (the monthly cadence dominates). If it sends “your subscription was just upgraded” and reads plan, you need shorter (or false) — otherwise the user’s email tells them they got Pro, but the orchestrator still sees the cached plan: "free".
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. There’s no per-campaign cache isolation.

See also

Enrichers protocol reference

Wire format, envelope shape, error codes.

Node.js SDK reference

client.enricher, client.dispatch, error classes.

Orchestrator concept

Where enrichWith() is called from.

Keys and environments

Where webhookSigningSecret comes from.