Skip to main content

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 webhookSigningSecret from the dashboard environment settings.

Step 1 — Register the enricher

import { NotifizzClient, z } from "@notifizz/nodejs";

export const notifizz = new NotifizzClient(
  process.env.NOTIFIZZ_AUTH_SECRET_KEY!,
  process.env.NOTIFIZZ_SDK_SECRET_KEY!,
  process.env.NOTIFIZZ_WEBHOOK_SIGNING_SECRET!,
);

notifizz.enricher("fetchUser", {
  description: "Live user profile from the application database",
  input: z.object({ userId: z.string() }),
  output: z.object({
    id: z.string(),
    email: z.string(),
    displayName: z.string(),
    locale: z.string().optional(),
  }),
  cache: { ttl: "1h" },
  handler: async ({ userId }) => {
    const user = await db.users.findOne({ id: userId });
    return {
      id: user.id,
      email: user.email,
      displayName: user.name,
      locale: user.locale,
    };
  },
});
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.
import { Body, Controller, Post } from "@nestjs/common";
import { notifizz } from "./notifizz.config";

@Controller("notifizz")
export class NotifizzController {
  @Post()
  dispatch(@Body() body: unknown) {
    return notifizz.dispatch(body);
  }
}
import express from "express";
import { notifizz } from "./notifizz.config";

const app = express();
app.use(express.json());
app.post("/notifizz", async (req, res) => {
  res.json(await notifizz.dispatch(req.body));
});
import Fastify from "fastify";
import { notifizz } from "./notifizz.config";

const fastify = Fastify();
fastify.post("/notifizz", async (req) => notifizz.dispatch(req.body));
import { Hono } from "hono";
import { notifizz } from "./notifizz.config";

const app = new Hono();
app.post("/notifizz", async (c) => c.json(await notifizz.dispatch(await c.req.json())));
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:
ngrok http 3000
# Copy the https://<id>.ngrok.io URL and append /notifizz
cloudflared tunnel --url http://localhost:3000
# Copy the trycloudflare.com URL and append /notifizz
Discovery is signed too — there is no public unauthenticated path. To verify the tunnel manually, synthesise a signed envelope with signDispatchPayload (see the protocol reference).

Step 4 — Register the URL in the dashboard

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):
export const notifizz = new NotifizzClient(
  process.env.NOTIFIZZ_AUTH_SECRET_KEY!,
  process.env.NOTIFIZZ_SDK_SECRET_KEY!,
  process.env.NOTIFIZZ_WEBHOOK_SIGNING_SECRET!,
  { discoveryUrl: "https://api.example.com/notifizz" }, // the route you mounted dispatch() on
);
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 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, call ready() once your server is listening so the change is picked up right away instead of on the next periodic pass:
app.listen(3000, () => notifizz.ready());
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:
const user = await enrichWith("fetchUser", { userId: event.properties.userId });
return [{ id: user.id, email: user.email, displayName: user.displayName }];
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:
ValueBehaviourWhen to use
falseNo 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.
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

SymptomLikely causeFix
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 listYou called dispatch() before any client.enricher(...).Register all enrichers before the route accepts traffic.

FAQ

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