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

# Notification Event Naming and Properties

> Event grammar, properties, idempotency, the missing-properties dashboard view, and naming conventions for Notifizz events.

# Events

An event is the unit of input your application sends to Notifizz. Naming, properties, and idempotency are decisions that shape every campaign that listens on the event — get them right early and the rest of the pipeline stays simple.

## TL;DR

* Names follow a stable grammar (`domain.event_name` style); see the RFC for the exact rules.
* Properties are arbitrary JSON the orchestrator can read; they double as recipient identifiers and template values.
* Idempotency keys deduplicate retried emits — set a deterministic key for any retried job.
* The dashboard surfaces "missing properties" when a campaign expects something the event didn't send.

## Naming

Event names follow the grammar  The shape is `domain.event_name` — for example:

* `order.shipped`
* `user.signed_up`
* `invoice.paid`
* `cart.abandoned_after_30min`
* `subscription.renewed`

Names go through `validateEventName` server-side. Invalid names are rejected with `400 event/invalid-name` and a `reason` field in the response body.

### Conventions

* **Lowercase + underscores or dots.** Don't mix camelCase. `order.shipped` ✅; `orderShipped` ❌.
* **Past tense for verbs.** Events describe things that *happened*. `signed_up` not `sign_up`.
* **Stable names.** Renaming an event after a campaign keys on it breaks the campaign — update the campaign in the dashboard before/together with the rename.
* **Domain-first.** `order.shipped` is easier to scan and group than `shipped_order`.

## Properties

Properties are an arbitrary JSON object you attach to an event:

```javascript theme={null}
await client.track("order.shipped", {
  orderId: "ORD-4521",
  userId: "u_42",
  carrier: "FedEx",
  trackingUrl: "https://tracking.example.com/4521",
  items: [{ sku: "ABC-001", qty: 1 }],
});
```

Three things consume properties:

1. **Recipient resolution** — the orchestrator extracts `userId`, `email`, or whatever your campaign keys on, directly or via an enricher.
2. **Template substitution** — `{{ trackingUrl }}` in the notification content resolves to the property value at delivery time.
3. **Routing context** — campaigns can branch on properties (e.g. `if (properties.plan === "pro")`).

### Rules of thumb

* **Pass enough context for the campaign**, not your whole DB row. Properties are persisted on the workflow instance for traceability — keep them lean.
* **Stable shape** beats stable values. Adding a new property is safe; renaming an existing one breaks campaigns that reference the old name.
* **Strings, numbers, booleans, nested objects, arrays.** All JSON-serialisable types work. Avoid `Date` (use ISO strings) and `BigInt`.
* **Don't repeat the orchestrator's job.** If you can compute the recipient list from a `userId`, just pass `userId` and let an enricher fetch the rest at delivery time.

### Missing properties

When a campaign reads a property the event didn't send, the orchestrator either branches gracefully or surfaces it as a "missing property" in the dashboard. Use the `Missing properties` view per environment to find events where you've under-shared with the campaign — usually a sign that you renamed something on one side without the other.

## Declaring events (catalog)

Tracking an event is enough to make Notifizz process it — declared or not. Declaring goes one step further: it writes the event into a **catalog** the orchestrator and the dashboard can read, and gives the AI a precise contract for the event's properties instead of having to guess from observed payloads.

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

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"],
});

// `ORDER_SHIPPED` is typed as the literal `'order.shipped'` — refactor-safe.
await client.track(ORDER_SHIPPED, { orderId, userId, carrier: "FedEx", trackingUrl });
```

Three things change once an event is declared:

1. **The orchestrator gets a schema, not just observed shapes.** When a campaign listens on this event, the AI orchestrator receives `description`, `schema`, and `idempotencyFields` directly in its context — no more inventing properties that don't exist.
2. **The dashboard catalog reflects what your code says.** Description, declared schema, and last-seen timestamps appear in the dashboard, browsable by environment.
3. **Schema-based validation in dev**, opt-in via mode (see below).

### How it propagates

Declaring is a local act on your client; the server picks it up through the **discovery webhook** that already serves enrichers. On a 30-minute cycle (or when you click "Refresh" in the dashboard), the backend `GET`s your handler URL and reads `{ enrichers, events }` from the response. New events appear in the catalog; removed events shift to `removed` status with their last-known schema preserved.

### Validation modes — `soft` (default) and `strict`

`schemaMode` is **purely client-side** — it controls how the SDK behaves when an event you `track()` doesn't match its declared schema. The server never blocks an event based on a declared schema; it only records mismatches as audits in the dashboard.

| Case                                      | `soft` (default)                                       | `strict`                               |
| ----------------------------------------- | ------------------------------------------------------ | -------------------------------------- |
| Event not declared                        | `console.warn`, event still sent                       | `console.error`, **SDK does not push** |
| Payload doesn't match the declared schema | `console.warn`, event sent **byte-for-byte** unchanged | `console.error`, **SDK does not push** |
| All good                                  | silent                                                 | silent                                 |

`strict` is a local guard rail for catching bugs in dev/CI. The server stays permissive — events from Segment, webhook bridges, or non-SDK code paths always go through, regardless of any declared schema.

```ts theme={null}
const client = new NotifizzClient(authKey, sdkKey, {
  schemaMode: "soft", // default
});

// Override per environment without touching code:
//   NOTIFIZZ_SCHEMA_MODE=strict npm test
```

<Note>
  Declaring an event is **optional**. Tracking continues to work without `declareEvent` — the event simply appears as an "orphan" in the dashboard with its observed properties as the only source of truth for the orchestrator. Declaring is the path to a typed catalog and a stricter local feedback loop.
</Note>

## Idempotency

Every event carries an idempotency key. The SDK auto-generates a UUID when you don't provide one — that's fine for fire-once emits but defeats deduplication on retries.

For any **retried** emit (queued job, scheduler, retry middleware, webhook handler), set a deterministic key derived from your domain:

```javascript theme={null}
await client.track(
  "order.shipped",
  { orderId, userId },
  { idempotencyKey: `order-shipped:${orderId}` },
);
```

A retry with the same key returns `{ duplicate: true, idempotencyKey }` (status `200`) and does **not** re-enqueue. See [Event Tracking](/docs/sdks/event-tracking/overview) for the wire format and idempotency plan for the design.

## Mental model: events ≠ Segment events

If you come from a Segment-like analytics tool, the model looks similar (`track` calls, properties), but the meaning differs:

|                    | Analytics events (Segment, Mixpanel, …) | Notifizz events                   |
| ------------------ | --------------------------------------- | --------------------------------- |
| **Purpose**        | Record what users did                   | Trigger notifications             |
| **Latency budget** | Bulk-loaded later                       | Real-time                         |
| **Volume**         | Every page view, every click            | One per real-world product action |
| **Cardinality**    | Hundreds of unique names                | Tens, sometimes a hundred         |
| **Properties**     | Anything that helps analysis            | Just enough for the campaign      |

Use both side by side — Segment for analysis, Notifizz for triggering campaigns. Or use the [Segment connector](/docs/sdks/how-to/connector-webhooks) to bridge both worlds and avoid double-instrumentation.

## FAQ

<AccordionGroup>
  <Accordion title="snake_case / kebab-case / dot.notation — which?">
    The grammar is permissive; the convention is `domain.event_name`. Use lowercase dots between domains and underscores within a name component (`order.shipped`, `user.signed_up`). Avoid mixing kebab-case and camelCase — pick one and stick to it across your team.
  </Accordion>

  <Accordion title="Can I rename an event after a campaign uses it?">
    Yes, but the rename is a two-step migration: (1) update the campaign in the dashboard to listen on the new name; (2) update your code to emit the new name. Do them close together — between the two, the campaign won't run on the old name.
  </Accordion>

  <Accordion title="I added a new property — does the old campaign still work?">
    Yes. Properties are extensible — adding a new field is safe; existing campaigns ignore what they don't read. The campaign only breaks if it *needs* a property that's no longer being sent.
  </Accordion>

  <Accordion title="How do I model 'cart abandoned after 30 minutes'?">
    Two patterns: (1) emit `cart.abandoned` from your backend after a server-side timer; (2) emit `cart.created` and let the campaign's orchestrator schedule the delayed step. (1) keeps timing logic in your domain; (2) keeps everything in Notifizz. Both are common.
  </Accordion>

  <Accordion title="Can the same event drive multiple campaigns?">
    Yes — fan-out is automatic. Every campaign listening on the event creates its own workflow instance independently. See [campaigns](/docs/concepts/campaigns) for what happens after match.
  </Accordion>

  <Accordion title="What about event categories or transactional vs marketing?">
    The event itself is plain — no category. The *campaign* picks a category (transactional or promotional) which decides queue priority. Same event can drive both a transactional campaign and a promotional one.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Campaigns" icon="diagram-project" href="/docs/concepts/campaigns">
    Lifecycle, statuses, versioning — what runs after the event matches.
  </Card>

  <Card title="Event Tracking" icon="code" href="/docs/sdks/event-tracking/overview">
    HTTP wire format, idempotency contract.
  </Card>

  <Card title="Recipients" icon="user" href="/docs/concepts/recipients">
    How event properties become recipient lists.
  </Card>

  <Card title="Connector webhooks" icon="plug" href="/docs/sdks/how-to/connector-webhooks">
    Segment / Stripe / HubSpot / Intercom — alternatives to direct `track()`.
  </Card>
</CardGroup>
