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

# @notifizz/nodejs SDK Reference

> Node.js / TypeScript reference for the @notifizz/nodejs SDK — track events, register enrichers, mount the HTTP handler, generate widget auth tokens.

# `@notifizz/nodejs` SDK reference

`@notifizz/nodejs` is the Node SDK for tracking events from a backend service. It exposes a single `track()` method, a hashed-token helper for widget auth, and the only **enricher subsystem** available across the official SDKs today.

## TL;DR

* `new NotifizzClient(authSecretKey, sdkSecretKey, webhookSigningSecret?)` — third argument is required only when you call `dispatch()` to expose enrichers.
* `await client.track(eventName, properties, options?)` posts a single event to `POST /v1/events/track` with retries (`1s`, then `2s`) and an idempotency key.
* `client.enricher(name, options)` registers an enricher. `client.declareEvent(name, options)` declares an event in the catalog. `await client.dispatch(body)` is the body-only webhook entry point — your controller forwards `req.body` and returns the result.
* `import { z } from "@notifizz/nodejs"` — re-exported Zod, mandatory to avoid the "two Zod copies" hooks crash in Vite-bundled apps.
* Errors live **inside the dispatch response body** (`{ ok: false, error: { code, message } }`) — your controller writes 200 unconditionally.

## Installation

<CodeGroup>
  ```bash npm theme={null}
  npm install @notifizz/nodejs
  ```

  ```bash yarn theme={null}
  yarn add @notifizz/nodejs
  ```

  ```bash pnpm theme={null}
  pnpm add @notifizz/nodejs
  ```
</CodeGroup>

## Constructor

```ts theme={null}
import { NotifizzClient } from "@notifizz/nodejs";

const client = new NotifizzClient(
  process.env.NOTIFIZZ_AUTH_SECRET_KEY!,
  process.env.NOTIFIZZ_SDK_SECRET_KEY!,
  process.env.NOTIFIZZ_WEBHOOK_SIGNING_SECRET, // optional, required only for handler()
);
```

| Parameter              | Type     | Required | Description                                                                                                                                                                                                        |
| ---------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `authSecretKey`        | `string` | yes      | Used by `generateHashedToken()` for widget backend-token auth.                                                                                                                                                     |
| `sdkSecretKey`         | `string` | yes      | Used as the Bearer token on the tracking API and posted in the body.                                                                                                                                               |
| `webhookSigningSecret` | `string` | no       | Required when calling `dispatch()` — used to verify the HMAC on incoming enricher webhooks. If omitted, `enricher()` and `declareEvent()` still register locally, but `dispatch()` throws on the first invocation. |

<Tip>
  Keep keys in environment variables. They are scoped per environment (dev / prod) — see [keys and environments](/docs/environments/api-keys).
</Tip>

## `client.track(eventName, properties, options?)`

Emits a single event. Notifizz resolves campaigns by `eventName` and runs each campaign's orchestrator server-side to build the recipient list — there is no client-side workflow or recipient targeting.

```ts theme={null}
await client.track("order.shipped", {
  orderId: "ORD-4521",
  userId: "u_42",
  carrier: "FedEx",
});

// With a caller-supplied idempotency key (recommended for retried jobs):
await client.track(
  "order.shipped",
  { orderId: "ORD-4521", userId: "u_42" },
  { idempotencyKey: `order-shipped:${orderId}` },
);
```

### Parameters

| Parameter                | Type                  | Default   | Description                                                                     |
| ------------------------ | --------------------- | --------- | ------------------------------------------------------------------------------- |
| `eventName`              | `string`              | —         | Canonical event name. Must match the event you registered in the dashboard.     |
| `properties`             | `Record<string, any>` | `{}`      | Arbitrary key-value data passed to campaigns.                                   |
| `options.idempotencyKey` | `string`              | auto-UUID | Caller-supplied dedupe key. Set this when the same logical emit may be retried. |

### Behaviour

* Posts `{ eventName, properties, sdkSecretKey, idempotencyKey }` to `POST /v1/events/track`.
* Sends `Authorization: Bearer <sdkSecretKey>` and `X-Idempotency-Key: <idempotencyKey>`.
* Retries transient failures twice (1s, then 2s). After three total attempts, the last error bubbles up.
* Returns `Promise<void>`. The backend acks acceptance; delivery happens asynchronously.

### Idempotency

A retried emit with the same `idempotencyKey` produces the same outcome — the backend responds `{ duplicate: true, idempotencyKey }` and does **not** re-enqueue the event. Pick a key that uniquely identifies the *logical* event (e.g. `order-shipped:${orderId}`), not the call site.

See the [Event Tracking reference](/docs/sdks/event-tracking/overview) for the full wire format.

## Declaring events (Node-only today)

Declaring an event registers it in a **catalog** that the orchestrator AI and the dashboard can read. The orchestrator receives the event's schema, description, and idempotency fields in its context — no more guessing properties from observed payloads. Declaring is optional; tracking always works.

### `client.declareEvent(name, options)`

Registers a single event on the client. The signature mirrors `client.enricher(name, options)` — name first, options second. Call once per event, typically at boot from a central catalog file. Returns the canonical event name as a string-literal type — store it for refactor-safe `track()` calls.

```ts theme={null}
import { NotifizzClient, z } from "@notifizz/nodejs";

const client = new NotifizzClient(authKey, sdkKey, signingSecret);

const ORDER_SHIPPED = client.declareEvent("order.shipped", {
  description: "An order has been handed to the carrier and a tracking URL is available",
  schema: z.object({
    orderId: z.string(),
    userId: z.string(),
    carrier: z.enum(["FedEx", "UPS", "DHL"]),
    trackingUrl: z.string().url(),
  }),
  idempotencyFields: ["orderId"],
});

// Use the returned constant for type-safe track() — TS infers `'order.shipped'`.
await client.track(ORDER_SHIPPED, { orderId: "ORD-1", userId: "u_42", carrier: "FedEx", trackingUrl: "..." });
```

| Argument                    | Type                         | Required | Description                                                                                                                                |
| --------------------------- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`                      | `string`                     | yes      | Canonical event name. Same grammar as `track()` (`domain.event_name`). Returned verbatim as a literal-typed string.                        |
| `options.description`       | `string`                     | yes      | Surfaced in the dashboard catalog and sent to the orchestrator AI as part of the campaign context.                                         |
| `options.schema`            | `ZodType` \| raw JSON Schema | no       | Validates payloads in `track()`. Optional — without it, the event is in the catalog but unvalidated.                                       |
| `options.idempotencyFields` | `string[]`                   | no       | Property names the SDK uses to derive a deterministic idempotency key when one isn't supplied. Implements option C of the idempotency RFC. |

For multiple events, just call `declareEvent` once per event (typically grouped in a `events.ts` catalogue module). There is no bulk variant — the SDK favours one declaration per call so each event keeps its own literal-typed return value.

### Validation modes (`schemaMode`)

`schemaMode` is **client-side only**. The server never blocks a track based on a declared schema; the strict mode is a local guard rail to catch bugs in dev/CI.

```ts theme={null}
const client = new NotifizzClient(authKey, sdkKey, {
  schemaMode: "soft", // 'soft' (default) | 'strict'
  onError: (eventName, reason, payload) => {
    logger.error({ eventName, reason }, "notifizz strict-mode rejection");
  },
});
```

| Case                                 | `soft`                                           | `strict`                                                           |
| ------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------ |
| Event not declared                   | `console.warn`, push                             | `console.error`, **no push**, `track()` rejects, `onError` invoked |
| Payload not matching declared schema | `console.warn`, push **byte-for-byte** unchanged | `console.error`, **no push**, `track()` rejects, `onError` invoked |
| All good                             | silent push                                      | silent push                                                        |

Override per environment with the `NOTIFIZZ_SCHEMA_MODE` env var:

```bash theme={null}
NOTIFIZZ_SCHEMA_MODE=strict npm test
```

### Discovery exposure

Declared events are exposed alongside enrichers through the same `client.dispatch()` webhook. On a `kind: 'discovery'` payload, dispatch returns the unified catalogue:

```json theme={null}
{
  "ok": true,
  "result": {
    "enrichers": [...],
    "events": [
      {
        "name": "order.shipped",
        "description": "...",
        "schema": { "type": "object", "required": [...], "properties": {...} },
        "idempotencyFields": ["orderId"]
      }
    ]
  }
}
```

The Notifizz backend pulls this every 30 minutes (or when "Refresh" is clicked in the dashboard). Schemas using Zod are converted to JSON Schema 2019-09 at registration time via `zod-to-json-schema`.

### Conflict signals

If two services declare the same event name with diverging schemas on the same environment, the backend keeps last-write-wins and surfaces an **"inconsistent declared event"** banner on the event in the dashboard catalog. Resolve by aligning the declarations across services.

### Concept page

See [Events](/docs/concepts/events) for the why, the catalog UI, and the modes' tradeoffs at a higher level.

## `client.generateHashedToken(userId)`

Generates the SHA-256 HMAC of `userId + authSecretKey`. Pass it to your frontend so the Notification Center widget can authenticate in `backendToken` mode.

```ts theme={null}
const token = client.generateHashedToken("u_42");
// Return token to your frontend via your own API endpoint.
```

| Parameter | Type     | Description                                                                    |
| --------- | -------- | ------------------------------------------------------------------------------ |
| `userId`  | `string` | The user's unique identifier — must match the `userId` you pass to the widget. |

**Returns** — `string`, hex-encoded SHA-256.

See [backend tokens](/docs/sdks/authentication/backend-tokens) for the widget side.

## Enrichers (Node-only today)

An **enricher** is a server-side function the Notifizz orchestrator calls to fetch live data at notification time. You register one per data source (`fetchUser`, `fetchOrder`, …), expose them on a public URL, and the backend calls them via HMAC-signed webhooks. Cached responses respect the policy you declare.

The full protocol is in [enrichers protocol reference](/docs/sdks/event-tracking/enrichers-protocol). The end-to-end tutorial is in the [enrichers guide](/docs/sdks/how-to/enrichers-tutorial).

### `client.enricher(name, options)`

Registers an enricher on this client instance. Call once per enricher, **before** mounting `handler()`.

```ts theme={null}
import { NotifizzClient, z } from "@notifizz/nodejs";

const client = new NotifizzClient(authKey, sdkKey, signingSecret);

client.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(),
  }),
  cache: { ttl: "1h" },
  handler: async ({ userId }) => {
    const user = await db.users.findOne({ id: userId });
    return { id: user.id, email: user.email, displayName: user.name };
  },
});
```

| Option        | Type                                   | Required | Description                                                                         |
| ------------- | -------------------------------------- | -------- | ----------------------------------------------------------------------------------- |
| `description` | `string`                               | no       | Surfaced in the discovery endpoint.                                                 |
| `input`       | `ZodType` \| raw JSON Schema           | yes      | Validated on every webhook call; throws `EnricherInputValidationError` on mismatch. |
| `output`      | `ZodType` \| raw JSON Schema           | yes      | Discovery only — Notifizz validates server-side.                                    |
| `cache`       | `false` \| `{ ttl: number \| string }` | no       | `false` disables caching; `{ ttl: "1h" }` caches in Notifizz's Valkey. Default: 1h. |
| `handler`     | `(params) => Promise<output>`          | yes      | Must be **idempotent** — the backend may retry after a network failure.             |

<Warning>
  Always import `z` from `@notifizz/nodejs`, not from `zod` directly. Bundlers like Vite ship two copies of Zod when you import from both — `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>

### `client.enrichers()`

Returns the list of registered enrichers. Useful for diagnostics and tests.

```ts theme={null}
const registered = client.enrichers();
console.log(registered.map((r) => r.name));
```

### `client.dispatch(body)`

The body-only webhook entry point. Your controller is a one-liner regardless of framework:

<CodeGroup>
  ```ts NestJS theme={null}
  @Controller('notifizz')
  export class NotifizzController {
    @Post()
    dispatch(@Body() body: unknown) {
      return notifizz.dispatch(body);
    }
  }
  ```

  ```ts Express theme={null}
  app.post('/notifizz', express.json(), async (req, res) => {
    res.json(await notifizz.dispatch(req.body));
  });
  ```

  ```ts Fastify theme={null}
  fastify.post('/notifizz', async (req) => notifizz.dispatch(req.body));
  ```

  ```ts Hono theme={null}
  app.post('/notifizz', async (c) => c.json(await notifizz.dispatch(await c.req.json())));
  ```
</CodeGroup>

The Notifizz backend POSTs an envelope `{ payload, signature }` where `payload` is a JSON-encoded discriminated union (`{ kind: 'discovery' }` or `{ kind: 'execute', name, params, timestamp }`). `dispatch()` verifies the HMAC over `payload`, rejects stale timestamps (anti-replay, ±5 min), and returns:

| Result body                                 | When                                                                                                                       |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `{ ok: true, result: <discovery payload> }` | `kind: 'discovery'` succeeded.                                                                                             |
| `{ ok: true, result: <enricher return> }`   | `kind: 'execute'` ran the registered handler.                                                                              |
| `{ ok: false, error: { code, message } }`   | Anything failed. See [enrichers protocol reference](/docs/sdks/event-tracking/enrichers-protocol) for the full code table. |

Errors are encoded **inside the body**, never thrown across the dispatch boundary. The customer's framework writes 200 unconditionally — the Notifizz backend gateway interprets `error.code` and translates it back to typed domain errors. That is what lets the controller be one line.

`dispatch()` throws synchronously on the first call if `webhookSigningSecret` was not provided to the constructor.

### Bundled exports

```ts theme={null}
import {
  NotifizzClient,
  z, // bundled Zod — use this, not your own
  signDispatchPayload, // HMAC over a `payload` string — for testing dispatch with synthetic signed requests
  TIMESTAMP_TOLERANCE_MS,
  EnricherRegistrationError,
  EnricherInputValidationError,
  EnricherNotFoundError,
  EnricherHandlerError,
  type EnricherOptions,
  type EnricherCachePolicy,
  type EnricherSchemaInput,
  type EnricherRegistration,
  type DiscoveryResponse,
  type DispatchEnvelope,
  type DispatchPayload,
  type DispatchResponse,
  type DispatchError,
  type DispatchErrorCode,
  type DiscoveryResult,
} from "@notifizz/nodejs";
```

## `client.config(options)`

Overrides default options. Currently only `baseUrl` is configurable.

```ts theme={null}
client.config({ baseUrl: "https://eu.api.notifizz.com/v1" });
```

| Option    | Default                          | Description                     |
| --------- | -------------------------------- | ------------------------------- |
| `baseUrl` | `https://eu.api.notifizz.com/v1` | Base URL for all SDK API calls. |

## Error handling

`track()` throws the underlying `axios` error after exhausting retries. Wrap it when you need a custom log line:

```ts theme={null}
try {
  await client.track("order.shipped", { orderId, userId });
} catch (e) {
  logger.error({ err: e, orderId }, "notifizz track failed");
  throw e;
}
```

Enricher execution errors are encoded in the response body by `dispatch()` — see the result table above. The customer's controller never has to throw or set a status code; it just `return`s whatever `dispatch()` produces.

If you call enricher logic yourself outside the dispatch flow (rare — only for unit tests of a single handler), the typed errors are still exported:

```ts theme={null}
try {
  // ...
} catch (e) {
  if (e instanceof EnricherInputValidationError) {
    // ...
  }
}
```

The full error catalog (including HTTP status mappings) is in [error catalogue](/docs/sdks/error-catalogue).

## FAQ

<AccordionGroup>
  <Accordion title="Why is `webhookSigningSecret` not required by default?">
    Because most clients only ever call `track()` and `generateHashedToken()` — they never expose enrichers. Forcing the third argument would burden every customer with a secret they don't need. The constructor accepts it as optional, and `dispatch()` throws if it's missing on the first invocation.
  </Accordion>

  <Accordion title="My enricher works locally but `dispatch()` returns `{ ok: false, error: { code: 'hmac-invalid' } }` in production.">
    The HMAC over the `payload` string did not match. Check two things: (1) `webhookSigningSecret` matches the value configured in the dashboard for this environment; (2) clock skew — `error.code === 'stale-timestamp'` means the payload's `timestamp` is outside the ±5 min window, NTP your enricher host. See [enrichers protocol reference](/docs/sdks/event-tracking/enrichers-protocol).
  </Accordion>

  <Accordion title="I get a hooks crash from Vite when registering an enricher.">
    Two copies of Zod loaded — one bundled by the SDK, one from your `node_modules`. Always import `z` from `@notifizz/nodejs`. If you really must use your own Zod (e.g. for sharing schemas across packages), pass a raw JSON Schema object instead of a Zod schema to `enricher()`.
  </Accordion>

  <Accordion title="Should I generate the idempotency key myself?">
    Yes when the same logical emit may be retried (queued jobs, cron, retry middleware). Use a deterministic key derived from your domain — `order-shipped:${orderId}` is better than `crypto.randomUUID()`, which generates a new key per attempt and defeats the dedupe.
  </Accordion>

  <Accordion title="Is `track()` blocking? My request handler latency went up.">
    `track()` is async and the backend acks acceptance fast — but the SDK retries failures with 1s + 2s delays, so a degraded backend can stall your request handler for up to \~3s. Either fire-and-log (`track().catch(...)` without `await`) or push the emit onto a queue you control.
  </Accordion>

  <Accordion title="Can I customise the retry schedule?">
    Not today. The schedule is `[1000, 2000]` ms. If you need different behaviour, wrap `track()` in your own retry layer and use a deterministic idempotency key so retries dedupe at the backend.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Event Tracking reference" icon="code" href="/docs/sdks/event-tracking/overview">
    HTTP wire format, idempotency contract, error shapes.
  </Card>

  <Card title="Enrichers tutorial" icon="puzzle-piece" href="/docs/sdks/how-to/enrichers-tutorial">
    End-to-end walkthrough — register, mount, debug an enricher.
  </Card>

  <Card title="Backend quickstart" icon="rocket" href="/docs/quickstart/backend">
    Send your first event in under five minutes.
  </Card>

  <Card title="Notification Center widget" icon="bell" href="/docs/sdks/notification-center/overview">
    Display the notifications your events drive.
  </Card>
</CardGroup>
