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

# Conversation Routing

> Route inbound WhatsApp messages to your agent, your own webhook, or nothing — per number and per conversation.

Every Wassist phone number has a **default routing mode**, and every
conversation can optionally override it. The resolved mode — the
**effective routing** — decides what happens when a customer sends a
message.

## The routing modes

| Mode      | What happens on inbound                                                | When to use it                                            |
| --------- | ---------------------------------------------------------------------- | --------------------------------------------------------- |
| `agent`   | The connected agent replies as normal                                  | Default for numbers wired to an agent.                    |
| `webhook` | The message is forwarded to a single webhook; the agent is **not** run | When you want to handle replies in your own service.      |
| `null`    | The message is stored, nothing else happens                            | Pause routing without disconnecting the agent or webhook. |
| `sandbox` | Internal sandbox flow (shared system number)                           | System-managed only — see the note below.                 |

<Note>
  "No routing" is represented as **`null`** on the wire — there is no `"none"`
  string value. A number or conversation with `defaultRouting: null` (or
  `routing: null` on the conversation) simply stores incoming messages and
  does nothing else.
</Note>

<Note>
  `sandbox` is a system-managed mode for shared Wassist test numbers (numbers
  with no WhatsApp Business Account). It lets you chat with yourself for
  testing. You cannot set `sandbox` from the API or dashboard; any attempt to
  do so returns `400`. Sandbox numbers also ignore per-conversation routing
  overrides.
</Note>

## How effective routing is resolved

```mermaid theme={null}
flowchart LR
    Conv[Conversation.routing] -->|set| Effective[Effective routing]
    Conv -->|null| Num[WhatsappNumber.defaultRouting]
    Num --> Effective
    Sandbox{Number is sandbox?} -.->|yes| Forced[Effective = sandbox]
```

In code terms:

1. If the number is a sandbox number → `sandbox`, full stop.
2. Otherwise, if the conversation has its own non-null `routing`, use it.
3. Otherwise, fall back to the number's `defaultRouting` (which itself may
   be `null` — meaning "no routing").

The same precedence applies to the webhook itself:
`conversation.webhookOverride` wins over `whatsappNumber.defaultWebhook`.

## Subscription lifecycle events

Whenever a conversation's effective `(routing, webhook)` pair transitions
to or away from a webhook, Wassist fires lifecycle events to the affected
webhook:

| Transition              | Event                                                        |
| ----------------------- | ------------------------------------------------------------ |
| not-webhook → webhook W | `subscription.activated` on W                                |
| webhook W → not-webhook | `subscription.revoked` on W                                  |
| webhook W1 → webhook W2 | `subscription.revoked` on W1, `subscription.activated` on W2 |
| same → same             | (none)                                                       |

The triggers are:

* [`POST /conversations/{id}/subscribe/`](/api-reference/conversations/subscribe),
  [`/unsubscribe/`](/api-reference/conversations/unsubscribe),
  [`/routing/`](/api-reference/conversations/routing)

<Note>
  Phone-number-level routing changes (the
  [`/phone-numbers/{n}/subscribe`](/api-reference/phone-numbers/subscribe),
  [`/connect-agent`](/api-reference/phone-numbers/connect-agent), and
  [`/unsubscribe`](/api-reference/phone-numbers/unsubscribe) endpoints) do
  **not** fire subscription lifecycle events, even when `applyToExisting` is
  true. Number-wide changes are bulk admin actions — if your service needs
  to know about every conversation, subscribe to it individually.
</Note>

## Inbound message dispatch

Once a conversation is in `webhook` routing:

* `subscription.message.received` is dispatched **only** to the assigned
  webhook (no fan-out).
* The agent pipeline is skipped entirely.
* The legacy `message.received` event still fan-outs to every active
  webhook subscribed to it, so existing integrations keep working.

## Service window lifecycle

WhatsApp's 24-hour customer service window is tracked per conversation in
`webhook` mode:

* `subscription.service_window.expiring` fires roughly 1 hour before the
  window closes for a given conversation, once per window.
* `subscription.service_window.closed` fires when the window has closed.
* The tracker resets the moment a new inbound user message lands.

## Event payloads

All `subscription.*` events use the same envelope as
[`message.received`](/concepts/webhooks#message-received), with two extra
fields:

```json theme={null}
{
  "event": "subscription.message.received",
  "timestamp": "2026-06-23T16:48:00.000Z",
  "phoneNumber": "+447700900100",
  "from": "+447700900200",
  "contact": { "name": "Alex", "phoneNumber": "+447700900200" },
  "message": { /* same shape as message.received */ },
  "conversationId": "5a3f...e2",
  "routing": "webhook",
  "webhookId": "9c1c...a0"
}
```

For lifecycle events (`subscription.activated`, `.revoked`, the service
window pair) the `message` field is `null` — only conversation context is
sent.

## Setting routing from your code

```ts theme={null}
import { WassistClient } from "@wassist/sdk";

const client = new WassistClient({ apiKey: process.env.WASSIST_API_KEY! });

// Subscribe a single conversation to a webhook
await client.conversations.subscribe(conversationId, { webhookId });

// Clear the override (falls back to the number's defaultRouting)
await client.conversations.unsubscribe(conversationId);

// Or use the general-purpose endpoint
await client.conversations.setRouting(conversationId, { mode: "agent" });

// Pass null (or "inherit") to clear the override
await client.conversations.setRouting(conversationId, { mode: null });
```

To change the number-wide default, do it from the dashboard or via the
three dedicated phone-number endpoints. Each one atomically sets the
routing mode and clears the unrelated FK (agent vs. webhook), so the
number can't end up in an inconsistent state:

```ts theme={null}
// Route the number to a webhook (drops any connected agent)
await client.phoneNumbers.subscribe("447700900100", {
  webhookId,
  applyToExisting: true,
});

// Connect an agent (drops any subscribed webhook)
await client.phoneNumbers.connectAgent("447700900100", {
  agentId,
  applyToExisting: true,
});

// Disable routing entirely (drops both)
await client.phoneNumbers.unsubscribe("447700900100", {
  applyToExisting: true,
});
```

`applyToExisting: true` materialises the change onto every existing
conversation on this number — `activeAgent` is overwritten with the new
default (or cleared, for `subscribe` / `unsubscribe`) and any in-flight
session is dropped. No subscription lifecycle events are dispatched.

<Note>
  **Webhooks must be created in the [dashboard](https://wassist.app/developers/webhooks).**
  There is no API for webhook creation — the routing endpoints only let you
  point conversations at webhooks that already exist.
</Note>

## Validation rules

* `mode='sandbox'` is rejected on every public endpoint.
* Any routing change on a sandbox number is rejected (the number is shared
  and system-managed).
* `webhookId` must reference a webhook owned by the requesting user.
* `webhookId` is required when (and only when) `mode === 'webhook'`.
