Skip to main content

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:
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.
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 GETs 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.
Casesoft (default)strict
Event not declaredconsole.warn, event still sentconsole.error, SDK does not push
Payload doesn’t match the declared schemaconsole.warn, event sent byte-for-byte unchangedconsole.error, SDK does not push
All goodsilentsilent
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.
const client = new NotifizzClient(authKey, sdkKey, {
  schemaMode: "soft", // default
});

// Override per environment without touching code:
//   NOTIFIZZ_SCHEMA_MODE=strict npm test
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.

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:
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 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
PurposeRecord what users didTrigger notifications
Latency budgetBulk-loaded laterReal-time
VolumeEvery page view, every clickOne per real-world product action
CardinalityHundreds of unique namesTens, sometimes a hundred
PropertiesAnything that helps analysisJust enough for the campaign
Use both side by side — Segment for analysis, Notifizz for triggering campaigns. Or use the Segment connector to bridge both worlds and avoid double-instrumentation.

FAQ

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.
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.
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.
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.
Yes — fan-out is automatic. Every campaign listening on the event creates its own workflow instance independently. See campaigns for what happens after match.
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.

See also

Campaigns

Lifecycle, statuses, versioning — what runs after the event matches.

Event Tracking

HTTP wire format, idempotency contract.

Recipients

How event properties become recipient lists.

Connector webhooks

Segment / Stripe / HubSpot / Intercom — alternatives to direct track().