Drive a cloud browser over CDP, and hand it to a person
Connect any DevTools Protocol (CDP) client to a real browser hosted by Spicrawl with GET /v1/browser, and open a live view so a person can watch it and take control for a login, a one-time code or a captcha.
GET /v1/browser is a WebSocket that speaks the DevTools Protocol (CDP). Point any CDP client at it and you drive a real browser hosted by Spicrawl: open tabs, click, type, run scripts, intercept requests. When a page needs a person, such as a login, a one-time code or a captcha, ask for a live view link. They open it, take control, finish the step, and give control back. Your connection carries on in the same tab with the same cookies.
It needs a key with the browser scope, which new keys do not get by default; grant it deliberately. A session costs 8 credits per started minute and lasts session_ttl seconds (60-900, default 180, at most 15 minutes) or until you disconnect. For scripted page interaction inside one request, use browser actions.
Connect
Mint a single-use connect URL, then open it with your CDP client as its browser WebSocket endpoint:
curl -X POST https://api.spicrawl.com/v1/browser/token \
-H "Authorization: Bearer $SPICRAWL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"proxy_country": "de", "session_ttl": 300}'{
"url": "wss://api.spicrawl.com/v1/browser?proxy_country=de&session_ttl=300&token=wbt_...",
"path": "/v1/browser?proxy_country=de&session_ttl=300&token=wbt_...",
"expires_at": "2026-11-10T10:31:00Z",
"expires_in": 60,
"single_use": true,
"session_id": "bs4f0c2a9e1d7b38c65a0e9f12"
}The token (wbt_...) works for one connection, within 60 s, and only with the options it was minted with: editing them in the URL is 400 ERR::REQUEST::INVALID_PARAMETER. Mint a new one for every session. Keep session_id: it is how you ask for the live view later.
This Node.js 22+ script needs no library. It opens example.com and reads its title:
const mint = await fetch('https://api.spicrawl.com/v1/browser/token', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SPICRAWL_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ session_ttl: 300 }),
}).then((r) => r.json());
const ws = new WebSocket(mint.url);
let id = 0;
const send = (method, params = {}, sessionId) => new Promise((resolve) => {
const me = ++id;
ws.addEventListener('message', function on(ev) {
const msg = JSON.parse(ev.data);
if (msg.id === me) { ws.removeEventListener('message', on); resolve(msg); }
});
ws.send(JSON.stringify({ id: me, method, params, ...(sessionId && { sessionId }) }));
});
await new Promise((r) => ws.addEventListener('open', r));
const { result: { targetId } } = await send('Target.createTarget', { url: 'https://example.com' });
const { result: { sessionId } } = await send('Target.attachToTarget', { targetId, flatten: true });
await new Promise((r) => setTimeout(r, 2000));
const title = await send('Runtime.evaluate', { expression: 'document.title', returnByValue: true }, sessionId);
console.log(title.result.result.value, '| session', mint.session_id);
ws.close();Any CDP client library connects the same way: pass url as its browser WebSocket endpoint. If your client can set headers on the WebSocket upgrade, you can skip the token and connect to wss://api.spicrawl.com/v1/browser with Authorization: Bearer <key>. That session has no session_id you can address, so it has no live view.
Options
Send them in the token body (or as query parameters on a header-authenticated connect).
| Option | Values | Default |
|---|---|---|
engine | browser (full-browser rendering, the whole protocol) or js (lighter JavaScript rendering, a smaller protocol surface; a live view can return 409 on it) | browser |
session_ttl | 60-900 seconds. Out of range is 400, not clamped. | 180 |
proxy_country | ISO 3166-1 alpha-2, lowercase (de) | any Refused with 400 ERR::REQUEST::INVALID_PARAMETER when Spicrawl's pool has no exit in that country (checked at mint and connect); omit it to exit anywhere. |
proxy_region | only global (no constraint) is accepted for now; any other value is 400 | global |
sticky_key | 2-64 letters, digits or hyphens: pins the exit for the session. Known limit: on our pool it does not yet keep the same exit across sessions, so a later session with the same key may get another IP | exit rotates |
proxy | your own http://user:pass@host:port; never echoed back in url | our pool |
Live view and human takeover
While your connection is open, ask for a live view link and send it to a person:
curl -X POST https://api.spicrawl.com/v1/browser/sessions/bs4f0c2a9e1d7b38c65a0e9f12/live \
-H "Authorization: Bearer $SPICRAWL_API_KEY"{
"session_id": "bs4f0c2a9e1d7b38c65a0e9f12",
"live_view_url": "https://api.spicrawl.com/v1/browser/live#wss%3A%2F%2Fapi.spicrawl.com%2Fv1%2Fbrowser%2Fsessions%2Fbs4f0c2a9e1d7b38c65a0e9f12%2Flive%3Ftoken%3Dwbv_...",
"live_url": "wss://api.spicrawl.com/v1/browser/sessions/bs4f0c2a9e1d7b38c65a0e9f12/live?token=wbv_...",
"expires_in": 60,
"single_use": true
}The person opens live_view_url
They see the page your connection is on, streamed live, and follow it to any new tab you open. The link works once and must be opened within 60 s; ask again for a new one.
They press "Take control"
Their mouse, keyboard and scrolling now drive the browser. Your connection's input (Input.*), navigation (Page.navigate, Page.reload, Page.navigateToHistoryEntry) and tab changes (Target.createTarget, Target.closeTarget, Target.activateTarget) are answered with CDP error -32001 instead of reaching the browser. Reading the page (Runtime.evaluate, DOM.*) keeps working, so a script that polls does not crash.
They press "Give control back to the agent"
Poll until controller is agent, then carry on: same tab, same cookies, now logged in.
curl https://api.spicrawl.com/v1/browser/sessions/bs4f0c2a9e1d7b38c65a0e9f12 \
-H "Authorization: Bearer $SPICRAWL_API_KEY"
# {"session_id":"bs4f0c2a9e1d7b38c65a0e9f12","status":"running","controller":"agent","expires_at":"2026-11-10T10:35:00Z"}You can also flip control yourself: POST /v1/browser/sessions/{id}/takeover hands it to the person, and POST /v1/browser/sessions/{id}/release gives it back to your connection. Both answer like the GET.
- Not billed separately. Watching rides the session's per-minute price. The live view ends when the session does.
- Who can see it. The routes need your key and the
browserscope. Another organization's session, an unknown id and a closed session are all404 ERR::REQUEST::NOT_FOUND. The viewer token (wbv_and 64 hex characters) is stored only as a hash. The page keeps it in the URL fragment, which a browser never sends to a server, and removes it from the address bar on load. - What the viewer can do. Stream the page and, while holding control, send input. It cannot run scripts, read cookies or navigate by URL.
- From an AI agent. The MCP tools
spicrawl_browser_live_viewandspicrawl_browser_controlwrap these routes.
What is refused
A few commands would let a session leave its assigned exit or act on the browser as a whole. They are answered with a CDP error and never reach the browser: a per-context proxy (Target.createBrowserContext with proxyServer), Browser.close and Browser.crash, SystemInfo.*, Target.exposeDevToolsProtocol, Target.sendMessageToTarget (use flat sessions, sessionId), and navigation to any scheme other than http, https, data and about:blank. Downloads are always denied. Every request the browser makes goes through Spicrawl's proxy with the same private-network guard as /v1/scrape.
Limits and errors
| What | Value |
|---|---|
| Session length | session_ttl, at most 900 s |
| Idle timeout | 120 s with no CDP traffic either way |
| Concurrent sessions | 2 per organization by default |
| Traffic per session | 2 GiB, both directions together |
Errors before the WebSocket upgrade are ordinary problem responses; after it, a session ends by closing the connection.
| Code | HTTP | Do next |
|---|---|---|
ERR::AUTH::INVALID_KEY | 401 | The token was used, expired (60 s) or is unknown. Mint a new one. |
ERR::AUTH::INSUFFICIENT_SCOPE | 403 | Grant the key the browser scope in the dashboard. |
ERR::REQUEST::INVALID_PARAMETER | 400 | An option is out of range, or differs from the one the token was minted with. |
ERR::LIMIT::CONCURRENCY_EXCEEDED | 429 | Your organization already has its maximum of sessions open. Close one, or wait Retry-After seconds; the same token still works. |
ERR::LIMIT::RATE_LIMITED | 429 | Sessions are being opened too fast. Wait Retry-After seconds; the same token still works. |
ERR::LIMIT::QUOTA_EXCEEDED | 402 | Not enough credits for the whole session_ttl. |
ERR::ENGINE::UNAVAILABLE | 501 | This deployment has no cloud browsers. Do not retry. |
ERR::REQUEST::NOT_FOUND | 404 | (live view) The session is closed or not yours. |
ERR::REQUEST::CONFLICT | 409 | (live view socket) This session's browser cannot take a viewer, for example with engine: "js". |
Related
- Browser actions: scripted clicks and typing inside one
/v1/scraperequest, no connection to hold. - Sessions and logins: keep cookies across scrapes.
- MCP server: the same browser from an AI agent.
Batch jobs
Submit up to 10,000 URLs per call to POST /v1/batch, poll the job until it is terminal, and page the JSONL results before they expire after 72 hours.
Caching
How the /v1/scrape result cache works: on by default with a 48-hour window, a cache hit is billed at the price of the fetch that stored it, and cache=false or cache_ttl=0 forces a fresh fetch.