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

# Co-browse session model: consent postures and the privacy-masking contract

> How a co-browse session's consent ceiling, capture-time redaction policy, and server-side control guard fit together — and why they share one masking posture with session replay. Read this before you wire the widget, join an agent, or relax masking.

# Co-browse session model

Co-browse lets a support agent join a visitor's live browser page while a conversation is in flight. The consent model and the privacy masking contract that governs it are the two halves of one idea: the visitor decides how much an agent may do, and a tenant-owned policy decides what the page may reveal. This page explains how those two halves interact, and why they are exactly the same policy contract that [session replay](/guides/session-replay) uses. The endpoint-by-endpoint walkthrough lives in [Co-browse: live screen assistance](/guides/cobrowse-live-assist); the request/response shapes are in the [Co-browse API reference](/api-reference/cobrowse).

## Co-browse vs session replay

Both surfaces capture the visitor's DOM — but they answer different questions, at different times:

|           | Co-browse                                                           | Session replay                                             |
| --------- | ------------------------------------------------------------------- | ---------------------------------------------------------- |
| When      | Live — an agent joins while the page is open                        | After the fact — the session is recorded, then played back |
| Transport | A scoped data channel; only the DOM is mirrored, not a pixel stream | Batched DOM snapshots uploaded for storage and playback    |
| Consent   | Set at session start by the visitor, revocable at any time          | Runs under the tenant's widget capture configuration       |

What never differs is the masking posture. Both surfaces apply the same capture-side redaction contract — masking decided in the visitor's browser *before* any packet is published, so the API never receives the raw, unredacted DOM. A tenant that relaxes masking in one place does it once, and both surfaces respect it.

## Consent postures

A session opens with one of two postures, chosen by the visitor when they start co-browsing from the widget:

* **Observe** (`control: false`) — the agent watches the live page. The visitor's browser publishes DOM; nothing the agent types or clicks reaches the page. `control` defaults to `false` when the start call omits it.
* **Guided control** (`control: true`) — in addition to observing, the agent may publish `highlight`, `scroll`, and `form_fill` packets that the visitor's browser renders back into the page.

Three rules hold the consent ceiling:

1. **The ceiling is set at session start, by the visitor.** An agent's join request asks for control (`POST /api/v1/cobrowse/{conversationId}/join` with `control: true`), but the effective grant is the AND of the agent's request and the visitor's stored consent. An observe-only session can never be escalated to guided control — the grant is enforced when the agent's token is minted, not just in the dashboard UI, and the data-channel grant for an agent only permits publishing control packets when the visitor consented.
2. **The visitor can re-confirm or revoke at any time.** The widget re-POSTs the start call with a new `control` value, and the stored ceiling moves with it.
3. **Sessions never revive.** Once a session ends (`ended` by the visitor, the agent, or system cleanup), it cannot be joined again — the visitor re-initiates from the widget so consent is re-confirmed before any agent attaches.

## Capture-side redaction

The masking decision happens **in the visitor's browser, before a DOM packet is published** — the server never touches the raw page. The policy that decides it is the one you set on `PUT /api/v1/cobrowse/privacy`, persisted on your widget configuration's feature flags as `cobrowse_privacy` (a JSONB blob, so updating it is a settings write, not a schema change).

Two properties drive the whole posture:

* **Default-deny.** With nothing configured — or a stored value that drifted — every input value is masked and the entire block-by-type list (`tel`, `email`, `cc`) is blocked. A tenant that never opens the settings panel still streams a fully redacted page; an operator must explicitly relax masking.
* **A hard floor that survives any tenant setting.** `input[type="password"]` and a small set of built-in payment/card selectors (for example `input[autocomplete="cc-number"]` and `input[autocomplete="cc-csc"]`) are always blocked. Tenant-added entries in `block_selectors` and `blocked_input_types` only ever add restrictions — they can never remove the hard floor.

The policy fields you can set:

| Field                     | Meaning                                                                                                                                                 |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mask_all_inputs`         | Mask every `<input>`, `<textarea>`, and `<select>` value before publishing. Default `true`.                                                             |
| `allowlist_selectors`     | When `mask_all_inputs` is `false`, only these selectors publish unmasked; everything else stays masked. Ignored while `mask_all_inputs` is `true`.      |
| `block_selectors`         | Selectors whose subtree is never published and can never be targeted by a control packet. Merged with the always-blocked entries, never replacing them. |
| `blocked_input_types`     | Block-by-type list from `tel`, `email`, `cc`. `password` is always blocked and is never a member of this list.                                          |
| `unmask_on_visitor_click` | Lets the visitor click a masked field on their own screen to reveal it *locally*. Never changes what is published to the agent.                         |

Because masking applies to the page's DOM tree — not a pixel stream of the whole screen — content the page itself never exposes is never transmitted. Choosing what to block, and documenting that decision with your DPO, is the tenant-owned control that keeps co-browse aligned with your compliance posture.

## The server-side control guard

The same policy doubles as a server-side gate on the control endpoint. Before a `highlight`, `scroll`, or `form_fill` packet is broadcast into the room, its target `selector` and `input_type` are checked against the effective policy:

* A packet targeting a selector inside any blocked region — a tenant entry or the always-blocked hard floor — is rejected with `403 COBROWSE_CONTROL_BLOCKED` and never reaches the visitor's browser.
* A `form_fill` packet on an observe-only session is rejected with `403 COBROWSE_CONTROL_NOT_GRANTED`.
* A packet that passes the guard is broadcast; accepted `form_fill` packets are audit-logged, and blocked attempts are audit-logged too.

An empty or missing selector fails closed — treated as blocked rather than assumed safe. The guard is what makes the tenant's `block_selectors` more than a capture filter: it is the same list that keeps an agent's remote pointer out of the regions the tenant chose to protect.

## Session lifecycle

A session is stamped on the conversation it belongs to and moves through:

1. **Visitor starts** — the widget calls its start endpoint; the session is `requested` with the consent ceiling stored on it.
2. **Agent joins** — `POST /api/v1/cobrowse/{conversationId}/join` mints a data-channel token bound to the organization, conversation, and session, flips the session to `active`, and stamps the agent and start time.
3. **Control packets** — while the visitor granted control and the agent requested it, `POST .../control` packets flow through the guard above.
4. **End** — either side ends; `status` becomes `ended` with `ended_by` stamped as `visitor`, `agent`, or `system`. An ended session is a terminal state — re-initiation re-confirms consent.

Both sides receive the same policy snapshot: the visitor's session bootstrap and the agent's join response each carry the effective privacy policy, so the capture client and the operator see an identical redaction contract. A policy update takes effect on the next session bootstrap — it does not retroactively change a session already in progress.

## Parity with session replay

Session replay ships the same default-deny, capture-time masking contract for recorded sessions. Co-browse ports that exact contract to the live DOM stream, so the two capture surfaces carry an identical redaction model rather than two diverging ones. Co-browse adds two things replay does not need: a control-channel block list (because co-browse has a live agent→visitor control surface) and the `unmask_on_visitor_click` affordance (a live-session UX toggle that never widens what is published). If you maintain one masking policy deliberately, both surfaces stay consistent with it.

## Worked example: relax masking on one selector, verify on both sides

Your checkout page shows a shipping-preference widget the agent needs to read aloud, masked today by the default `mask_all_inputs`. Relax masking for that one selector only:

```bash theme={null}
curl -X PUT https://api.orbit.devotel.io/api/v1/cobrowse/privacy \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "mask_all_inputs": false,
    "allowlist_selectors": ["#shipping-preferences"]
  }'
```

Then verify both sides see the same policy:

1. Start a fresh session from the widget (a session already running keeps its bootstrap-time snapshot — end it and re-start).
2. As the agent, read the policy back with `GET /api/v1/cobrowse/privacy` and join with `POST /api/v1/cobrowse/{conversationId}/join`; the response echoes the effective policy under `data.privacy`.
3. On the visitor's page, the `#shipping-preferences` subtree now publishes unmasked to the agent view; every other input stays masked, because `mask_all_inputs: false` still masks anything the allowlist does not cover.

Because the hard floor is independent of your configuration, a selector like `#shipping-preferences input[autocomplete="cc-number"]` remains blocked even though its ancestor is allowlisted — relaxing masking never relaxes the floor.

## See also

* [Co-browse: live screen assistance](/guides/cobrowse-live-assist) — the step-by-step walkthrough from widget install to first session
* [Session replay](/guides/session-replay) — the asynchronous counterpart that shares the masking contract
* [Co-browse API reference](/api-reference/cobrowse) — endpoint shapes, the session object, and the error ladder
* [Troubleshooting: co-browse session stuck](/troubleshooting/cobrowse-stuck) — the failure ladder for each stuck-session class
