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

> Wire spec for the Notifizz enricher loop — body-only envelope, HMAC over payload, discriminated discovery + execute. For Node SDK users and HTTP-direct enricher hosts.

# 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

```mermaid theme={null}
sequenceDiagram
    participant Backend as Notifizz backend
    participant Customer as Customer enricher service
    participant Source as Customer data source

    Backend->>Customer: POST {url} { payload: '{"kind":"discovery",...}', signature }
    Customer->>Customer: notifizz.dispatch(req.body)
    Customer-->>Backend: 200 { ok: true, result: { enrichers, events? } }
    Note over Backend: Cache JSON schemas + cache policies

    Backend->>Customer: POST {url} { payload: '{"kind":"execute","name":"fetchUser",...}', signature }
    Customer->>Customer: dispatch verifies HMAC, validates input
    Customer->>Source: query
    Source-->>Customer: data
    Customer-->>Backend: 200 { ok: true, result: <handler output> }
    Note over Backend: Cache per declared policy
```

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

  <Step title="Customer mounts the dispatch route">
    A single POST route forwards the body: `return notifizz.dispatch(req.body)`. Requires `webhookSigningSecret` in the constructor.
  </Step>

  <Step title="Customer declares the URL in the dashboard">
    The Notifizz dashboard stores the enricher URL per environment.
  </Step>

  <Step title="Backend discovers">
    On startup or refresh, the backend POSTs a `kind: 'discovery'` envelope. The customer responds with `{ ok: true, result: { enrichers, events? } }`.
  </Step>

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

  <Step title="Backend caches">
    Per the cache policy declared at registration. Cache key is `(orgId, enricherName, params)`. On cache hit, the customer endpoint is **not** called.
  </Step>
</Steps>

## Wire envelope

Every request from Notifizz has the same shape:

```json theme={null}
{
  "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

```json theme={null}
{
  "kind": "discovery",
  "timestamp": 1746000000000
}
```

No further fields. The customer responds with the unified catalogue:

```json theme={null}
{
  "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](/docs/sdks/event-tracking/overview).

### Execute

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

```json theme={null}
{
  "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.code`         | Cause                                                                                                  | Backend gateway translation       |
| -------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------- |
| `envelope-invalid`   | Body shape unparseable, missing `payload` or `signature`, decoded payload missing fields.              | `EnricherHttpError` (status 400). |
| `hmac-invalid`       | Signature does not match.                                                                              | `EnricherHttpError` (status 401). |
| `stale-timestamp`    | Payload timestamp outside ±5min.                                                                       | `EnricherHttpError` (status 401). |
| `kind-unknown`       | Payload `kind` is not `'discovery'` nor `'execute'`.                                                   | `EnricherHttpError` (status 400). |
| `enricher-not-found` | Execute on a name not registered on the customer's client instance.                                    | `EnricherHttpError` (status 404). |
| `input-invalid`      | `params` failed Zod / Ajv validation against the registered `input`. `details` carries the issue tree. | `EnricherHttpError` (status 400). |
| `handler-error`      | The customer's handler threw.                                                                          | `EnricherHttpError` (status 500). |
| `internal`           | Anything not classified above.                                                                         | `EnricherHttpError` (status 500). |

Codes are exported as the `DispatchErrorCode` union.

## Cache policy

Declared at registration:

| Value                    | Behaviour                                                                 |
| ------------------------ | ------------------------------------------------------------------------- |
| `false`                  | The 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:

* **ngrok** — `ngrok http 3000`, point the dashboard at the public URL.
* **cloudflared** — `cloudflared tunnel --url http://localhost:3000`.

To synthesise a signed envelope from a unit test:

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

<AccordionGroup>
  <Accordion title="HMAC fails with `hmac-invalid` — what's wrong?">
    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.
  </Accordion>

  <Accordion title="`stale-timestamp` errors but my code is correct.">
    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.
  </Accordion>

  <Accordion title="Why is the body always 200 even on errors?">
    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.
  </Accordion>

  <Accordion title="My enricher is called every time despite `cache: { ttl: '1h' }`.">
    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.
  </Accordion>

  <Accordion title="Right TTL for a user-profile enricher?">
    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.
  </Accordion>

  <Accordion title="Enricher times out — what does the backend do?">
    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.
  </Accordion>

  <Accordion title="Empty or null response — 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 use the same enricher with different params — cache shared?">
    Yes, by `(orgId, name, paramsHash)`. Same params across campaigns hit the same cached entry. Different params produce different entries.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Enrichers tutorial" icon="puzzle-piece" href="/docs/sdks/how-to/enrichers-tutorial">
    End-to-end: register, mount, debug.
  </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="Keys and environments" icon="key" href="/docs/environments/api-keys">
    Where `webhookSigningSecret` comes from.
  </Card>

  <Card title="Error catalogue" icon="circle-exclamation" href="/docs/sdks/error-catalogue">
    Enricher error codes, full reference.
  </Card>
</CardGroup>
