Enrichers tutorial
This guide walks through registering an enricher with@notifizz/nodejs, exposing it on a public URL 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-only today. Java and PHP backends consume enriched data normally but cannot host enrichers themselves.
- 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 in Valkey.
Prerequisites
- A Notifizz account with at least one environment.
- A backend service that can reach the public internet (production) or a tunneling tool like ngrok / cloudflared (local dev).
- 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 — Expose publicly
The Notifizz backend must be able to reach the URL. 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 — use a tunnel:
signDispatchPayload (see the protocol reference).
Step 4 — Register the URL in the dashboard
In the Notifizz dashboard:- 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 dev 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 dev 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.
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:| Value | Behaviour | When to use |
|---|---|---|
false | No caching. Your endpoint is called every time. | Always-live data: feature flags, fast-changing user state. |
{ ttl: "30s" } | 30-second cache. | Near-live data — needs to reflect recent changes but not every byte. |
{ ttl: "1h" } (default) | 1-hour cache. | Standard user profiles, account metadata. |
{ ttl: "24h" } | 24-hour cache. | Reference data, locale preferences, organisation settings. |
{ ttl: "7d" } | 7-day cache. | Static or near-static data. |
(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
| Symptom | Likely cause | Fix |
|---|---|---|
Body returns { ok: false, error: { code: "hmac-invalid" } } | webhookSigningSecret mismatch dashboard ↔ constructor. | Re-copy from the dashboard. |
Body returns { ok: false, error: { code: "stale-timestamp" } } | Clock drift > 5 min between Notifizz backend and your host. | NTP your enricher host. |
Body returns { ok: false, error: { code: "input-invalid", details: ... } } | Campaign passes params that don’t match your input schema. details carries the issue tree. | Update the schema or fix the orchestrator code. |
Body returns { ok: false, error: { code: "enricher-not-found" } } | The name in the campaign doesn’t match a registered enricher. | Check spelling; ensure the same client instance both registered and mounted dispatch. |
| Discovery returns an empty list | You called dispatch() before any client.enricher(...). | Register all enrichers before the route accepts traffic. |
FAQ
PHP / Java enrichers — when?
PHP / Java enrichers — when?
On the roadmap. Until then, host enrichers from a Node service alongside your PHP/Java backend; your campaigns receive enriched data the same way regardless of where the enricher lives. The wire protocol is plain HTTP+HMAC-over-body, so a manual implementation in any language is also possible — 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?
Use ngrok or cloudflared (above). For unit tests, the SDK exports
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.