> ## 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 Recipient Resolution

> How recipients are produced — from event properties, from enrichers, from dynamic sources. Validation rules, audience caps, suppression.

# 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

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

<Note>
  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**.
</Note>

## Three patterns

### 1. From event properties (direct)

The simplest case — the event already carries the recipient identifier.

```javascript theme={null}
// Caller side
await client.track("user.signed_up", {
  userId: "u_42",
  email: "alice@example.com",
});
```

```typescript theme={null}
// 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.

```javascript theme={null}
// Caller side — only userId needed
await client.track("user.welcomed", { userId: "u_42" });
```

```typescript theme={null}
// 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](/docs/sdks/how-to/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

```typescript theme={null}
// 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:

```typescript theme={null}
// 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:

| Rule                            | Behaviour |
| ------------------------------- | --------- |
| `id` is a non-empty string      | required  |
| `email` is a non-empty string   | required  |
| `email` matches the email regex | required  |

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:

| Field            | Purpose                                                           |
| ---------------- | ----------------------------------------------------------------- |
| `displayName`    | Used in salutation: `"Hello {{ recipient.displayName }}"`.        |
| `locale`         | Drives template selection (`en-US` vs `fr-FR`).                   |
| `timezone`       | Time-aware formatting in the template.                            |
| `role`           | Branch the template on the recipient's relationship to the event. |
| `unsubscribeUrl` | One-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](/docs/concepts/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:

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

<AccordionGroup>
  <Accordion title="Send to one specific user from the SDK?">
    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.
  </Accordion>

  <Accordion title="Send to a segment?">
    Use a dynamic recipient source. The dashboard surfaces saved audiences (or computed ones via a query DSL); the orchestrator calls `fetchSource(audienceId)` to resolve.
  </Accordion>

  <Accordion title="Empty recipient list — why?">
    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.
  </Accordion>

  <Accordion title="Recipient missing `email` field — what happens?">
    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).
  </Accordion>

  <Accordion title="Cap audience size?">
    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.
  </Accordion>

  <Accordion title="Exclude a user (suppression list) — built in?">
    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.
  </Accordion>
</AccordionGroup>

## See also

<CardGroup cols={2}>
  <Card title="Orchestrator" icon="robot" href="/docs/concepts/orchestrator">
    Where the recipient logic lives.
  </Card>

  <Card title="Enrichers tutorial" icon="puzzle-piece" href="/docs/sdks/how-to/enrichers-tutorial">
    Live data lookups for recipient details.
  </Card>

  <Card title="Events" icon="bolt" href="/docs/concepts/events">
    What gets passed in `event.properties`.
  </Card>

  <Card title="Brands and variables" icon="palette" href="/docs/concepts/brands-and-variables">
    Where recipient fields are referenced from templates.
  </Card>
</CardGroup>
