Skip to main content

Enrichers protocol reference

An enricher is a server-side function the Notifizz orchestrator calls at notification time to fetch live data — a user profile, an order, a feature flag, anything you don’t want to copy into Notifizz. The Node SDK (@notifizz/nodejs) wraps the protocol; this page documents it so any HTTP-capable runtime can host enrichers.

TL;DR

  • One endpoint, one body shape. Discovery and enricher execution both go through POST {webhookUrl} with a body-only envelope. No headers, no query string.
  • Envelope: { payload, signature } where payload is a JSON-encoded discriminated union and signature = HMAC-SHA256(signingSecret, payload).
  • Always 200. The customer’s webhook responds with { ok: true, result } or { ok: false, error: { code, message } } — errors are encoded in the body, never in the HTTP status. The customer’s controller is a one-liner.
  • Cache policy declared at registration: false, { ttl: number | string }, or default (1h).
  • Idempotent handlers — the backend may retry after a network failure.

End-to-end flow

1

Customer registers enrichers + events

client.enricher(name, options) and client.declareEvent(name, options) register locally. Each registration carries schemas, a cache policy (enrichers), and a handler.
2

Customer mounts the dispatch route

A single POST route forwards the body: return notifizz.dispatch(req.body). Requires webhookSigningSecret in the constructor.
3

Customer declares the URL in the dashboard

The Notifizz dashboard stores the enricher URL per environment.
4

Backend discovers

On startup or refresh, the backend POSTs a kind: 'discovery' envelope. The customer responds with { ok: true, result: { enrichers, events? } }.
5

Orchestrator calls

When a campaign step calls enrichWith(name, params), the backend POSTs a kind: 'execute' envelope. The customer SDK verifies the HMAC, validates the input against the registered schema, runs the handler, and returns { ok: true, result: <output> }.
6

Backend caches

Per the cache policy declared at registration. Cache key is (orgId, enricherName, params). On cache hit, the customer endpoint is not called.

Wire envelope

Every request from Notifizz has the same shape:
{
  "payload": "<JSON-encoded inner request, signed verbatim>",
  "signature": "<hex HMAC-SHA256 of payload>"
}
The payload field is a string, not a nested object. The signature covers it byte for byte, so signature stability does not depend on canonical JSON serialisation (object key order, whitespace, number encoding). The customer just forwards req.body to notifizz.dispatch().

Signature formula

signature = HMAC-SHA256(signingSecret, payload)
payload is the verbatim string the SDK received as body.payload. No timestamp prefix, no header, no concatenation — the timestamp lives inside the payload (see below) and is therefore implicitly signed. The Node SDK exports signDispatchPayload(secret, payload): string so tests and tools don’t duplicate the formula.

Anti-replay

The inner payload carries a numeric timestamp (millisecond epoch). The verifier rejects payloads whose timestamp is more than ±5 minutes off the local clock. The constant is exported as TIMESTAMP_TOLERANCE_MS.

Inner payload — discriminated union

After verifying the signature, dispatch() parses payload as JSON and dispatches on kind.

Discovery

{
  "kind": "discovery",
  "timestamp": 1746000000000
}
No further fields. The customer responds with the unified catalogue:
{
  "ok": true,
  "result": {
    "enrichers": [
      {
        "name": "fetchUser",
        "description": "Live user profile from the application database",
        "input": {
          "type": "object",
          "properties": { "userId": { "type": "string" } },
          "required": ["userId"]
        },
        "output": {
          "type": "object",
          "properties": { "id": { "type": "string" }, "email": { "type": "string" }, "displayName": { "type": "string" } },
          "required": ["id", "email", "displayName"]
        },
        "cache": { "ttl": "1h" }
      }
    ],
    "events": [
      {
        "name": "order.shipped",
        "description": "An order has been handed to the carrier and a tracking URL is available",
        "schema": {
          "type": "object",
          "properties": {
            "orderId": { "type": "string" },
            "userId": { "type": "string" },
            "carrier": { "type": "string", "enum": ["FedEx", "UPS", "DHL"] },
            "trackingUrl": { "type": "string", "format": "uri" }
          },
          "required": ["orderId", "userId", "carrier", "trackingUrl"]
        },
        "idempotencyFields": ["orderId"]
      }
    ]
  }
}
input / output (enrichers) and schema (events) are JSON Schema Draft 2019-09. The Node SDK derives them from Zod schemas at registration time. events is optional and unrelated to enricher execution — it populates the dashboard catalog and the orchestrator AI context. Server-side events validation is audit-only (never blocking) — see Event Tracking.

Execute

{
  "kind": "execute",
  "name": "fetchUser",
  "params": { "userId": "u_42" },
  "timestamp": 1746000000000
}
params matches the registered input schema for the named enricher. The customer responds with the handler return value:
{
  "ok": true,
  "result": {
    "id": "u_42",
    "email": "alice@example.com",
    "displayName": "Alice"
  }
}

Error response

On any failure, dispatch returns { ok: false, error: { code, message, details? } } with HTTP status 200. The Notifizz backend gateway switches on error.code to translate the failure back into a typed domain error.
error.codeCauseBackend gateway translation
envelope-invalidBody shape unparseable, missing payload or signature, decoded payload missing fields.EnricherHttpError (status 400).
hmac-invalidSignature does not match.EnricherHttpError (status 401).
stale-timestampPayload timestamp outside ±5min.EnricherHttpError (status 401).
kind-unknownPayload kind is not 'discovery' nor 'execute'.EnricherHttpError (status 400).
enricher-not-foundExecute on a name not registered on the customer’s client instance.EnricherHttpError (status 404).
input-invalidparams failed Zod / Ajv validation against the registered input. details carries the issue tree.EnricherHttpError (status 400).
handler-errorThe customer’s handler threw.EnricherHttpError (status 500).
internalAnything not classified above.EnricherHttpError (status 500).
Codes are exported as the DispatchErrorCode union.

Cache policy

Declared at registration:
ValueBehaviour
falseThe customer endpoint is called every time. Use for “always live” data.
{ ttl: number }Cache for number milliseconds.
{ ttl: "1h" }Cache for the parsed duration ("30s", "5m", "1h", "24h", "7d").
undefined (no cache)Default: 1 hour.
Caching is server-side, in Notifizz’s Valkey. The SDK only declares the policy — Notifizz enforces it. Cache key includes the org id, enricher name, and serialised params.

Local development

The customer enricher service must be reachable from the Notifizz backend. For local development:
  • ngrokngrok http 3000, point the dashboard at the public URL.
  • cloudflaredcloudflared tunnel --url http://localhost:3000.
To synthesise a signed envelope from a unit test:
import { signDispatchPayload, type DispatchEnvelope } from "@notifizz/nodejs";

const payload = JSON.stringify({ kind: "execute", name: "fetchUser", params: { userId: "u_42" }, timestamp: Date.now() });
const envelope: DispatchEnvelope = { payload, signature: signDispatchPayload(SECRET, payload) };

const res = await client.dispatch(envelope);
// res === { ok: true, result: ... } or { ok: false, error: { code, message } }

FAQ

Two usual suspects: (1) webhookSigningSecret mismatch between dashboard and constructor; (2) the customer’s framework re-serialised the body before passing it to dispatch() — the SDK signs and verifies the verbatim payload string, so re-stringifying the parsed inner object would change the bytes. Forward req.body as-is. Modern framework body parsers preserve the envelope as JSON and never touch the inner payload string.
Clock drift — the verifier tolerates ±5 minutes between the signing clock (Notifizz backend) and your verifying clock. NTP your enricher host. Containers with broken time sync are a common cause.
Two reasons. First, customer controllers stay one line: return notifizz.dispatch(req.body). Their framework writes 200 from the return, no status-code juggling. Second, the protocol is framework-neutral — it works the same on Express, Fastify, NestJS, Hono, Cloudflare Workers, Vercel Edge. The Notifizz backend gateway switches on error.code to translate failures back into typed domain errors. The HTTP status is no longer the contract.
Caching is by (orgId, enricherName, paramsHash). If the params differ on every call (timestamp in the input, random ID, …), every call is a miss. Audit what your campaign passes — typically you want to cache by a stable user/order id, not by request-time data.
Depends on data freshness. If a profile change must reach a notification within seconds, use false or a short TTL ("30s"). If “yesterday’s profile is fine”, "1h" keeps load off your DB without surprising users. Default is 1h — start there and tune from observability.
Notifizz retries with backoff. The handler must be idempotent for this reason — retried identical params should produce identical output. After a few attempts the orchestrator marks the step as failed; the campaign can branch on that or surface a dev task.
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.

See also

Enrichers tutorial

End-to-end: register, mount, debug.

Node.js SDK reference

client.enricher(), client.dispatch(), error classes.

Keys and environments

Where webhookSigningSecret comes from.

Error catalogue

Enricher error codes, full reference.