Notifications

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.

Tenant scoped
TriggerBusiness signaltransaction_id · workflow_key
ResolvePreference policy5 ordered layers
Evaluate each channel
  • Emailaddress
  • SMSnumber
  • Pushdevice
  • Chatidentity
  • In-appinbox
Every allowed pathDurable delivery contract
  1. Payload refprotected
  2. Attempt ledgerfenced
  3. Provideraccepted
  4. Evidenceobserved
policy and execution known provider acceptance downstream evidence

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.

OperationMethod + pathPurpose
Create / upsertPOST /api/v2/notification/subscribersUpsert by (tenant, external_id).
GetGET /api/v2/notification/subscribers/{id}Fetch by internal id.
ListGET /api/v2/notification/subscribersPage, newest first.
UpdatePUT /api/v2/notification/subscribers/{id}Replace mutable profile fields (still matched on external_id).
DeleteDELETE /api/v2/notification/subscribers/{id}Soft delete (active=false).
Add endpointPOST /api/v2/notification/subscribers/{subscriber_id}/endpointsUpsert a channel address.
Remove endpointDELETE /api/v2/notification/subscribers/{subscriber_id}/endpoints/{endpoint_id}Soft-delete an address.
List endpointsGET /api/v2/notification/subscribers/{subscriber_id}/endpointsOptional channel filter.
Subscribe to topicPOST /api/v2/notification/subscribers/{subscriber_id}/subscriptionsAttach with optional context keys.
UnsubscribeDELETE /api/v2/notification/subscribers/{subscriber_id}/subscriptions/{topic_key}Detach.
List subscriptionsGET /api/v2/notification/subscribers/{subscriber_id}/subscriptionsEvery topic the subscriber is in.
Get preferencesGET /api/v2/notification/subscribers/{subscriber_id}/preferencesEvery preference row the subscriber owns (layers 3–5).
Update preferencesPOST /api/v2/notification/subscribers/{subscriber_id}/preferencesUpsert one subscriber-owned preference row.
Preview effective preferencesPOST /api/v2/notification/subscribers/{subscriber_id}/preferences:previewRun 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.

OperationMethod + path
CreatePOST /api/v2/notification/topics
GetGET /api/v2/notification/topics/{key}
ListGET /api/v2/notification/topics
UpdatePUT /api/v2/notification/topics/{key}
DeleteDELETE /api/v2/notification/topics/{key} (cascades to subscriptions)
Add subscribersPOST /api/v2/notification/topics/{key}/subscribers
Remove subscribersPOST /api/v2/notification/topics/{key}/subscribers:remove
List subscribersGET /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.

OperationMethod + path
ListGET /api/v2/notification/integrations
GetGET /api/v2/notification/integrations/{id}
CreatePOST /api/v2/notification/integrations
UpdatePUT /api/v2/notification/integrations/{id}
DeleteDELETE /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.

OperationMethod + path
CreatePOST /api/v2/layouts
GetGET /api/v2/layouts/{id}
ListGET /api/v2/layouts (optional channel filter)
UpdatePUT /api/v2/layouts/{id} (optimistic — requires current version)
DeleteDELETE /api/v2/layouts/{id}
Get usageGET /api/v2/layouts/{id}/usage (workflows referencing it)
Set defaultPOST /api/v2/layouts/{id}:setDefault
DuplicatePOST /api/v2/layouts/{id}:duplicate
PreviewPOST /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.

OperationMethod + path
TriggerPOST /api/v2/notifications/trigger
List deliveriesGET /api/v2/notifications/deliveries
List dead lettersGET /api/v2/notifications/dead-letters
Redrive a dead letterPOST /api/v2/notifications/dead-letters/{dead_letter_id}:redrive
List delivery observationsGET /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