Notification Platform
The multi-channel notification send-side — subscribers, topics, the five-layer preference resolver, channel integrations, layouts, and the dispatch trigger.
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.
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".
The path
Signal map · 01
One trigger. Five governed paths. Every attempt accounted for.
transaction_id · workflow_key5 ordered layers- Emailaddress
- SMSnumber
- Pushdevice
- Chatidentity
- In-appinbox
- Payload refprotected
- Attempt ledgerfenced
- Provideraccepted
- Evidenceobserved
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
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
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.
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
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).
Prop
Type
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.
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.
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.
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
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 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 |
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.
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) |
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 and
Outbound Integrations for how that
connection is authenticated and its secrets stored.
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
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.
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.
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 path is gated by the config flag notification.dispatch.enabled. The
management surface — subscribers, topics, integrations, layouts, preferences — is
always available; the flag gates only the send itself.
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.
Where to go next
Delivery Reliability
Idempotent attempts, bounded fan-out, protected payloads, observations, dead letters, and audited recovery.
Inbox
The receive side — the in-app notification centre end users read.
Connections
The connector connection an integration binds to for encrypted credentials.
Outbound Integrations
Ductor as an event source for external automation platforms.
Clearing API Reference
Ductor's REST and Connect-RPC API — auth model, tenancy, resource groups, and the full interactive reference.
Delivery Reliability
Idempotent notification attempts, bounded topic fan-out, protected payload references, delivery observations, dead letters, and explicit operator recovery.