Skip to main content

Recipients

Recipients are the audience a campaign delivers to. They’re produced server-side by the orchestrator, never picked at the SDK call site. This page covers the three patterns for producing them, the validation rules, and the gotchas.

TL;DR

  • Each recipient is at minimum { id: string, email: string }. Extra fields flow into the channel template.
  • Three production patterns: from event properties, from an enricher, from a dynamic recipient source.
  • Empty recipient lists short-circuit — no message is created and no metric fires.
  • Invalid recipients (missing email or bad format) are skipped with a logged warning, not failed.

The shape

interface Recipient {
  id: string;          // stable, opaque id — must equal the userId your widget mounts (not the email)
  email: string;       // valid email format
  // any other fields you want available to the template
  displayName?: string;
  locale?: string;
  role?: string;
}
The orchestrator returns Recipient[]. The system iterates: for each recipient, the channel processor renders the template + dispatches. Recipient validation runs server-side at message creation. Invalid recipients are skipped — see “Validation rules” below.
For the Notification Center channel, recipient.id must exactly equal the identity your widget authenticates with (the userId it mounts). Use a stable, opaque id — never the email, which is mutable. A mismatch delivers the notification but it never appears in the user’s inbox. Declare the field your widget uses under Settings → Notification Center → Authentification.

Three patterns

1. From event properties (direct)

The simplest case — the event already carries the recipient identifier.
// Caller side
await client.track("user.signed_up", {
  userId: "u_42",
  email: "alice@example.com",
});
// Orchestrator
return [{
  id: event.properties.userId,
  email: event.properties.email,
}];
When to use: when your code already knows the user id and email at the moment of track(). Most “single-recipient” campaigns fit this pattern.

2. Via an enricher (live lookup)

Pass a stable id, fetch the rest at notification time.
// Caller side — only userId needed
await client.track("user.welcomed", { userId: "u_42" });
// Orchestrator — enricher fetches profile
const user = await enrichWith("fetchUser", { userId: event.properties.userId });
return [{
  id: user.id,
  email: user.email,
  displayName: user.displayName,
  locale: user.locale,
}];
When to use:
  • The event payload should stay narrow (just identifiers), with detail fetched live.
  • Multiple campaigns need different fields from the same user — define one enricher, reuse it.
  • Email or other PII shouldn’t be in the event payload (compliance, privacy-friendly by default).
See enrichers tutorial for the wire-up.

3. From a dynamic recipient source

Saved audiences and segments — defined once in the dashboard, referenced from any campaign. The
// Orchestrator
const audience = await fetchSource("vip-customers");
return audience.map(u => ({ id: u.id, email: u.email }));
When to use:
  • Lifecycle campaigns whose audience is a saved segment (“re-engage users inactive for 30 days”).
  • Marketing campaigns where the audience is curated, not derived from the triggering event.
  • Cross-event audiences (“users who completed onboarding AND have a paid plan”).

Combining patterns

A campaign can use all three. A common pattern:
// Orchestrator: notify the user + team admins on an upgrade
const user = await enrichWith("fetchUser", { userId: event.properties.userId });   // enricher
const admins = await fetchSource(`team-${event.properties.teamId}-admins`);         // source

return [
  { id: user.id, email: user.email, role: "subject" },
  ...admins.map(a => ({ id: a.id, email: a.email, role: "observer" })),
];
The role custom field lets the channel template branch — different copy per role, same campaign.

Validation rules

At message creation, every recipient is checked:
RuleBehaviour
id is a non-empty stringrequired
email is a non-empty stringrequired
email matches the email regexrequired
Recipients that fail are skipped with a logged warning — the campaign doesn’t fail, but you don’t get a message for that recipient. Inspect the workflow trace and the application logs to identify skipped recipients. If the recipient list is empty, the system also short-circuits with a warning — no message is created, no send metric fires.

Custom fields

Anything beyond id + email is custom. Common patterns:
FieldPurpose
displayNameUsed in salutation: "Hello {{ recipient.displayName }}".
localeDrives template selection (en-US vs fr-FR).
timezoneTime-aware formatting in the template.
roleBranch the template on the recipient’s relationship to the event.
unsubscribeUrlOne-time per-message link, useful for marketing campaigns.
The channel template has access to the full recipient object as {{ recipient }} (or your variable system’s equivalent). See brands and variables for variable resolution rules.

Audience size

Recipients are processed sequentially per workflow instance — the orchestrator returns the full list, then the channel processor iterates. There’s no hard cap, but practical limits apply:
  • Per-workflow-instance — sub-second per recipient on healthy infrastructure. A 10,000-recipient campaign takes around 10 seconds end-to-end.
  • Total throughput — bound by the queue’s parallelism. Burst-fire scenarios benefit from transactional priority for the urgent campaigns.
  • Memory — the orchestrator’s return value is held in memory. Avoid > 100k recipients per single instance; split via dynamic recipient source pagination if you genuinely need that scale.
For broadcasts to large audiences, consider firing one event per recipient (more workflow instances, more parallelism) rather than one event with a giant recipient list.

Suppression and opt-out

Notifizz doesn’t ship a built-in suppression list — the orchestrator is the right place. Pattern:
const user = await enrichWith("fetchUser", { userId: event.properties.userId });
if (user.optOutMarketing) return [];   // skip; no message fires
return [{ id: user.id, email: user.email }];
Bake the opt-out check into your enricher’s data model. For audit, prefer “log skip and return empty” over “filter silently” — the workflow trace then shows why no message was created.

FAQ

Put userId in the event properties and let the orchestrator return that user as the only recipient. The SDK never picks recipients — that’s the deliberate design.
Use a dynamic recipient source. The dashboard surfaces saved audiences (or computed ones via a query DSL); the orchestrator calls fetchSource(audienceId) to resolve.
Three causes: (1) the orchestrator’s filter excluded everyone (e.g. all users have optOutMarketing: true); (2) the enricher returned empty; (3) you returned [] literally as a placeholder. The workflow trace shows the orchestrator’s output — inspect it.
Skipped with a logged warning. The campaign doesn’t fail; the other (valid) recipients still receive the message. Inspect the warning to identify the broken recipient and fix upstream (data, enricher, or orchestrator logic).
No platform-imposed cap, but practical limits apply (see “Audience size” above). For very large audiences, split via pagination or fire one event per recipient. For “soft caps” (e.g. “max 1000 recipients per campaign per day”), implement at the orchestrator level — fetch the count, refuse if over.
Not as a platform feature. Implement at the orchestrator + enricher level: read a suppressed field per user and filter. The advantage is that the suppression rule lives next to the campaign logic and shows up in the audit trail.

See also

Orchestrator

Where the recipient logic lives.

Enrichers tutorial

Live data lookups for recipient details.

Events

What gets passed in event.properties.

Brands and variables

Where recipient fields are referenced from templates.