# Notification Platform (/docs/notifications)



Ductor's notification platform is the **send side** of an in-product messaging
system: you register the people who can be notified, group them, decide which
channels each one accepts, bind every channel to a provider, wrap the render in an
envelope, and then fire a single trigger that fans out across every channel the
recipient allows. This page walks that path end to end.

The **receive side** — the in-app notification centre an end user opens to read
what was sent — lives on its own page:
[Inbox](/docs/notifications/inbox).

<Callout type="info" title="Everything here is tenant-scoped">
  Every object below is keyed by the authenticated tenant. The tenant is always
  taken from the request context, never from the payload. Local-dev curls pass it
  explicitly with `-H "X-Tenant-ID: $DUCTOR_TENANT_ID"`.
</Callout>

## The path [#the-path]

<NotificationFabric />

Read it left to right: a **subscriber** is who you can reach, a **topic** is a
fan-out group of subscribers, **preferences** decide which channels fire, an
**integration** binds each channel to a provider connection, a **layout** wraps the
render, and the **trigger** dispatches and records one delivery per channel.

## Subscribers [#subscribers]

A **subscriber** is a first-class recipient. It is matched on
`(tenant, external_id)` at upsert time — `external_id` is your own opaque user id,
unique per tenant — so a create with an id you have seen before updates the existing
row rather than duplicating it. A subscriber carries profile fields (`first_name`,
`last_name`, `email`, `phone`, `avatar`, `locale`, `timezone`) and an arbitrary
`data` JSON blob. Delete is a soft delete: `active` flips to false and the row's
endpoints and subscriptions stay queryable for audit.

The subscriber service (`/api/v2/notification/subscribers`, scope
`notification:read` / `notification:write`) owns three sub-resources — endpoints,
subscriptions, and preferences — for a total of fourteen operations.

| Operation                     | Method + path                                                                       | Purpose                                                                     |
| ----------------------------- | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Create / upsert               | `POST /api/v2/notification/subscribers`                                             | Upsert by `(tenant, external_id)`.                                          |
| Get                           | `GET /api/v2/notification/subscribers/{id}`                                         | Fetch by internal id.                                                       |
| List                          | `GET /api/v2/notification/subscribers`                                              | Page, newest first.                                                         |
| Update                        | `PUT /api/v2/notification/subscribers/{id}`                                         | Replace mutable profile fields (still matched on `external_id`).            |
| Delete                        | `DELETE /api/v2/notification/subscribers/{id}`                                      | Soft delete (`active=false`).                                               |
| Add endpoint                  | `POST /api/v2/notification/subscribers/{subscriber_id}/endpoints`                   | Upsert a channel address.                                                   |
| Remove endpoint               | `DELETE /api/v2/notification/subscribers/{subscriber_id}/endpoints/{endpoint_id}`   | Soft-delete an address.                                                     |
| List endpoints                | `GET /api/v2/notification/subscribers/{subscriber_id}/endpoints`                    | Optional `channel` filter.                                                  |
| Subscribe to topic            | `POST /api/v2/notification/subscribers/{subscriber_id}/subscriptions`               | Attach with optional context keys.                                          |
| Unsubscribe                   | `DELETE /api/v2/notification/subscribers/{subscriber_id}/subscriptions/{topic_key}` | Detach.                                                                     |
| List subscriptions            | `GET /api/v2/notification/subscribers/{subscriber_id}/subscriptions`                | Every topic the subscriber is in.                                           |
| Get preferences               | `GET /api/v2/notification/subscribers/{subscriber_id}/preferences`                  | Every preference row the subscriber owns (layers 3–5).                      |
| Update preferences            | `POST /api/v2/notification/subscribers/{subscriber_id}/preferences`                 | Upsert one subscriber-owned preference row.                                 |
| Preview effective preferences | `POST /api/v2/notification/subscribers/{subscriber_id}/preferences:preview`         | Run the production resolver without dispatching or mutating delivery state. |

### Channel endpoints [#channel-endpoints]

A **channel endpoint** is the address at which a subscriber receives one channel via
one provider. Channels are `email`, `sms`, `push`, `chat`, and `in_app`. An endpoint
carries a `channel`, a `provider_key` (e.g. `fcm`, `sendgrid`), an `identity` (the
email string, device token, chat id …), an optional `connection_id` referencing a
connector connection, a `verified` flag for double opt-in, and free-form `metadata`.
Uniqueness is scoped to `(tenant, channel, provider, identity)`; re-adding the same
address just refreshes its `last_used_at`.

```bash
curl -X POST "$DUCTOR_API/api/v2/notification/subscribers/$SUB/endpoints" \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{"channel":"push","provider_key":"fcm","identity":"<device-token>","verified":true}'
```

### The five-layer preference resolver [#the-five-layer-preference-resolver]

Whether a channel actually fires for a given trigger is decided by a **five-layer
resolver**. The layers are ordered weakest to strongest; a stronger layer overrides a
weaker one for a channel unless a weaker layer set that channel `read_only` (a lock a
stronger layer may not flip).

<TypeTable
  type="{
  &#x22;Layer 1 — Workflow default&#x22;: { type: &#x22;author-owned&#x22;, description: &#x22;The workflow-author default declared in the workflow definition. Keyed by workflow_key. Weakest.&#x22; },
  &#x22;Layer 2 — User/workflow&#x22;: { type: &#x22;author-owned&#x22;, description: &#x22;The dashboard-author default. Keyed by workflow_key + user_id; enabled by passing user_id on the trigger.&#x22; },
  &#x22;Layer 3 — Subscriber global&#x22;: { type: &#x22;subscriber-owned&#x22;, description: &#x22;The subscriber's all-channels default. Keyed by subscriber.&#x22; },
  &#x22;Layer 4 — Subscriber/workflow&#x22;: { type: &#x22;subscriber-owned&#x22;, description: &#x22;The subscriber's per-workflow override. Keyed by subscriber + workflow_key.&#x22; },
  &#x22;Layer 5 — Subscription/subscriber/workflow&#x22;: { type: &#x22;subscriber-owned&#x22;, description: &#x22;The per-topic-subscription override, scoped by context_keys. Keyed by subscriber + workflow_key + topic_key + context_keys. Strongest.&#x22; },
}"
/>

Each preference row carries an `all` default channel decision, per-channel overrides
(`enabled`, `read_only`, and an optional CEL `condition`), an optional weekly
quiet-hours `schedule`, a record-level `read_only` lock, and an optional record-level
CEL `condition`. `UpdateSubscriberPreferences` edits **only** layers 3, 4, and 5 —
layers 1 and 2 are author-owned and set elsewhere. Layer 3 needs no extra keying,
layer 4 needs `workflow_key`, and layer 5 needs `workflow_key` + `topic_key` +
`context_keys`.

<Callout type="info" title="Quiet hours can be bypassed by a lock">
  During the resolve, the strongest non-nil `schedule` wins; if the current
  weekday/time falls inside a silent range, every channel is marked disallowed —
  unless the channel's winning layer set `read_only=true`, which is the transactional
  bypass for messages that must go out regardless of quiet hours.
</Callout>

### Preview the effective decision [#preview-the-effective-decision]

Use `preferences:preview` to explain what dispatch would do for one subscriber and
workflow before sending. It runs resolver version `preference-v2` and returns each
channel's `allowed` decision, stable `reason_code`, `winning_layer`, and ordered
per-layer trace. Optional `user_id`, `topic_key`, `context_keys`, and `channels`
make the preview match the intended trigger.

`payload_context` is available for CEL evaluation, but it is transient: encoded
input is limited to 64 KiB and is never persisted or echoed in the response.

```bash
curl -X POST "$DUCTOR_API/api/v2/notification/subscribers/$SUB/preferences:preview" \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_key": "lead.assigned",
    "channels": ["email", "push"],
    "payload_context": {"lead_tier": "enterprise"}
  }'
```

## Topics [#topics]

A **topic** is a named, tenant-scoped group of subscribers and the **fan-out unit**.
When a trigger is admitted, Ductor records a membership cutoff and then expands the
eligible members as of that cutoff with stable keyset pagination. Membership changes
after admission do not change that broadcast. Topics are keyed by `{key}` (a
lower-snake-or-dot slug, 1–128 chars, unique per tenant); the key is immutable once
created. Membership entries may carry `context_keys` that scope layer-5 preference
resolution. Eight operations, on `/api/v2/notification/topics`, scope
`notification:read` / `notification:write`.

Topic triggers are asynchronous and require the durable fan-out queue. There is no
synchronous fallback: admission fails before acceptance if the payload object,
manifest, or queue cannot be persisted. See
[Delivery Reliability](/docs/notifications/delivery-reliability) for limits and
recovery behavior.

| Operation          | Method + path                                                          |
| ------------------ | ---------------------------------------------------------------------- |
| Create             | `POST /api/v2/notification/topics`                                     |
| Get                | `GET /api/v2/notification/topics/{key}`                                |
| List               | `GET /api/v2/notification/topics`                                      |
| Update             | `PUT /api/v2/notification/topics/{key}`                                |
| Delete             | `DELETE /api/v2/notification/topics/{key}` (cascades to subscriptions) |
| Add subscribers    | `POST /api/v2/notification/topics/{key}/subscribers`                   |
| Remove subscribers | `POST /api/v2/notification/topics/{key}/subscribers:remove`            |
| List subscribers   | `GET /api/v2/notification/topics/{key}/subscribers`                    |

<Callout type="info" title="Why :remove is a custom verb, not a DELETE">
  Removing members takes a body (a list of `subscriber_ids`), so it is modelled as
  the custom verb `POST …/subscribers:remove` rather than a `DELETE`. Unknown members
  in the list are ignored.
</Callout>

## Integrations [#integrations]

A **notification integration** binds a `(channel, provider)` pair to a **connector
connection** that holds the encrypted credentials — it is the join between "I want to
send email" and "here is the account that sends it". Five operations, on
`/api/v2/notification/integrations`, scope `notification:read` / `notification:write`.

An integration carries a `channel`, a `name` (unique per `(tenant, channel)`), a
`provider_key`, the `connection_id`, a `priority` (lower wins; defaults to 100, ties
break by `created_at` ascending), an `active` flag, an optional CEL `condition` for
conditional routing, an optional `casing_override`, and `metadata`. At dispatch time
the resolver picks the highest-precedence active integration whose condition matches.

| Operation | Method + path                                                                                      |
| --------- | -------------------------------------------------------------------------------------------------- |
| List      | `GET /api/v2/notification/integrations`                                                            |
| Get       | `GET /api/v2/notification/integrations/{id}`                                                       |
| Create    | `POST /api/v2/notification/integrations`                                                           |
| Update    | `PUT /api/v2/notification/integrations/{id}`                                                       |
| Delete    | `DELETE /api/v2/notification/integrations/{id}` (soft delete; excluded from selection immediately) |

<Callout type="info" title="The credentials live in the connection, not here">
  An integration only references a `connection_id`. The encrypted credential, its
  rotation, and its lifecycle belong to the connector connection. See
  [Connections](/docs/connectors/connections) and
  [Outbound Integrations](/docs/connectors/outbound-integrations) for how that
  connection is authenticated and its secrets stored.
</Callout>

## Layouts [#layouts]

A **layout** is a channel-scoped envelope template that wraps a content render
through the `{{ content }}` placeholder — the outer shell (header, footer, styling)
that every message on a channel shares. Layouts render with `handlebars` or `liquid`,
are partitioned by channel (`email`, `sms`, `push`, `chat`, `inapp`), and at most one
layout per `(tenant, channel)` may be the default. Nine operations, on
`/api/v2/layouts`, scope `layout:read` / `layout:write`.

| Operation   | Method + path                                                        |
| ----------- | -------------------------------------------------------------------- |
| Create      | `POST /api/v2/layouts`                                               |
| Get         | `GET /api/v2/layouts/{id}`                                           |
| List        | `GET /api/v2/layouts` (optional `channel` filter)                    |
| Update      | `PUT /api/v2/layouts/{id}` (optimistic — requires current `version`) |
| Delete      | `DELETE /api/v2/layouts/{id}`                                        |
| Get usage   | `GET /api/v2/layouts/{id}/usage` (workflows referencing it)          |
| Set default | `POST /api/v2/layouts/{id}:setDefault`                               |
| Duplicate   | `POST /api/v2/layouts/{id}:duplicate`                                |
| Preview     | `POST /api/v2/layouts/{id}:preview`                                  |

The content body **must** contain `{{ content }}` — create and update reject a body
without it. `:setDefault` demotes the previous default on the same channel in the same
transaction, preserving the at-most-one-default invariant. `:preview` renders the body
server-side against a sample payload (replacing `{{ content }}` with a deterministic
stand-in) and reports resolved and unresolved variables, so authors see the real
envelope without client-side substitution drift. `:duplicate` clones a layout with a
fresh identifier, a `" (copy)"` name suffix, and never marked default.

## Trigger and deliveries [#trigger-and-deliveries]

The **trigger** is the single send entry point. `TriggerNotification` resolves the
recipient, runs the five-layer resolver, selects an integration per channel, renders
through the layout, dispatches via the provider, and records one delivery per
`(channel, endpoint)`. Five operations cover dispatch and recovery, scope
`notification:read` / `notification:write`; redrive requires `notification:admin`.

| Operation                  | Method + path                                                      |
| -------------------------- | ------------------------------------------------------------------ |
| Trigger                    | `POST /api/v2/notifications/trigger`                               |
| List deliveries            | `GET /api/v2/notifications/deliveries`                             |
| List dead letters          | `GET /api/v2/notifications/dead-letters`                           |
| Redrive a dead letter      | `POST /api/v2/notifications/dead-letters/{dead_letter_id}:redrive` |
| List delivery observations | `GET /api/v2/notifications/deliveries/{delivery_id}/observations`  |

The recipient is exactly one of: `subscriber_external_id` (with an optional inline
`subscriber` profile that is upserted before dispatch), an existing `subscriber_id`,
or a `topic` fan-out. You may restrict `channels` and pass per-channel `overrides`; an
omitted channel list means every channel the preferences allow. The response echoes
the `transaction_id` and a per-channel/endpoint `outcomes` list (`sent` / `failed` /
`skipped`). For an accepted topic trigger, outcomes accrue asynchronously in delivery
records rather than in the admission response.

`ListNotificationDeliveries` reads the recorded rows by **exactly one** of
`transaction_id` (every channel/endpoint from one trigger) or `subscriber_id` (recent
deliveries, newest first). Each row carries channel, status
(`queued` / `running` / `sent` / `failed` / `skipped`), provider message id, typed
failure phase and code, preference reason code and resolver version, retry timing,
execution and provider attempt numbers, downstream observation state, reconciliation
flag, and timestamps.

<Callout type="warn" title="sent means provider accepted, not recipient delivered">
  Provider acceptance and downstream delivery are separate facts. `status=sent`
  means the provider accepted the request. Use `observation_state` and the append-only
  observations endpoint for later delivery or engagement evidence. Ductor does not
  expose a generic unauthenticated provider callback; observations are accepted only
  through trusted tenant-attributed adapters.
</Callout>

```bash
curl -X POST "$DUCTOR_API/api/v2/notifications/trigger" \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_key": "lead.assigned",
    "subscriber_external_id": "user_42",
    "payload": {"lead_name": "Acme Corp"}
  }'
```

### The dispatch feature flag [#the-dispatch-feature-flag]

The dispatch path is gated by the config flag &#x2A;*`notification.dispatch.enabled`**. The
management surface — subscribers, topics, integrations, layouts, preferences — is
always available; the flag gates only the send itself.

<Callout type="warn" title="When notification.dispatch.enabled is off, trigger short-circuits">
  With the flag off, send requests return a precondition failure (HTTP `412`).
  No delivery rows are written and nothing is sent. Subscriber, topic, integration,
  layout, and preference management remains available. In the enterprise security
  profile, enabling dispatch also requires a tiered queue for durable fan-out.
</Callout>

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Delivery Reliability" href="/docs/notifications/delivery-reliability">
    Idempotent attempts, bounded fan-out, protected payloads, observations, dead
    letters, and audited recovery.
  </Card>

  <Card title="Inbox" href="/docs/notifications/inbox">
    The receive side — the in-app notification centre end users read.
  </Card>

  <Card title="Connections" href="/docs/connectors/connections">
    The connector connection an integration binds to for encrypted credentials.
  </Card>

  <Card title="Outbound Integrations" href="/docs/connectors/outbound-integrations">
    Ductor as an event source for external automation platforms.
  </Card>
</Cards>
