> ## Documentation Index
> Fetch the complete documentation index at: https://notifizz.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enrichers Tutorial — Fetch User Data at Notification Time

> End-to-end walkthrough — register an enricher in the Node SDK, mount the dispatch route, expose it publicly, watch the orchestrator call it. Caching, pitfalls, debugging.

# 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](/docs/sdks/event-tracking/enrichers-protocol). 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

```ts theme={null}
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.

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

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

<CodeGroup>
  ```ts NestJS theme={null}
  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);
    }
  }
  ```

  ```ts Express theme={null}
  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));
  });
  ```

  ```ts Fastify theme={null}
  import Fastify from "fastify";
  import { notifizz } from "./notifizz.config";

  const fastify = Fastify();
  fastify.post("/notifizz", async (req) => notifizz.dispatch(req.body));
  ```

  ```ts Hono theme={null}
  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())));
  ```
</CodeGroup>

`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](/docs/sdks/event-tracking/enrichers-protocol) 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:

<CodeGroup>
  ```bash ngrok theme={null}
  ngrok http 3000
  # Copy the https://<id>.ngrok.io URL and append /notifizz
  ```

  ```bash cloudflared theme={null}
  cloudflared tunnel --url http://localhost:3000
  # Copy the trycloudflare.com URL and append /notifizz
  ```
</CodeGroup>

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](/docs/sdks/event-tracking/enrichers-protocol#local-development)).

## 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`):

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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](/docs/concepts/orchestrator) 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:

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

Cache key is `(orgId, enricherName, paramsHash)`. Same params → same cached entry across campaigns.

## Pitfalls

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

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

<AccordionGroup>
  <Accordion title="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](/docs/sdks/event-tracking/enrichers-protocol).
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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"`.
  </Accordion>

  <Accordion title="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".
  </Accordion>

  <Accordion title="Two campaigns same enricher — cache shared?">
    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.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Enrichers protocol reference" icon="puzzle-piece" href="/docs/sdks/event-tracking/enrichers-protocol">
    Wire format, envelope shape, error codes.
  </Card>

  <Card title="Node.js SDK reference" icon="node-js" href="/docs/sdks/event-tracking/node-js">
    `client.enricher`, `client.dispatch`, error classes.
  </Card>

  <Card title="Orchestrator concept" icon="robot" href="/docs/concepts/orchestrator">
    Where `enrichWith()` is called from.
  </Card>

  <Card title="Keys and environments" icon="key" href="/docs/environments/api-keys">
    Where `webhookSigningSecret` comes from.
  </Card>
</CardGroup>
