> ## Documentation Index
> Fetch the complete documentation index at: https://docs.manticscore.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Realtime toasts and in-app notifications

> Bootstrap a WebSocket session, subscribe to user-scoped topics, register a device for APNS fallback, and publish toasts from external services.

The toast endpoints power realtime in-app notifications. The flow is:

1. Exchange a Clerk session for a short-lived WebSocket token.
2. Open a WebSocket to the realtime gateway with that token.
3. Subscribe to one or more topics (always within your own user namespace).
4. (Optional) Register an APNS token so undelivered toasts fall back to a push notification.

Internal services publish toasts directly inside the API process. External services use the publish endpoint with a shared bearer secret.

## Bootstrap a WebSocket token

Mint a short-lived WebSocket token bound to a (user, device) pair. Clients call this with their Clerk session, then open a WebSocket to the URL returned in the response.

Rate limit: **30 requests per minute**.

```
POST /v1/sessions/toast-bootstrap
```

<ParamField body="clerk_jwt" type="string">
  Optional. Informational mirror of the Clerk JWT — the `Authorization` header is the source of truth and is verified by the auth middleware. Provided so callers can document the binding explicitly; the body field is not re-verified.
</ParamField>

<ParamField body="device_id" type="string">
  Stable per-device identifier. Optional — when omitted, the server synthesizes a deterministic id of the form `clerk:<user_id>` that is stable across reconnects. Pass an explicit value when two devices on the same Clerk account need to differentiate themselves (separate APNS tokens, distinct presence rows).
</ParamField>

<ParamField body="bundle_id" type="string" default="com.bairisland.manticscore">
  iOS bundle identifier of the calling app.
</ParamField>

<ParamField body="default_topics" type="array">
  Topics to pre-subscribe at connect. Topics outside the user's own namespace are silently dropped — only `user:<user_id>:*` and arbitrary non-`user:*` topics are allowed. When the array is empty, the server inserts `user:<user_id>` so first-connect subscriptions work without any client-side topic plumbing.
</ParamField>

```json 200 response theme={null}
{
  "token": "<opaque ws token>",
  "ws_url": "wss://api.manticscore.com/api/v1/realtime?token=<opaque ws token>",
  "expires_at": "2026-05-05T08:45:00+00:00",
  "default_topics": ["user:user_2abcXYZ:default"]
}
```

<ResponseField name="token" type="string" required>
  Short-lived WebSocket token (15-minute TTL). Treat as a credential.
</ResponseField>

<ResponseField name="ws_url" type="string" required>
  Pre-formatted WebSocket URL with the token embedded as a query parameter.
</ResponseField>

<ResponseField name="expires_at" type="string" required>
  ISO 8601 timestamp when the token expires. Re-bootstrap before this to maintain a continuous connection.
</ResponseField>

<ResponseField name="default_topics" type="array" required>
  The subset of `default_topics` that passed the namespace check.
</ResponseField>

## Register a device for APNS fallback

Register (or refresh) an iOS device for APNS fallback delivery. Toasts dispatched while no foreground WebSocket is connected are forwarded to APNS instead of dropped.

Rate limit: **30 requests per minute**.

```
POST /v1/devices/register-toast
```

<ParamField body="device_id" type="string" required>
  Stable per-device identifier; must match the one used in `toast-bootstrap`. If the bootstrap call omitted `device_id`, pass the synthesized `clerk:<user_id>` value here.
</ParamField>

<ParamField body="bundle_id" type="string" default="com.bairisland.manticscore">
  iOS bundle identifier.
</ParamField>

<ParamField body="apns" type="object">
  <Expandable title="properties">
    <ParamField body="device_token" type="string">
      APNS device token. Send `null` to disable fallback.
    </ParamField>

    <ParamField body="environment" type="string" default="sandbox">
      One of `sandbox` or `production`.
    </ParamField>

    <ParamField body="fallback_enabled" type="boolean" default="false">
      When `true`, the server falls back to APNS for toasts dispatched while no foreground WebSocket is connected.
    </ParamField>
  </Expandable>
</ParamField>

```json 200 response theme={null}
{
  "registered": true,
  "device_id": "device-uuid",
  "apns_fallback_enabled": true
}
```

## Publish a toast from an external service

External publishers (separate services or scripts) call this endpoint to deliver a toast to a user. Internal services should call `services.toasts.publish_toast()` directly instead — no HTTP round-trip, no shared secret.

Rate limit: **120 requests per minute**.

```
POST /v1/toasts/publish
```

Authentication is a bearer token whose value is the shared `TOAST_PUBLISH_SECRET_KEY`:

```
Authorization: Bearer <toast_publish_secret>
```

<ParamField body="user_id" type="string" required>
  Recipient Clerk user id.
</ParamField>

<ParamField body="topic" type="string" required>
  Topic to publish on. Subscribers receive only the topics they have explicitly subscribed to.
</ParamField>

<ParamField body="toast" type="object" required>
  <Expandable title="properties">
    <ParamField body="title" type="string" required>
      Headline. Required.
    </ParamField>

    <ParamField body="body" type="string">
      Optional body text.
    </ParamField>

    <ParamField body="style" type="string" default="info">
      Visual style hint. Common values: `info`, `success`, `warning`, `error`.
    </ParamField>

    <ParamField body="priority" type="string" default="normal">
      Delivery priority. Common values: `low`, `normal`, `high`.
    </ParamField>

    <ParamField body="duration_ms" type="number" default="3000">
      Display duration in milliseconds.
    </ParamField>

    <ParamField body="deeplink" type="string">
      Optional deep link to open on tap.
    </ParamField>

    <ParamField body="metadata" type="object">
      Free-form metadata forwarded to the client.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="dedupe_key" type="string">
  Optional. The server drops a toast if another with the same `dedupe_key` was delivered to this user recently.
</ParamField>

<ParamField body="collapse_key" type="string">
  Optional. The client collapses toasts that share a `collapse_key`, replacing the previous one in place.
</ParamField>

<ParamField body="ttl_ms" type="number">
  Optional. Server-side TTL after which the toast is dropped if not delivered.
</ParamField>

<ParamField body="fallback_apns" type="boolean" default="true">
  When `true`, the server falls back to APNS if the user has no foreground WebSocket connection.
</ParamField>

```json 202 response theme={null}
{
  "delivered": true,
  "transport": "websocket",
  "session_count": 1
}
```

Returns `401 invalid_publish_key` if the bearer is wrong, and `503 toast_publish_disabled` if the secret is unset server-side.

## List recent toasts for the current user

Replay surface for the authenticated user. Useful for a notification-center UI.

Rate limit: **60 requests per minute**.

```
GET /v1/toasts
```

<ParamField query="limit" type="number" default="50">
  Page size, range 1–200.
</ParamField>

```json 200 response theme={null}
{
  "items": [
    {
      "id": "01HXY7Z1...",
      "topic": "user:user_2abcXYZ:default",
      "title": "Research complete",
      "body": "AI expense tracker for freelancers",
      "style": "success",
      "priority": "normal",
      "deeplink": "manticscore://research/01HXY7AA...",
      "created_at": "2026-05-05T08:35:00+00:00"
    }
  ]
}
```

## WebSocket realtime gateway

Open a multiplexed WebSocket to receive toasts in realtime.

```
GET wss://api.manticscore.com/api/v1/realtime?token=<ws_token>
```

The token is obtained from `POST /v1/sessions/toast-bootstrap` and expires after 15 minutes. Connections with an expired or unknown token receive an `error` frame and a close with code `4001`. Connections without a token are closed with code `4002`.

### Server frames

On successful connect, the server immediately sends:

```json theme={null}
{
  "type": "connection_ack",
  "session_id": "<uuid>",
  "topics": ["user:user_2abcXYZ:default"]
}
```

Subsequent server frames carry typed messages — toasts, control envelopes, and protocol acks.

### Client messages

The client may send the following JSON messages:

<Tabs>
  <Tab title="subscribe">
    ```json theme={null}
    {"type": "subscribe", "topics": ["user:user_2abcXYZ:builds"]}
    ```

    Topics outside the user's own namespace are rejected with an `error` frame of code `topic_not_allowed`.
  </Tab>

  <Tab title="unsubscribe">
    ```json theme={null}
    {"type": "unsubscribe", "topics": ["user:user_2abcXYZ:builds"]}
    ```
  </Tab>

  <Tab title="client_state">
    ```json theme={null}
    {"type": "client_state", "foreground_state": "foreground", "overlay_mounted": true}
    ```

    `foreground_state` accepts `foreground`, `background`, or `unknown`. Unknown values are coerced to `unknown`.
  </Tab>

  <Tab title="ping">
    ```json theme={null}
    {"type": "ping"}
    ```

    Server replies with `{"type": "pong"}`. The heartbeat sweeper culls connections idle longer than 60 seconds — send a `ping` at least every 30 seconds.
  </Tab>
</Tabs>

### Close codes

| Code   | Meaning                                  |
| ------ | ---------------------------------------- |
| `4001` | Token expired or rejected after upgrade. |
| `4002` | Missing token at connect.                |

<Note>
  The realtime gateway is multi-worker safe. The publisher uses a Redis pub/sub channel keyed on the user, so any worker holding a foreground connection delivers the toast — APNS fallback is only used when no worker has a connection.
</Note>

### Silent control envelopes

The same WebSocket also carries silent control messages with envelope `{"type": "control", "control": {"kind": "...", "params": {...}, "ts": "..."}}`. These are dispatched by the server when state the client cares about changes — for example:

* `usage.changed` — fired when an entitlement transitions (RC webhook event, Apple server-to-server notification, or admin credit grant). The client refetches `GET /v1/usage`.
* `flags.changed` — broadcast to all clients when CMS feature flags update.
* `research.completed` — fired when a research pipeline run reaches a terminal completed state. Lets the client exit its `/research/{id}/status` polling loop immediately instead of waiting for the next poll tick. The `params` payload includes `research_id`, `project_id`, `pipeline_stage` (for example, `priorities_picked`), and `picked_count`.
* `research.failed` — fired when a research pipeline run terminates without producing usable output. Pairs with `research.completed` so the client exits its polling loop on failure as well as success. The `params` payload includes `research_id`, `code`, `message` (truncated to 200 characters), and `retryable`.
* `build_graph.ready` — fired when a build graph reaches the terminal `done` state. Lets the client exit polling on `GET /build-graphs/{id}` or `GET /projects/{id}/full` for graph readiness; the per-step NDJSON `/build-graphs/{id}/events` stream remains the push surface for intermediate stages. The `params` payload includes `graph_id`, `root_count`, and `duration_ms`, plus `project_id` when the graph is associated with a project.
* `build_graph.failed` — fired when a build graph terminates in an error state. Pairs with `build_graph.ready` so the client exits polling on failure too. The `params` payload includes `graph_id`, `code`, `message` (truncated to 200 characters), and `retryable`.
* `feature_research.completed` — fired when a feature research run finishes. Lets the client exit polling on `GET /feature-research/{id}/status`. The `params` payload includes `feature_research_id` and `duration_ms`.
* `feature_research.failed` — fired when a feature research run terminates with a pipeline error. Pairs with `feature_research.completed` so the client exits polling on failure too. The `params` payload includes `feature_research_id`, `code` (for example, `PIPELINE_ERROR`), `message` (truncated to 200 characters), `stage`, and `retryable`.
* `brief.ready` — fired when a brief reaches the terminal ready state. Lets the client exit polling on `GET /briefs/{id}`; the per-brief NDJSON stream remains the detail-view push surface. The `params` payload includes `brief_id`, `research_id`, `title`, and `quality_score`.
* `brief.failed` — fired when brief generation cannot proceed (for example, the source research is missing or incomplete). Pairs with `brief.ready` so the client exits polling on failure too. The `params` payload includes `brief_id`, `research_id`, and `message`.
* `forge_run.status_changed` — fired on every status transition for a forge run so list views update without polling `GET /forge/runs`; the per-run NDJSON stream remains the detail-view push surface. The `params` payload includes `run_id`, `status`, and `error` (truncated to 200 characters) when the new status is a failure state.
* `intel_company.enriched` — fired when a company record finishes enrichment in the watchlist or signals UI. Lets the client exit polling for enrichment status. The `params` payload includes `company_id`, `name`, `domain`, and `sector`.
* `signals.detected` — fired when a competitive intelligence crawl produces significant signals (confidence above 0.7 or a change-type signal) for a monitored company. The client invalidates the signals list cache for that company. The `params` payload includes `company_id`, `company_name`, `monitor_id`, `count`, and `first_title` (truncated to 200 characters).
* `products.changed` — fired when a product card row transitions to a new pipeline state or progress checkpoint. The client invalidates its local product list and refreshes only the affected card instead of refetching `GET /products`. The `params` payload includes `product_card_id`, `research_job_id`, `state`, and `progress`.

Control envelopes are not persisted and have no APNS fallback — every control kind has a corresponding REST endpoint the client can refetch from on reconnect. SDKs route these to a separate `onControl` handler so they do not surface as toast overlays.
