# Inbox (/docs/notifications/inbox)



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](/docs/notifications) 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.

<Callout type="info" title="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.
</Callout>

## The message [#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:

<TypeTable
  type="{
  seen_at: { type: &#x22;timestamp&#x22;, description: &#x22;The subscriber observed the message in a feed (drives the unseen badge).&#x22; },
  read_at: { type: &#x22;timestamp&#x22;, description: &#x22;The subscriber opened the message (drives the unread count).&#x22; },
  snoozed_until: { type: &#x22;timestamp&#x22;, description: &#x22;Hidden from the active feed until this wall time, when the unsnoozer clears it.&#x22; },
  archived_at: { type: &#x22;timestamp&#x22;, description: &#x22;Moved out of the active feed into the archive view.&#x22; },
  deleted_at: { type: &#x22;timestamp&#x22;, description: &#x22;Soft-deleted; the tombstone is permanent.&#x22; },
}"
/>

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 [#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 [#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 [#live-updates]

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

```mermaid
flowchart TD
  A["POST .../sessions"] --> B["Subscriber JWT + websocket_path"]
  B --> C["Dial WebSocket gateway"]
  C --> D["Live message + count events"]
  E["GET .../stream"] -.->|"fallback when WS unavailable"| F["Feed head + counts (poll on a timer)"]
```

### Sessions and the WebSocket [#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.

<Callout type="warn" title="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.
</Callout>

```bash
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 [#where-to-go-next]

<Cards>
  <Card title="Notification Platform" href="/docs/notifications">
    The send side — subscribers, topics, integrations, layouts, and the trigger that
    fills this inbox.
  </Card>

  <Card title="Delivery Reliability" href="/docs/notifications/delivery-reliability">
    Durable fan-out, provider-attempt safety, delivery observations, and operator
    recovery boundaries.
  </Card>
</Cards>
