# Open a Chrome DevTools Protocol WebSocket to a cloud browser (coming soon)

> A WebSocket upgrade, not a JSON endpoint. Connect with `puppeteer.connect({browserWSEndpoint})` or `chromium.connectOverCDP(url)`.

Source: https://docs.spicrawl.com/api-reference/browser/browser-connect

## GET /v1/browser

Operation ID: `browserConnect`. API key scope: `browser`.

Coming soon.
A WebSocket upgrade, not a JSON endpoint. Connect with `puppeteer.connect({browserWSEndpoint})` or
`chromium.connectOverCDP(url)`. Authenticate with, in order of preference: `Authorization: Bearer <key>`
on the upgrade; a single-use `token` from `POST /v1/browser/token` (60 s, one connection, bound to its
options — the URL the CLI and MCP return); or the `apikey` query parameter (`api_key` alias), kept for
ZenRows-style URLs. When `token` is present it is the only credential consulted. Requires the
`browser` scope.

Every refusal happens before the upgrade and is an RFC 7807 problem. After the 101 there is no HTTP
response left; the session ends by closing the TCP connection, never with a close frame or error
message.

Why a session ends (recorded server-side, not sent to you):
`client_closed` (you disconnected, or stopped answering WebSocket pings: the API pings every 15 s and
closes after 2 unanswered, so a half-open client frees its slot within about 30 s),
`ttl_expired` (`session_ttl` elapsed; a hard wall, not idle),
`idle_timeout` (no CDP messages in either direction for the deployment idle timeout, 2 minutes by
default, measured from the last message; pings and pongs do not count),
`byte_limit` (the deployment's per-session traffic cap was reached, if configured),
`upstream_closed` (the browser went away).

Pricing: a session costs 8 credits per started minute, the same as one real-Chromium page. At
admission the session HOLDS the price of its whole `session_ttl` against the monthly credit
allowance (default `session_ttl` 180 s = 24 credits; 60–900 s range = 8–120 credits). At close it
is charged for the minutes it actually held (rounded up, capped at `session_ttl`'s minutes) and
the unused part of the hold is returned immediately. A session the platform ends (upstream closed,
or API shutdown) costs 0; a session that never opens costs 0 and returns its hold.
While the operator has switched billing off, sessions hold and cost 0.

Admission, before the upgrade, in this order:
- Rate limit on session opens per key: 429 `ERR::LIMIT::RATE_LIMITED`, with `X-RateLimit-*` and `Retry-After`.
- Monthly credit allowance cannot cover the whole `session_ttl` hold: 402 `ERR::LIMIT::QUOTA_EXCEEDED`,
  refused before any browser is leased. A shorter `session_ttl` may fit.
- Simultaneous sessions per organization (`browser_concurrency`, default 2): 429
  `ERR::LIMIT::CONCURRENCY_EXCEEDED`, `Retry-After: 5`, `Concurrency-Limit`/`Concurrency-Remaining: 0`.
  The slot is held until the socket closes, so always close your browser.
- If the limiter store is unreachable the session is refused (fail closed): 503
  `ERR::INTERNAL::UNAVAILABLE`, `Retry-After: 10`.
- No CDP backend on this deployment: 501 `ERR::ENGINE::UNAVAILABLE`, `retryable: false`. Do not retry.

Rate and concurrency headers appear only on refusals; the 101 is the browser backend's handshake
forwarded verbatim.

### Example

```bash
curl "https://api.spicrawl.com/v1/browser" \
  -H "Authorization: Bearer $SPICRAWL_API_KEY"
```

### Parameters

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `token` | query | string | no | A single-use browser token from `POST /v1/browser/token`. Consumed on arrival, whatever happens next; expires 60 s after issue; revoked with the key that minted it. The session options are the ones bound into the token; an option in the URL that differs from (or was not part of) the binding is refused with 400 `ERR::REQUEST::INVALID_PARAMETER`. A used, expired or unknown token is 401 `ERR::AUTH::INVALID_KEY`. |
| `apikey` | query | string | no | The API key, for WebSocket clients that cannot set headers. `api_key` is accepted as an alias. Ignored when an `Authorization` Bearer header is present. Keep the URL out of logs you do not control. |
| `api_key` | query | string | no | Alias for `apikey`, consulted only when `apikey` is absent or blank. |
| `engine` | query | string: `chromium`, `obscura`; default `"chromium"` | no | Which browser serves the session. Case-insensitive. `fetch` and `camoufox` are refused with 400 because neither speaks CDP. Omitted means the deployment default, `chromium`. |
| `proxy_region` | query | string; default `"global"` | no | Broad exit region such as `eu`. Case-insensitive. `global` means no constraint and is the same as omitting it. Cannot be combined with `proxy_country`. |
| `proxy_country` | query | string | no | Exit country as a two-letter ISO 3166-1 alpha-2 code (case-insensitive, normalised to lowercase). Combining it with a non-`global` `proxy_region` is refused with 400 `ERR::REQUEST::INCOMPATIBLE_FLAGS`. |
| `session_ttl` | query | integer; default `180` | no | Hard session lifetime in whole seconds. Out-of-range values are refused, not clamped. When it elapses the socket is closed even mid-command. |
| `headless` | query | string: `true`, `false`, `1`, `0` | no | `true`/`1` or `false`/`0` (case-insensitive); anything else is 400. Omitted defers to the deployment. `false` attaches a display. `headless=false` with `engine=obscura` is refused with 400 `ERR::REQUEST::INCOMPATIBLE_FLAGS`. |
| `sticky_key` | query | string | no | Pins the exit for the session. 2-64 letters, digits and hyphens; no underscores. Omitted means the exit rotates, the opposite of `/v1/sessions`. |
| `Upgrade` | header | string | yes | Must be `websocket` (case-insensitive). A plain HTTP GET is refused with 400 `ERR::REQUEST::INVALID`. |
| `Connection` | header | string | yes | Must contain the token `Upgrade`. |
| `Sec-WebSocket-Key` | header | string | yes | Forwarded to the browser backend unchanged; the returned `Sec-WebSocket-Accept` is computed from it. Your WebSocket library sets it. |
| `Sec-WebSocket-Version` | header | string | yes | Forwarded unchanged. Normally `13`. |

### Responses

#### 101

Switching Protocols. The socket now carries raw CDP JSON messages in both directions, spliced to the browser without inspection.

#### 400

The request is invalid. Not retryable; fix the request using `code` and `diagnostics.hint`.

Headers: `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 401

Missing, invalid, revoked or expired key. Not retryable with the same key.

Headers: `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 402

`ERR::LIMIT::QUOTA_EXCEEDED`: the organization is out of credits. Not retryable until credits are added.

Headers: `X-Request-Id`, `X-Credits-Remaining`.

`application/problem+json` (`Problem` schema).

#### 403

The key lacks the scope this route requires (`ERR::AUTH::INSUFFICIENT_SCOPE`), or the action is not permitted.

Headers: `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 429

Rate, concurrency or live-session limit reached. Retryable after `Retry-After`.

Headers: `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 500

Internal error. Retryable when `retryable` is true.

Headers: `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 501

The capability is not deployed on this host. Not retryable.

Headers: `X-Request-Id`.

`application/problem+json` (`Problem` schema).

#### 502

The browser backend could not be reached or refused the session (`ERR::UPSTREAM::ERROR`), or the
worker reported another upstream-class code. Not caused by your request; retry with backoff and
quote the `request_id` if it persists.

`application/problem+json` (`Problem` schema).

#### 503

No proxy exit or engine capacity was available. Retryable after `Retry-After`; 0 credits.

Headers: `Retry-After`, `X-Request-Id`.

`application/problem+json` (`Problem` schema).

Full OpenAPI spec: https://docs.spicrawl.com/openapi.yaml
