Skip to main content

@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

npm install @notifizz/nodejs
yarn add @notifizz/nodejs
pnpm add @notifizz/nodejs

Constructor

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()
);
ParameterTypeRequiredDescription
authSecretKeystringyesUsed by generateHashedToken() for widget backend-token auth.
sdkSecretKeystringyesUsed as the Bearer token on the tracking API and posted in the body.
webhookSigningSecretstringnoRequired 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.
Keep keys in environment variables. They are scoped per environment (dev / prod) — see keys and environments.

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

ParameterTypeDefaultDescription
eventNamestringCanonical event name. Must match the event you registered in the dashboard.
propertiesRecord<string, any>{}Arbitrary key-value data passed to campaigns.
options.idempotencyKeystringauto-UUIDCaller-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 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.
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: "..." });
ArgumentTypeRequiredDescription
namestringyesCanonical event name. Same grammar as track() (domain.event_name). Returned verbatim as a literal-typed string.
options.descriptionstringyesSurfaced in the dashboard catalog and sent to the orchestrator AI as part of the campaign context.
options.schemaZodType | raw JSON SchemanoValidates payloads in track(). Optional — without it, the event is in the catalog but unvalidated.
options.idempotencyFieldsstring[]noProperty 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.
const client = new NotifizzClient(authKey, sdkKey, {
  schemaMode: "soft", // 'soft' (default) | 'strict'
  onError: (eventName, reason, payload) => {
    logger.error({ eventName, reason }, "notifizz strict-mode rejection");
  },
});
Casesoftstrict
Event not declaredconsole.warn, pushconsole.error, no push, track() rejects, onError invoked
Payload not matching declared schemaconsole.warn, push byte-for-byte unchangedconsole.error, no push, track() rejects, onError invoked
All goodsilent pushsilent push
Override per environment with the NOTIFIZZ_SCHEMA_MODE env var:
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:
{
  "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 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.
const token = client.generateHashedToken("u_42");
// Return token to your frontend via your own API endpoint.
ParameterTypeDescription
userIdstringThe user’s unique identifier — must match the userId you pass to the widget.
Returnsstring, hex-encoded SHA-256. See 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. The end-to-end tutorial is in the enrichers guide.

client.enricher(name, options)

Registers an enricher on this client instance. Call once per enricher, before mounting handler().
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 };
  },
});
OptionTypeRequiredDescription
descriptionstringnoSurfaced in the discovery endpoint.
inputZodType | raw JSON SchemayesValidated on every webhook call; throws EnricherInputValidationError on mismatch.
outputZodType | raw JSON SchemayesDiscovery only — Notifizz validates server-side.
cachefalse | { ttl: number | string }nofalse disables caching; { ttl: "1h" } caches in Notifizz’s Valkey. Default: 1h.
handler(params) => Promise<output>yesMust be idempotent — the backend may retry after a network failure.
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.

client.enrichers()

Returns the list of registered enrichers. Useful for diagnostics and tests.
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:
@Controller('notifizz')
export class NotifizzController {
  @Post()
  dispatch(@Body() body: unknown) {
    return notifizz.dispatch(body);
  }
}
app.post('/notifizz', express.json(), async (req, res) => {
  res.json(await notifizz.dispatch(req.body));
});
fastify.post('/notifizz', async (req) => notifizz.dispatch(req.body));
app.post('/notifizz', async (c) => c.json(await notifizz.dispatch(await c.req.json())));
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 bodyWhen
{ 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 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

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.
client.config({ baseUrl: "https://eu.api.notifizz.com/v1" });
OptionDefaultDescription
baseUrlhttps://eu.api.notifizz.com/v1Base 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:
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 returns 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:
try {
  // ...
} catch (e) {
  if (e instanceof EnricherInputValidationError) {
    // ...
  }
}
The full error catalog (including HTTP status mappings) is in error catalogue.

FAQ

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

See also

Event Tracking reference

HTTP wire format, idempotency contract, error shapes.

Enrichers tutorial

End-to-end walkthrough — register, mount, debug an enricher.

Backend quickstart

Send your first event in under five minutes.

Notification Center widget

Display the notifications your events drive.