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.
| Operation | Method + path | Purpose |
|---|---|---|
| List messages | GET /api/inbox/{subscriber_id}/messages | Feed page; filters for unread/unseen/archived/snoozed. |
| Counts | GET /api/inbox/{subscriber_id}/counts | Current unread + unseen counts. |
| Mark read | POST /api/inbox/{subscriber_id}/messages/{message_id}/read | Record an open. |
| Mark unread | POST /api/inbox/{subscriber_id}/messages/{message_id}/unread | Clear the read marker. |
| Mark seen | POST /api/inbox/{subscriber_id}/messages/{message_id}/seen | Record observation. |
| Snooze | POST /api/inbox/{subscriber_id}/messages/{message_id}/snooze | Hide until a future snoozed_until. |
| Unsnooze | POST /api/inbox/{subscriber_id}/messages/{message_id}/unsnooze | Clear a snooze. |
| Archive | POST /api/inbox/{subscriber_id}/messages/{message_id}/archive | Move to the archive view. |
| Unarchive | POST /api/inbox/{subscriber_id}/messages/{message_id}/unarchive | Return to the active feed. |
| Delete | DELETE /api/inbox/{subscriber_id}/messages/{message_id} | Soft delete (permanent tombstone). |
| Invoke CTA | POST /api/inbox/{subscriber_id}/messages/{message_id}/cta/{cta_key} | Record a CTA invoked / declined. |
| Mint session | POST /api/inbox/{subscriber_id}/sessions | Short-lived WebSocket JWT. |
| Stream | GET /api/inbox/{subscriber_id}/stream | Polling 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.
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
Delivery Reliability
Idempotent notification attempts, bounded topic fan-out, protected payload references, delivery observations, dead letters, and explicit operator recovery.
Route & Workflow SDKs
Define durable, replayable workflows in your own codebase and host them at an HTTP bridge endpoint Ductor calls back into — in Go, TypeScript, or Python.