Notifications

Inbox

The end-user in-app notification centre — listing, the read/seen/snooze/archive lifecycle, CTAs, WebSocket sessions, and the polling fallback.

The inbox is the receive side of the notification platform: the in-app notification centre an end user opens to see what was sent to them. Where the Notification Platform is about dispatching messages, the inbox is about consuming them — a durable per-subscriber feed with a read / seen / snooze / archive lifecycle, call-to-action buttons, and a live stream.

An inbox message is durable and lands here when a trigger dispatches the in_app channel to a subscriber, so the two sides meet at that channel: the platform sends, the inbox receives.

subscriber_id here is the external id

Across the inbox API the {subscriber_id} path segment is the caller-supplied external subscriber id (your own user id), not the internal sub_… id. Every call is tenant-scoped from the request context.

The message

A message carries title, body, the immutable payload JSON captured at trigger time, and up to two CTA slots. Its lifecycle flags are orthogonal — any subset may be set at once:

Prop

Type

A CTA is one action slot — key is primary or secondary, with a label, a url (the SDK validates the protocol against http / https / mailto), and a status of pending, invoked, or declined.

Operations

Thirteen operations under /api/inbox/{subscriber_id}, scope inbox:read / inbox:write.

OperationMethod + pathPurpose
List messagesGET /api/inbox/{subscriber_id}/messagesFeed page; filters for unread/unseen/archived/snoozed.
CountsGET /api/inbox/{subscriber_id}/countsCurrent unread + unseen counts.
Mark readPOST /api/inbox/{subscriber_id}/messages/{message_id}/readRecord an open.
Mark unreadPOST /api/inbox/{subscriber_id}/messages/{message_id}/unreadClear the read marker.
Mark seenPOST /api/inbox/{subscriber_id}/messages/{message_id}/seenRecord observation.
SnoozePOST /api/inbox/{subscriber_id}/messages/{message_id}/snoozeHide until a future snoozed_until.
UnsnoozePOST /api/inbox/{subscriber_id}/messages/{message_id}/unsnoozeClear a snooze.
ArchivePOST /api/inbox/{subscriber_id}/messages/{message_id}/archiveMove to the archive view.
UnarchivePOST /api/inbox/{subscriber_id}/messages/{message_id}/unarchiveReturn to the active feed.
DeleteDELETE /api/inbox/{subscriber_id}/messages/{message_id}Soft delete (permanent tombstone).
Invoke CTAPOST /api/inbox/{subscriber_id}/messages/{message_id}/cta/{cta_key}Record a CTA invoked / declined.
Mint sessionPOST /api/inbox/{subscriber_id}/sessionsShort-lived WebSocket JWT.
StreamGET /api/inbox/{subscriber_id}/streamPolling fallback: feed head + counts in one round-trip.

Every lifecycle mutation returns the post-write message envelope, so a client can update its local state from the response without re-listing.

Listing and counts

GET …/messages returns the active feed by default and takes boolean knobs — only_unread, only_unseen, include_snoozed, and include_archived (which returns the archive view instead of the active feed). It pages with limit (default 25, max 100) and an opaque cursor; the response carries a next_cursor when more pages exist. GET …/counts returns just the unread and unseen integers for the badge.

Live updates

The inbox has two ways to stay current: a real WebSocket stream (the primary path) and a REST polling fallback.

fallback when WS unavailable POST .../sessions Subscriber JWT + websocket_path Dial WebSocket gateway Live message + count events GET .../stream Feed head + counts (poll on a timer)

Sessions and the WebSocket

POST …/sessions mints a short-lived subscriber JWT scoped to the tenant and subscriber. The response returns the token plaintext once (it is never persisted in plaintext server-side), an expires_at, and a websocket_path — the gateway route the client dials with that token to open the live stream. An optional ttl bounds the lifetime; when a tenant runs in secure mode, an subscriber_hash (a hex HMAC-SHA256(secret, subscriber_id) binding) is required and an empty value is rejected.

GET .../stream is the polling fallback, not the socket

Despite its name, GET …/stream does not upgrade to a WebSocket — it is the REST polling fallback for clients that cannot reach the WebSocket gateway. It returns the active feed head plus the current unread/unseen counts in a single round-trip, and clients poll it on a timer. The actual live stream is the WebSocket reached via the websocket_path from a minted session.

curl -X POST "$DUCTOR_API/api/inbox/$SUB/sessions" \
  -H "Authorization: Bearer $DUCTOR_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{}'

Where to go next