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
webhookSigningSecretfrom the dashboard environment settings.
Step 1 — Register the enricher
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.
Step 2 — Mount the dispatch route
Your controller is one line, regardless of framework. It forwardsreq.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:
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:
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.- Open environment settings → Enrichers.
- Paste the public URL.
- Click “Discover”. The dashboard POSTs a signed
kind: 'discovery'envelope and lists every registered enricher with its schemas. - Approve the enrichers you want available to campaigns.
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. PassdiscoveryUrl when you construct the client (or set NOTIFIZZ_DISCOVERY_URL):
- 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 orconfig()) names the proposed entry; it defaults to the URL host.- No
discoveryUrlconfigured → 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, callready() 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:Step 6 — Watch it fire
Trigger a campaign that usesfetchUser (client.track("user.signed_up", { userId: "u_42" })), then:
- Check the dashboard delivery history — the campaign should show one workflow instance per matching campaign.
- Click into the instance — it lists every step, including
enrichWith("fetchUser", ...)with timing. - On a cache hit, your
handler()is not called. On a miss, your service receives aPOSTto the dispatch route withkind: 'execute'.
Cache policy — what to pick
Declared at registration:
Cache key is
(orgId, enricherName, paramsHash). Same params → same cached entry across campaigns.
Pitfalls
Handler must be idempotent
Handler must be idempotent
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.
Don't return a payload bigger than ~1 MB
Don't return a payload bigger than ~1 MB
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.
Forward `req.body` as-is — don't re-stringify
Forward `req.body` as-is — don't re-stringify
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.Watch your DB load on cold caches
Watch your DB load on cold caches
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
Can a Java or PHP backend host enrichers?
Can a Java or PHP backend host enrichers?
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.How long should an enricher take?
How long should an enricher take?
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.
Test locally without a public URL?
Test locally without a public URL?
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.My DB query is heavy — cache 1h or 1min?
My DB query is heavy — cache 1h or 1min?
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".Empty object — does the campaign still send?
Empty object — does the campaign still send?
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”.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.