# API overview
Source: https://docs.surfsky.io/api-reference
Authenticate REST requests, read response formats, and find session, profile, and browser protocol endpoints.
Use the REST API to start and stop browsers, manage saved profiles, and configure proxies and extensions. A start request returns the WebSocket URL used by your automation framework.
For a first working script, follow the [quickstart](/quickstart). The endpoint pages in this tab document request fields and responses.
## Base URL
Use the API base URL assigned to your account in the [Surfsky dashboard](https://app.surfsky.io). `https://YOUR_API_HOST` is a placeholder for that URL. Calling a host that does not serve your account returns `403 namespace_not_allowed`.
The shell examples use `SURFSKY_API_BASE_URL` and `SURFSKY_API_TOKEN`; set them as shown in [credentials](/quickstart#credentials). In the API playground, enter your full API base URL in `baseUrl`.
## Authentication
Send the API token with REST management requests:
```bash theme={null}
curl "$SURFSKY_API_BASE_URL/profiles/active" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Use `Content-Type: application/json` for JSON request bodies. Extension uploads use multipart form data.
Keep tokens in server-side environment variables or your secret store. Live CDP, DevTools, ChromeDriver, and screencast URLs grant access to the browser through the session identifier; keep those URLs private too.
## Response shape
Read the schema for the endpoint you are calling. The API has several success formats:
| Operation | Payload location |
| - | - |
| Start a one-time or persistent browser | `internal_uuid`, `ws_url`, and `inspector` at the top level. |
| Create a persistent profile | The new UUID in `data.uuid`; HTTP `201`. |
| List persistent profiles | A JSON array at the top level. |
| List active sessions | An array in `data`. |
| Scrape one page | Page result in `data`. A batch returns an array in `data`. |
| Many management and lookup calls | A `success` flag and `data`, sometimes with `msg`. |
For example, a start response looks like this:
```json theme={null}
{
"internal_uuid": "a8fb62f90611456aa75422b01c385a62",
"ws_url": "wss://YOUR_API_HOST/proxy/a8fb62f90611456aa75422b01c385a62",
"inspector": {
"list": "https://YOUR_API_HOST/proxy/a8fb62f90611456aa75422b01c385a62/inspector",
"pages": [],
"screencast": "wss://YOUR_API_HOST/screencast/a8fb62f90611456aa75422b01c385a62/devtools/page/PAGE_ID"
},
"success": true
}
```
Use the IDs and URLs returned by your request in place of these examples. Errors commonly include `success: false`, `msg`, `data`, and `code`, but forwarded errors can differ. See [errors](/errors).
## Starting a browser
A one-time session starts without a saved profile. Its profile state is discarded when it stops. A persistent profile saves the storage types you enable and can be started again.
```bash theme={null}
curl -X POST "$SURFSKY_API_BASE_URL/profiles/one_time" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
The empty body uses a proxy pool available to your account. Supply your own connection if no pool is available.
`profile_uuid` identifies a saved profile. Use the returned `internal_uuid` for stop, scrape, and live browser connections. These values may be equal; do not assume a new or different internal ID on every start. See [session lifecycle](/sessions).
### Start one-time session
[POST /profiles/one\_time](/api-reference/profiles/start-one-time-session) starts a browser and returns its connection details. Configure fingerprints, proxies, [Human actions](/human_emulation), and CAPTCHA solving for your task.
### Stop session
[POST /profiles/\{internal\_uuid}/stop](/api-reference/profiles/stop-session) stops a running browser and frees its slot. Put this call in cleanup code so it also runs if navigation or the framework connection fails.
### Active sessions
[GET /profiles/active](/api-reference/profiles/list-active-sessions) lists one-time and persistent browsers currently running. Use `one_time` to distinguish them.
[POST /profiles/stop](/api-reference/profiles/stop-all-sessions) stops all browsers on the account. Check its `failed` list; use individual stop calls when other workers share the account.
## Persistent profiles
| Task | Endpoint |
| - | - |
| Create | [POST /profiles](/api-reference/profiles/create-profile) |
| List | [GET /profiles](/api-reference/profiles/list-profiles) |
| Read settings | [GET /profiles/\{profile\_uuid}](/api-reference/profiles/get-profile) |
| Update | [PATCH /profiles/\{profile\_uuid}](/api-reference/profiles/update-profile) |
| Start | [POST /profiles/\{profile\_uuid}/start](/api-reference/profiles/start-profile) |
| Delete one | [DELETE /profiles/\{profile\_uuid}](/api-reference/profiles/delete-profile) |
| Delete several | [DELETE /profiles](/api-reference/profiles/delete-profiles) |
Create with a title and a fingerprint containing `os`. Stop a profile before editing or deleting it. Starting an already running profile can return the existing session.
### Cookies
Use [export cookies](/api-reference/profiles/export-cookies) and [import cookies](/api-reference/profiles/import-cookies) on a stopped persistent profile. See the [cookie guide](/cookies) for JSON and Netscape formats and login-state requirements.
## Scraping
[POST /profiles/\{internal\_uuid}/scrape](/api-reference/profiles/scrape-page) returns rendered HTML, cookies, and an optional screenshot from a running browser. Read [Scraping API](/quickstart/scraping_api) for a full lifecycle example and batch behavior.
## Proxy reference
Use the [proxy guide](/proxies) to choose a tier, target a location, provide a VPN, block domains, or route domains through different connections.
Location lookup starts with [countries](/api-reference/proxies/list-premium-countries) or [geographic pools](/api-reference/proxies/list-proxy-pools). Shared proxies have their own [country list](/api-reference/proxies/list-shared-countries).
`GET /proxies/quota` is a legacy alias for [premium quota](/api-reference/proxies/get-premium-quota), not a combined quota for both tiers. Check [shared quota](/api-reference/proxies/get-shared-quota) separately. [Premium](/api-reference/proxies/get-premium-traffic-stats) and [shared](/api-reference/proxies/get-shared-traffic-stats) traffic statistics report time windows.
## Fingerprint reference
Look up [renderers](/api-reference/fingerprints/list-renderers), [screens](/api-reference/fingerprints/list-screens), and [device models](/api-reference/fingerprints/list-device-models). Use the generated values unless your workload needs an override; see [fingerprints](/fingerprints).
## Extensions
[Upload a ZIP](/api-reference/extensions/upload-extension) and use the returned UUID when starting a browser. The [extension guide](/extensions) covers packaging, loading, updating metadata, and deletion.
## CAPTCHA solving
Enable `anti_captcha` on the start request, then use the [CAPTCHA CDP commands](/captcha-solving). [GET /captcha/balance](/api-reference/captcha/get-solver-balance) reports configured solver balances.
## Endpoints that are not generated
The endpoints below forward live browser protocol traffic or expose account information outside the generated reference.
### CDP over WebSocket
```text theme={null}
wss://YOUR_API_HOST/proxy/{internal_uuid}
wss://YOUR_API_HOST/proxy/{internal_uuid}/{path}
```
Connect using the returned `ws_url`. See the [framework guides](/quickstart#choose-an-integration) for the exact connection and cleanup steps.
### DevTools HTTP
```text theme={null}
GET https://YOUR_API_HOST/proxy/{internal_uuid}/inspector
GET https://YOUR_API_HOST/proxy/{internal_uuid}/{path}
PUT https://YOUR_API_HOST/proxy/{internal_uuid}/{path}
```
The proxy exposes discovery endpoints such as `/json/list` and `/json/version`. See [debugging](/debugging) to open a page's inspector.
### ChromeDriver
```text theme={null}
GET|POST|PUT|DELETE https://YOUR_API_HOST/chromedriver/{internal_uuid}/{path}
```
Start with `enable_chromedriver: true`, then use this URL as Selenium's remote server. See the [Selenium quickstart](/quickstart/selenium).
### Screencast
```text theme={null}
wss://YOUR_API_HOST/screencast/{internal_uuid}/devtools/page/{page_id}
```
Use `inspector.screencast` from the start response to receive live JPEG frames. See [Screencast](/screencast) for a viewer example.
### Account limits
| Endpoint | Reports |
| - | - |
| `GET /users/plan` | Account plan and allowances. |
| `GET /users/browser-limits` | Running browsers and capacity; `available` is present for capped accounts. |
See [limits](/limits) for response details and how each limit affects a job.
## Rate limits
Profile responses report minute and hour request limits in `X-Ratelimit-Limit`, `X-Ratelimit-Limit-Hour`, `X-Ratelimit-Remaining`, and `X-Ratelimit-Remaining-Hour`.
Exhausting either window returns `429 rate_limits_reached`. Other `429` codes represent browser or quota limits. Use the [error code](/errors#retrying) to decide what to do next.
# Get solver balance
Source: https://docs.surfsky.io/api-reference/captcha/get-solver-balance
/api-reference/openapi.json get /captcha/balance
Read balances for the configured CAPTCHA providers exposed by the account. An empty services list is possible. This endpoint does not validate every solver key; see [CAPTCHA configuration](/captcha-solving).
# Delete extension
Source: https://docs.surfsky.io/api-reference/extensions/delete-extension
/api-reference/openapi.json delete /extensions/{extension_uuid}
Delete an uploaded extension. A copy already loaded in a running browser remains until that browser stops.
# Get extension
Source: https://docs.surfsky.io/api-reference/extensions/get-extension
/api-reference/openapi.json get /extensions/{extension_uuid}
Read an extension's name and timestamps.
# List extensions
Source: https://docs.surfsky.io/api-reference/extensions/list-extensions
/api-reference/openapi.json get /extensions
List uploaded extensions. The response contains extensions and count at the top level.
# Update extension
Source: https://docs.surfsky.io/api-reference/extensions/update-extension
/api-reference/openapi.json patch /extensions/{extension_uuid}
Rename an extension with a JSON name field. This does not replace its archive; upload a new ZIP to update extension code.
# Upload extension
Source: https://docs.surfsky.io/api-reference/extensions/upload-extension
/api-reference/openapi.json post /extensions
Upload an extension ZIP using multipart file and name fields. The response returns uuid at the top level. Load it through extensions on a browser start request. Limits are 100 uploads per account and 5 extensions per browser.
# List device models
Source: https://docs.surfsky.io/api-reference/fingerprints/list-device-models
/api-reference/openapi.json get /fingerprint/device_models
Look up device models using the available OS, version, architecture, and device-type filters. Device models apply to Android fingerprints.
# List renderers
Source: https://docs.surfsky.io/api-reference/fingerprints/list-renderers
/api-reference/openapi.json get /fingerprint/renderers
Look up GPU renderer values for a fingerprint. Use a returned value in fingerprint.renderer.
# List screens
Source: https://docs.surfsky.io/api-reference/fingerprints/list-screens
/api-reference/openapi.json get /fingerprint/screens
Look up screen values, optionally filtered by OS and architecture. Use the returned screen string in fingerprint.screen.
# Create profile
Source: https://docs.surfsky.io/api-reference/profiles/create-profile
/api-reference/openapi.json post /profiles
Create a saved profile without starting a browser. A title and fingerprint with os are required. The HTTP 201 response contains data.uuid; use it to start the profile. See [persistent profiles](/sessions#persistent-profiles).
# Delete profile
Source: https://docs.surfsky.io/api-reference/profiles/delete-profile
/api-reference/openapi.json delete /profiles/{profile_uuid}
Delete a stopped saved profile and its stored state. A running profile must be stopped first.
# Delete profiles
Source: https://docs.surfsky.io/api-reference/profiles/delete-profiles
/api-reference/openapi.json delete /profiles
Delete several saved profiles. HTTP 400 can report partial completion: inspect deleted_uuids, active_uuids, and not_found_uuids before deciding what to retry. Deleted profile state cannot be recovered through this API.
# Export cookies
Source: https://docs.surfsky.io/api-reference/profiles/export-cookies
/api-reference/openapi.json get /profiles/{profile_uuid}/cookies
Export a stopped saved profile's cookies as JSON or Netscape text. Cookies must be enabled in its storage settings. The response contains data.cookies.
# Get profile
Source: https://docs.surfsky.io/api-reference/profiles/get-profile
/api-reference/openapi.json get /profiles/{profile_uuid}
Read a saved profile's settings from the data object.
# Import cookies
Source: https://docs.surfsky.io/api-reference/profiles/import-cookies
/api-reference/openapi.json post /profiles/{profile_uuid}/cookies
Import cookies into a stopped saved profile. Send JSON cookies serialized as a string, or Netscape text, in the cookies field. Browser storage other than cookies is not imported. See [cookies](/cookies).
# List active sessions
Source: https://docs.surfsky.io/api-reference/profiles/list-active-sessions
/api-reference/openapi.json get /profiles/active
List running browsers, including one-time and persistent sessions. Use one_time to distinguish them and internal_uuid for stop or scrape calls. Session age alone does not prove that a worker has abandoned it.
# List profiles
Source: https://docs.surfsky.io/api-reference/profiles/list-profiles
/api-reference/openapi.json get /profiles
List saved profiles. This endpoint returns a JSON array at the top level. One-time sessions are listed through active sessions instead.
# Scrape page
Source: https://docs.surfsky.io/api-reference/profiles/scrape-page
/api-reference/openapi.json post /profiles/{internal_uuid}/scrape
Navigate an existing browser and return rendered HTML, cookies, and an optional PNG screenshot. Send one request or a sequential batch. Check each batch item and the target website's status. Avoid concurrent CDP work in this browser: the scraper reuses a page and closes extra pages. The gateway waits up to 120 seconds. See [Scraping API](/quickstart/scraping_api).
# Start one-time session
Source: https://docs.surfsky.io/api-reference/profiles/start-one-time-session
/api-reference/openapi.json post /profiles/one_time
Start a browser without a saved profile. The response returns connection fields at the top level. Use the returned internal_uuid to stop the session. Supply a proxy or VPN, or use a pool available to your account. Shared HTTP cache can be enabled separately from profile storage. See [the session guide](/sessions).
# Start profile
Source: https://docs.surfsky.io/api-reference/profiles/start-profile
/api-reference/openapi.json post /profiles/{profile_uuid}/start
Start a saved profile and restore its enabled storage types. Omitted proxy settings retain the stored proxy; explicit overrides apply to this run. Starting an already running profile can return its existing session. Use the returned connection fields as-is.
# Stop all sessions
Source: https://docs.surfsky.io/api-reference/profiles/stop-all-sessions
/api-reference/openapi.json post /profiles/stop
Stop every browser on the account, including sessions used by other workers. Check the stopped and failed lists even when the top-level success flag is true.
# Stop session
Source: https://docs.surfsky.io/api-reference/profiles/stop-session
/api-reference/openapi.json post /profiles/{internal_uuid}/stop
Stop a running browser. Use internal_uuid from the start response; it may equal the saved profile UUID. A persistent session saves its enabled storage on a normal stop. The response may include an optional cost receipt.
# Update profile
Source: https://docs.surfsky.io/api-reference/profiles/update-profile
/api-reference/openapi.json patch /profiles/{profile_uuid}
Update a stopped saved profile. Omitted fields retain their values. The profile's OS identity fields cannot be changed through this request.
# Get premium quota
Source: https://docs.surfsky.io/api-reference/proxies/get-premium-quota
/api-reference/openapi.json get /proxies/premium/quota
Read the remaining premium traffic allowance. Plans without an applicable quota return premium_quota_not_applicable. GB values use decimal gigabytes.
# Get premium traffic stats
Source: https://docs.surfsky.io/api-reference/proxies/get-premium-traffic-stats
/api-reference/openapi.json get /proxies/premium/stats
Read premium traffic totals for the last 24 hours, 7 days, and 30 days. These are time windows, not per-session records.
# Get shared quota
Source: https://docs.surfsky.io/api-reference/proxies/get-shared-quota
/api-reference/openapi.json get /proxies/shared/quota
Read shared traffic usage, remaining allowance, and reset timestamp. A value of -1 in limit and remaining fields means unlimited.
# Get shared traffic stats
Source: https://docs.surfsky.io/api-reference/proxies/get-shared-traffic-stats
/api-reference/openapi.json get /proxies/shared/stats
Read shared traffic totals for the last 24 hours, 7 days, and 30 days.
# Get traffic quota
Source: https://docs.surfsky.io/api-reference/proxies/get-traffic-quota
/api-reference/openapi.json get /proxies/quota
Legacy alias for the premium quota endpoint. This does not combine premium and shared traffic. Read shared quota separately.
# List premium ASNs
Source: https://docs.surfsky.io/api-reference/proxies/list-premium-asns
/api-reference/openapi.json get /proxies/asns/{country}
List ASNs available for premium targeting in a country. Use a numeric ASN in proxy.asn and include proxy.country.
# List premium cities
Source: https://docs.surfsky.io/api-reference/proxies/list-premium-cities
/api-reference/openapi.json get /proxies/cities/{country}/{region}
List premium cities in a region. Set country and region together with proxy.city.
# List premium countries
Source: https://docs.surfsky.io/api-reference/proxies/list-premium-countries
/api-reference/openapi.json get /proxies/countries
List premium proxy country codes and names. Use the returned code in proxy.country.
# List premium regions
Source: https://docs.surfsky.io/api-reference/proxies/list-premium-regions
/api-reference/openapi.json get /proxies/regions/{country}
List premium regions in a country. Use a returned code in proxy.region alongside proxy.country.
# List proxy pools
Source: https://docs.surfsky.io/api-reference/proxies/list-proxy-pools
/api-reference/openapi.json get /proxies/pools
List geographic pools and their countries. The pool code selects a geographic region; proxy.tier selects shared or premium access.
# List shared countries
Source: https://docs.surfsky.io/api-reference/proxies/list-shared-countries
/api-reference/openapi.json get /proxies/shared/countries
List countries currently available in the shared proxy pool. Shared targeting supports country only.
# Report a blocked site
Source: https://docs.surfsky.io/api-reference/site-reports/report-a-blocked-site
/api-reference/openapi.json post /site-reports
Tell the Surfsky team that a website blocks your browsers, after the steps in [troubleshooting](/troubleshooting#the-site-is-blocking-you) did not help. Send the page URL and a description: the block or challenge you saw, the status code, and the proxy and profile settings you tried.
# CAPTCHA solving
Source: https://docs.surfsky.io/captcha-solving
Enable CAPTCHA solvers, choose manual or automatic mode, and check the result.
Surfsky exposes CAPTCHA commands through a page-level CDP session. Enable `anti_captcha` when starting the browser, choose a solver for the challenge, and verify the page state after solving.
## Enable solving
Add this to the browser start body:
```json theme={null}
{
"anti_captcha": {
"enabled": true,
"auto_captcha_types": ["turnstile"]
}
}
```
Use `auto_captcha_types` to restrict automatic detection to the types your page needs. It does not restrict an explicit manual `Captcha.solve` request.
### Provider configuration
| Setting | Purpose |
| - | - |
| `enabled` | Enables the CAPTCHA subsystem. Defaults to `false`. |
| `auto_captcha_types` | Types permitted in automatic mode. Set an explicit list for predictable behavior. |
| `gemini_api_key` | Key used by solvers that require Gemini, unless configured on your account. |
| `disable_external_providers` | Skip loading the external provider integration. Defaults to `false`. |
| `external_providers_initially_enabled` | Load the external integration but control whether it begins enabled. Defaults to `true`; ignored if loading is disabled. |
Provider availability depends on your account configuration. reCAPTCHA, GeeTest, and BLS use the external provider integration. Image-to-text requires a configured CapMonster key. DataDome audio and FunCaptcha use Gemini.
Check [solver balance](/api-reference/captcha/get-solver-balance) for the providers exposed by your account. A balance response does not verify that every provider key is valid or funded.
## Choose a method
| Challenge | Manual `Captcha.solve` | Automatic `Captcha.autoSolve` | Guide |
| - | - | - | - |
| reCAPTCHA | No | Yes, external provider | [reCAPTCHA](/use-cases/recaptcha) |
| GeeTest | No | Yes, external provider | [GeeTest](/use-cases/geetest) |
| BLS | No | Yes, external provider | [BLS](/use-cases/bls) |
| Turnstile | Yes | Yes | [Turnstile](/use-cases/turnstile) |
| PerimeterX | Yes | Yes | [PerimeterX](/use-cases/perimeterx) |
| DataDome audio or slider | Yes | Yes | [DataDome](/use-cases/datadome) |
| FunCaptcha audio | Yes | Yes | [FunCaptcha](/use-cases/funcaptcha) |
| A known click target | Yes | No | [Click](/use-cases/click) |
| Image-to-text | Yes | No | [Image](/use-cases/image) |
Support can vary by challenge version and site configuration. Test on your target page and check that the protected content loads.
## Connect and solve
This Python example starts a browser, connects Playwright, and attempts a manual Turnstile solve. Install the packages and set your [Surfsky credentials](/quickstart#credentials):
```bash theme={null}
pip install requests playwright
export TARGET_URL="https://your-site.example/protected-page"
```
Replace `TARGET_URL` with the page you are testing. Save as `solve.py`:
```python theme={null}
import os
import requests
from playwright.sync_api import sync_playwright
base_url = os.environ["SURFSKY_API_BASE_URL"].rstrip("/")
headers = {"X-Cloud-Api-Token": os.environ["SURFSKY_API_TOKEN"]}
response = requests.post(
f"{base_url}/profiles/one_time",
headers=headers,
json={
"anti_captcha": {
"enabled": True,
"disable_external_providers": True,
"auto_captcha_types": ["turnstile"],
},
"browser_settings": {"inactive_kill_timeout": 120},
},
timeout=120,
)
response.raise_for_status()
session = response.json()
try:
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(session["ws_url"])
try:
context = browser.contexts[0]
page = context.pages[0] if context.pages else context.new_page()
cdp = page.context.new_cdp_session(page)
page.goto(os.environ["TARGET_URL"], wait_until="domcontentloaded")
result = cdp.send("Captcha.solve", {
"type": "turnstile",
"timeout": 60000,
})
print(result)
if result["status"] != "success":
raise RuntimeError(f"Solve ended with {result['status']}")
# Add a wait for your site's protected content here.
finally:
browser.close()
finally:
stopped = requests.post(
f"{base_url}/profiles/{session['internal_uuid']}/stop",
headers=headers, timeout=120,
)
if stopped.status_code != 404:
stopped.raise_for_status()
```
Run it:
```bash theme={null}
python solve.py
```
A successful attempt returns `{"status": "success", "type": "turnstile"}`. If no challenge is present, manual detection can return a CDP error instead. The final API request stops the browser when the example exits.
For JavaScript, start with the [Playwright lifecycle](/quickstart/playwright), add `anti_captcha` to its start body, and create the CDP session with `await page.context().newCDPSession(page)`. The command parameters are the same.
## Manual solving
`Captcha.solve` waits for one attempt to finish:
```javascript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "turnstile",
timeout: 60000,
});
```
| Parameter | Meaning |
| - | - |
| `type` | Solver type. When omitted, Surfsky tries manual detection. |
| `timeout` | Overall timeout in milliseconds for the manual attempt. |
| `options.selector` | Optional CSS selector, or `[x, y]` coordinates for click solving. |
| `options.image_url` | Required image URL for image-to-text. |
| `options.timeout` | Solver-specific timeout in milliseconds, where supported. |
| `options.wait_networkidle` | Solver-specific wait configuration; not an overall command timeout. |
| `options.proxy` | Solver-specific proxy URL, where supported; does not change the browser's proxy. |
Pass only the options listed in the selected solver's guide. Solvers may ignore other fields even when the request accepts them.
Results include `status` and `type`. Statuses include `success`, `failed`, `timeout`, and `not_detected`; image solving also returns `solution` on success. Invalid parameters and detection failures may raise CDP errors instead of returning a status object.
## Automatic solving
Use `Captcha.autoSolve` to start background detection on the page. Attach event listeners before starting it, then navigate:
```javascript theme={null}
cdp.on("Captcha.solveCompleted", (event) => {
console.log("Solve completed:", event.type, event.status);
});
cdp.on("Captcha.solveFailed", (event) => {
console.error("Solve failed:", event.type, event.status ?? event.error);
});
await cdp.send("Captcha.autoSolve", { type: "turnstile" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
// Wait for a selector or response that identifies your protected content.
```
The response `{"status": "started"}` confirms that background solving started. Wait for the expected page content or response to confirm completion, and set a timeout for that wait.
`type` restricts detection to one allowed type. Omit it to detect among the configured `auto_captcha_types`. Calling `autoSolve` again replaces the current background loop; disconnecting the CDP client ends it. Repeated failures can stop the loop, so observe failure events.
### Events
| Event | When it is emitted |
| - | - |
| `Captcha.detectionStarted` | Detection begins. |
| `Captcha.detectionCompleted` | Detection finishes. |
| `Captcha.solveStarted` | A solve attempt begins. |
| `Captcha.solveCompleted` | An attempt returns success. |
| `Captcha.solveFailed` | An attempt fails, times out, or raises an error. |
Payload fields depend on the event. Solve events include the type when known, and may include `status`, `error`, or an image `solution`.
## Control the external provider
If the browser loaded the external integration, toggle it on the current page with:
```javascript theme={null}
await cdp.send("Captcha.setExternalProvidersEnabled", { enabled: false });
await cdp.send("Captcha.setExternalProvidersEnabled", { enabled: true });
```
This does not disable internal solvers or stop the background detection loop. If the browser started with `disable_external_providers: true`, the integration is absent and this command returns an error. Start another browser with loading enabled if you need it.
## Troubleshooting
| Symptom | Check |
| - | - |
| Solver is not enabled | Set `anti_captcha.enabled: true` in the browser start request. |
| Type cannot be solved manually | Use automatic mode for reCAPTCHA, GeeTest, or BLS. |
| Type is not allowed in automatic mode | Include it in `auto_captcha_types` before starting the browser. |
| No CAPTCHA detected | Inspect the page and frame; wait for the challenge to appear. |
| `status: "started"` but content is still blocked | Observe solve events and wait for the actual page result. |
| Repeated failures | Check provider configuration, balance, proxy behavior, and current challenge type. |
| Solver succeeds but data is missing | The page may still need navigation, form submission, or another wait. |
Use [DevTools](/debugging) or [Screencast](/screencast) to observe one attempt before increasing concurrency. Keep provider keys out of logs and source control.
# CAPTCHA guides
Source: https://docs.surfsky.io/category/use-cases
Choose a guide for the challenge on your page, with setup requirements and solver examples.
Start with [CAPTCHA solving](/captcha-solving) to configure a browser and connect a page-level CDP session. Then choose the guide for the challenge your target displays.
| Challenge | Guide covers |
| - | - |
| [Turnstile](/use-cases/turnstile) | Manual and automatic solving. |
| [reCAPTCHA](/use-cases/recaptcha) | External automatic solving and page verification. |
| [GeeTest](/use-cases/geetest) | External automatic solving. |
| [BLS](/use-cases/bls) | External automatic solving for the challenge. |
| [PerimeterX](/use-cases/perimeterx) | Press-and-hold solving, manual and automatic. |
| [DataDome](/use-cases/datadome) | Audio and slider variants. |
| [FunCaptcha](/use-cases/funcaptcha) | Audio challenge solving. |
| [Click](/use-cases/click) | A known selector or coordinate target. |
| [Image](/use-cases/image) | Image-to-text recognition and form submission. |
If you cannot identify the challenge, inspect one attempt in [DevTools](/debugging). Check the page's result after solving; a solver completion event does not confirm that the whole job succeeded.
# Coding agents
Source: https://docs.surfsky.io/coding-agents
Set up Surfsky in your project with Claude Code, Codex, Cursor, or another coding agent.
Give this prompt to Claude Code, Codex, Cursor, or another agent that can run commands in your project:
```text theme={null}
Read https://surfsky.io/SKILL.md and set up Surfsky in this project.
```
The [skill file](https://surfsky.io/SKILL.md) tells the agent how to install the integration that fits your project and check it with one browser session. You need your [API token and base URL](/quickstart#before-you-start) for the setup.
## Install the skill yourself
During setup the agent saves the skill into your project and uses it for later Surfsky tasks. To add it without running the setup, download the file:
```bash Claude Code theme={null}
mkdir -p .claude/skills/surfsky
curl -fsSL https://surfsky.io/SKILL.md -o .claude/skills/surfsky/SKILL.md
```
```bash Codex, Gemini CLI, Cursor theme={null}
mkdir -p .agents/skills/surfsky
curl -fsSL https://surfsky.io/SKILL.md -o .agents/skills/surfsky/SKILL.md
```
# Concurrency
Source: https://docs.surfsky.io/concurrency
Run browser jobs in parallel, manage shared account limits, and clean up failed workers.
Limit the number of browser jobs running at once, queue excess work, and stop each session when its job finishes. Your account limit applies across all processes using the account.
## Check available capacity
```bash theme={null}
curl "$SURFSKY_API_BASE_URL/users/browser-limits" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Example response for an account with a browser limit:
```json theme={null}
{
"success": true,
"msg": "Browser limits retrieved",
"data": {
"has_browser_limits": true,
"parallel_browsers": 50,
"running": 12,
"available": 38
}
}
```
`available` is a snapshot. It does not reserve slots; another worker can start a browser before your next request. Set an application concurrency limit below the account cap if other jobs share the account, and handle `parallel_browsers_limit_reached` at startup.
Browser slots are one of several account limits; [limits](/limits) covers request rates and proxy traffic quotas and the `429` codes each one returns.
## Use an SDK pool
The [Python](/sdk/python#process-items-concurrently) and [TypeScript](/sdk/typescript#process-items-concurrently) SDKs provide `map()` and browser pools with a concurrency setting and managed cleanup.
A pool can reuse a browser for several items. Cookies, storage, and open pages can carry over. Use separate sessions for jobs that need independent state, and retire a browser after a job that leaves it unusable.
A [persistent profile](/sessions#persistent-profiles) runs one browser at a time, so use a separate profile per account you run concurrently.
## Stop sessions after failures
Put the stop call in `finally`, outside the framework connection block, so it also runs if connecting fails. The [framework quickstarts](/quickstart#choose-an-integration) show the full lifecycle.
Choose an [inactivity timeout](/sessions#how-a-session-ends) long enough for legitimate pauses, but do not rely on it as your only cleanup.
After a worker crashes, compare `GET /profiles/active` with the session IDs your application owns. Session age alone does not prove a leak: another worker may still be using that browser. Stop only sessions you can identify as abandoned.
`POST /profiles/stop` stops every browser on the account, including work owned by other processes. Use individual stop calls for routine cleanup.
## Increase load gradually
Start with a small worker count. Measure completed jobs, target-site failures, proxy traffic, and browser startup errors before raising it.
Browser slots and API rate limits are separate. Starting many browsers at once can hit a request limit even when slots remain. Queue startups and use bounded backoff with jitter for temporary capacity errors. See [errors and retries](/errors#retrying).
A start request can time out after the server has created the browser. Check active sessions before repeating a one-time start request.
## What to monitor
| Signal | What to do with it |
| - | - |
| `data.available` from `/users/browser-limits` | Check account capacity; do not treat it as a reservation. |
| Active sessions and your worker records | Find sessions whose owning job has ended. |
| Minute and hour rate-limit headers | Slow API requests before exhausting either window. |
| `cluster_is_full` or startup failures | Back off and stop retrying after a bounded number of attempts. |
| Proxy quota and traffic stats | Estimate how much work your remaining traffic can support. |
| Target status and expected page content | Measure completed work, including responses that contain challenge pages. |
# Cookies
Source: https://docs.surfsky.io/cookies
Export or import a profile's cookies and troubleshoot logins that do not survive a restart.
Persistent profiles save cookies by default. You only need the cookie endpoints when transferring cookies between a profile and another system. For ordinary login reuse, start the same [persistent profile](/sessions#persistent-profiles).
Use these endpoints on a stopped profile. To inspect or change cookies in a running browser, use your framework's cookie API in the existing context.
## Export
```bash theme={null}
curl --fail-with-body \
"$SURFSKY_API_BASE_URL/profiles/PROFILE_UUID/cookies?export_format=json" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
JSON export returns an array in `data.cookies`; `export_format=netscape` returns cookie-file text.
Example JSON export, with a redacted value:
```json theme={null}
{
"success": true,
"msg": "Cookies were exported",
"data": {
"cookies": [
{
"domain": ".example.com",
"name": "session",
"value": "REDACTED",
"path": "/",
"secure": true,
"httpOnly": true
}
]
}
}
```
`cookies_not_found` means there are no stored cookies to export. `cookies_disabled` means cookie storage is disabled for the profile.
## Import
Send the cookies as a string, either a serialized JSON array or Netscape text:
```bash theme={null}
curl --fail-with-body \
"$SURFSKY_API_BASE_URL/profiles/PROFILE_UUID/cookies" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"cookies": "[{\"domain\":\".example.com\",\"name\":\"session\",\"value\":\"YOUR_COOKIE_VALUE\",\"path\":\"/\",\"secure\":true}]"}'
```
Importing an expired cookie does not renew the website's session.
At profile creation, `cookies` also accepts an array:
```json theme={null}
{
"title": "my profile",
"fingerprint": { "os": "win" },
"cookies": [
{
"domain": ".example.com",
"name": "session",
"value": "YOUR_COOKIE_VALUE",
"path": "/"
}
]
}
```
Cookie values can grant access to signed-in accounts. Store exports securely and redact them from logs and support requests.
## Sessions that will not stay logged in
Check the state restored by the next run:
| Check | What to do |
| - | - |
| The profile is one-time | Create a persistent profile and reuse its saved UUID. |
| The profile was still running | A start request on a running profile returns that session and ignores new settings. Stop it, then start again. |
| The site stores authentication in local storage | Enable `storage_options.localstorage` before signing in again. |
| The previous session ended in a crash or failure | State after the last saved stop is lost. Sign in again and wait for the stop response before restarting. |
| The cookie has expired or been revoked | Authenticate again; restoring the cookie cannot extend its validity. |
| The site asks to verify a new device or location | Check for changes to the fingerprint or proxy IP. |
Service workers can also hold application state. Enable `storage_options.serviceworkers` if the site needs it. The [storage options table](/sessions#what-gets-saved) lists all defaults.
# Debugging
Source: https://docs.surfsky.io/debugging
Open DevTools for a running browser and inspect failed page loads, selectors, and network requests.
Open DevTools while the failing session is still running. You can inspect its DOM, console, and network requests, and compare what the browser received with what your code expected.
## Attach DevTools
The start response includes `inspector`:
```json theme={null}
{
"inspector": {
"list": "https://YOUR_API_HOST/proxy/INTERNAL_UUID/inspector",
"pages": [{
"page_url": "about:blank",
"devtools_url": "devtools://devtools/bundled/inspector.html?ws=..."
}]
}
}
```
Copy the returned `devtools_url` into Chrome's address bar. The example above is abbreviated.
Open `inspector.list` to find the current pages, including tabs created after startup. The inspector URLs work only while the session is running.
An idle DevTools tab does not count as activity. With the default `inactive_kill_timeout` of 30 seconds, the browser can stop while you read the page. Start the session with a longer value for manual inspection; see [the browser stopped by itself](/troubleshooting#the-browser-stopped-by-itself).
A session's connection and inspector URLs grant access to its live browser. Keep them out of public logs, screenshots, and client-side code served to unrelated users.
## The DevTools HTTP endpoints
Surfsky also proxies Chrome's discovery endpoints:
```bash theme={null}
curl "$SURFSKY_API_BASE_URL/proxy/$INTERNAL_UUID/json/version"
curl "$SURFSKY_API_BASE_URL/proxy/$INTERNAL_UUID/json/list"
```
Set `INTERNAL_UUID` to the value from the start response. Use the returned WebSocket address when connecting an automation client.
## Inspect a failing job
| What you see | What to inspect next |
| - | - |
| An element is missing | Check the current URL, frames, language, and whether a challenge replaced the page. |
| The element appears after your code fails | Wait for the relevant selector or response instead of adding a fixed delay. |
| A blank or incomplete page | Check failed network requests, blocked resources, and console errors. |
| A login page after restoring a profile | Check storage options and whether the script used the default browser context. |
| A CAPTCHA that does not progress | Check solver settings, events, and provider balance. |
| CDP disconnects | Check `/profiles/active`, the idle timeout, and whether another worker stopped the session. |
Keep the failure reproducible with one browser before increasing concurrency. DevTools can change timing, so also test the corrected script without it attached.
## Reading the page's requests
The Network panel can show whether the data you need arrived in a response before the page rendered it. If that response contains the required fields, you can capture it instead of extracting each field from the DOM.
The SDKs expose `capture_responses` in [Python](/sdk/python) and `captureResponses` in [TypeScript](/sdk/typescript). Enable capture before triggering the request. Avoid logging response bodies containing credentials or account data.
## Watch it run
Use [Screencast](/screencast) for a live, view-only image of the page. It is useful for observing layout changes or a challenge flow without opening the full DevTools interface.
## Getting help
Send a minimal reproduction, approximate UTC time, HTTP status, and the `x-cloud-tracing-uuid` response header to [support](mailto:hello@surfsky.io). Include the `internal_uuid` only in a private support conversation if needed.
Remove API tokens, proxy passwords, cookies, personal data, and live browser URLs from the reproduction. See [troubleshooting](/troubleshooting) for common causes to check first.
# Errors
Source: https://docs.surfsky.io/errors
Read API error responses, choose a retry policy, and diagnose request or browser failures.
Most API errors use this shape:
```json theme={null}
{
"success": false,
"msg": "Maximum parallel browsers",
"data": null,
"code": "parallel_browsers_limit_reached"
}
```
Use the HTTP status and `code`, when present, to choose an action. Keep `msg` for logs and diagnostics. `data` may contain validation details or information from the browser.
Some forwarded browser responses and scraping errors do not include `code`, and may use `detail` or `error` instead. Your error handler should tolerate these shapes and non-JSON responses from a network intermediary. Record the status and `x-cloud-tracing-uuid` header when available.
## Where the failure came from
Read the response first. Most failures fall into one of these:
| What you see | What failed | Go to |
| - | - | - |
| `401`, `403`, `404`, `422` | Your request or account: token, host, plan, or body | [Request errors](#request-errors) |
| `429`, `parallel_browsers_limit_reached` | An account limit | [Limits](/limits) |
| `proxy_required`, `bad_proxy`, `tunnel_not_ready`, `proxy_provider_error` | The browser's connection to the internet | [Proxy errors](/proxies#proxy-errors) |
| `browser_failed_to_start`, `profile_start_failed`, `invalid_fingerprint` | Browser startup | [Errors from the browser](#errors-from-the-browser) |
| `Target closed`, `Profile not found`, a dropped CDP socket | The session ended while the job was still running | [The browser stopped by itself](/troubleshooting#the-browser-stopped-by-itself) |
| `cluster_is_full`, `service_unavailable`, `app_outdated`, `502`, `503` | Surfsky infrastructure. Retry it | [Retrying](#retrying) |
| `success: true`, but the result is wrong | The target site | [The site is blocking you](/troubleshooting#the-site-is-blocking-you) |
A browser can start, `/scrape` can return HTTP 200, and the page can still hold a challenge or an empty result, so check the content and not only the status.
### Which timeout fired
| Symptom | Timeout | What to change |
| - | - | - |
| `400 scrape_timeout` | The 120-second budget for one `/scrape` call | Narrow `wait_for`, or split the batch |
| `Target closed` part way through a job | `inactive_kill_timeout`, 30 seconds by default | Raise it for long waits and manual work |
| Your framework times out while the browser is still running | The framework's own navigation or selector timeout | Wait for a selector that proves the content is ready |
## Retrying
Choose retries by the reported condition rather than the HTTP status alone:
| Condition | Action |
| - | - |
| `cluster_is_full`, temporary startup or provider failure, `503` | Retry with bounded exponential backoff and jitter. Stop after a limited number of attempts. |
| `rate_limits_reached` | Wait and reduce the request rate. Either the minute or hour window may be exhausted. |
| `parallel_browsers_limit_reached` | Wait for a slot or stop a session your job owns. |
| Traffic quota exhausted | Check the allowance and reset time; repeated immediate retries will not help. |
| Invalid credentials, missing resource, invalid body, unavailable account feature | Correct the request or account configuration first. |
| `profile_is_running` | Stop the profile before editing, deleting, or importing cookies. |
| `profile_version_conflict` | Re-read the profile, reconcile the intended change, then retry. |
A `400 cluster_is_full` can be retried; a `429` caused by exhausted traffic quota needs a different response. HTTP status alone cannot distinguish these cases.
If a start request times out or loses its connection, its outcome is uncertain. Check [active sessions](/sessions#find-running-sessions) before repeating it. Creating a profile or one-time session is not an idempotent operation, so an automatic retry can create an extra resource.
## Request errors
| Status | Code | What it means |
| - | - | - |
| 400 | `proxy_required` | The browser has no way out to the internet. Pass `proxy`, `open_vpn`, or `wireguard`, or use a plan with a pool. |
| 400 | `cluster_is_full` | No capacity right now. Retry with backoff. |
| 400 | `profile_start_failed` | The browser did not come up. Retry with backoff; if it repeats, inspect the response details and the proxy configuration. |
| 400 | `profiles_limit_reached` | Your plan's persistent profile limit. Delete unused profiles. |
| 400 | `persistent_profiles_not_allowed` | Your plan is one-time only. |
| 400 | `proxy_pool_unavailable` | No pool is attached to your account. Bring your own proxy. |
| 400 | `shared_pool_country_unavailable` | The shared pool has nothing in that country at the moment. Pick another, or use premium. |
| 400 | `proxy_provider_error` | The upstream proxy provider failed. Retry. |
| 400 | `chromedriver_not_enabled` | Start the profile with `enable_chromedriver: true` before talking to the ChromeDriver endpoint. |
| 400 | `scrape_timeout` | `/scrape` gave up after 120 seconds. Usually the page never satisfied your wait condition. |
| 400 | `extension_limit_reached` | 100 extensions per account. |
| 400 | `no_file_provided` | The extension upload had no file part. |
| 400 | `user_is_blocked` | The account is blocked. Contact support. |
| 401 | `not_authorized` | Missing or wrong `X-Cloud-Api-Token`. |
| 403 | `subscription_is_expired` | No active subscription. |
| 403 | `namespace_not_allowed` | Your account is served by a dedicated host and you called the shared one. Use the host you were given. |
| 403 | `premium_pool_unavailable` | Premium proxies are not enabled for your account. |
| 403 | `shared_pool_unavailable` | Shared proxies are not enabled for your account. |
| 403 | `region_not_chosen` | Pick a region in the dashboard before starting browsers. |
| 403 | `region_managed` | Your plan's region is assigned by support, not self-serve. |
| 404 | `profile_not_found` | No such profile, or it belongs to another account. The API does not distinguish the two. |
| 404 | `extension_not_found` | No such extension. |
| 404 | `plan_not_exists` | No such plan. |
| 404 | `premium_quota_not_applicable` | This plan bills premium traffic per GB and has no quota to report. |
| 409 | `profile_is_running` | You tried to edit or delete a profile that is up. Stop it first. |
| 409 | `browsers_still_running` | The operation needs every browser stopped. |
| 500 | `extension_upload_failed` | Upload to storage failed. Retry. |
| 500 | `extension_delete_failed` | Delete from storage failed. Retry. |
| 500 | `proxy_locations_unavailable` | The proxy provider's location list could not be fetched. |
| 502 | `unexpected_browser_response` | The browser answered with something that was not JSON. Check whether the session is still active, then inspect or stop it before replacing it. |
| 503 | `service_unavailable` | Temporary. Retry with backoff. |
## Limit errors
These errors return HTTP `429`. Profile API responses include headers for the current request limits and remaining allowance; see [Limits](/limits).
| Code | What it means |
| - | - |
| `rate_limits_reached` | Requests per minute or per hour exceeded. Wait for the window. |
| `parallel_browsers_limit_reached` | You already have as many browsers running as your plan allows. Stop one, or check `GET /users/browser-limits` before starting. |
| `shared_traffic_limit_reached` | Shared proxy traffic allowance exhausted. Switch to your own proxy or to premium. |
| `premium_traffic_limit_reached` | Premium proxy quota exhausted. Top up or use your own proxy. |
| `site_report_limit_reached` | More than 1 site report per minute or 20 per day. Wait before sending the next one. |
| `region_change_too_soon` | Regions cannot be changed again this soon. |
## Errors from the browser
These errors come from the container running your browser. They usually appear during startup, scraping, or cookie requests.
| Status | Code | What it means |
| - | - | - |
| 400 | `bad_proxy` | The proxy did not accept the connection, or failed to route. Check credentials and that the endpoint is reachable. |
| 400 | `invalid_fingerprint` | The browser rejected the fingerprint. Check its fields against the fingerprint reference. |
| 400 | `cookies_not_found` | The profile has no stored cookies to export. |
| 400 | `cookies_disabled` | The profile's storage options have cookies turned off. |
| 400 | `pod_is_acquired` | The session is already in use. |
| 409 | `profile_version_conflict` | The profile changed between the read and the write. Re-read it and retry. |
| 422 | `validation_error` | Malformed request body. `data` lists the fields. |
| 500 | `browser_failed_to_start` | The browser process died during startup. `data` carries what it printed. |
| 503 | `app_outdated` | The browser image is being updated. Retry shortly. |
| 503 | `tunnel_not_ready` | The proxy tunnel did not come up in time. Retry. |
## Validation errors
Gateway validation errors return `422 validation_error` with the failing fields in `data`. For example:
```json theme={null}
{
"success": false,
"msg": "Validation error",
"code": "validation_error",
"data": [
{
"loc": ["body", "fingerprint", "os"],
"msg": "Input should be 'win', 'mac' or 'android'",
"type": "enum"
}
]
}
```
# Extensions
Source: https://docs.surfsky.io/extensions
Upload a Chrome extension, load it in a browser session, and preserve its state when needed.
Upload an extension to your account, then pass its UUID when starting a browser. You can load up to five extensions in one session.
## Upload
Package an unpacked Chrome extension as a ZIP with `manifest.json` at the archive root. If you have a `.crx`, extract the extension files with a CRX-compatible tool before creating the ZIP.
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/extensions" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-F 'file=@my-extension.zip' \
-F 'name=Reporting helper'
```
Save `uuid` from the response.
## Use
Include `extensions` in a one-time or persistent-profile start request:
```json theme={null}
{ "extensions": ["EXTENSION_UUID"] }
```
It loads for that session only. Check its permissions and configuration before using it against your target site.
## Manage
List the account's extensions:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/extensions" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Rename an extension:
```bash theme={null}
curl --fail-with-body -X PATCH "$SURFSKY_API_BASE_URL/extensions/EXTENSION_UUID" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name": "Updated reporting helper"}'
```
Delete an extension you no longer need:
```bash theme={null}
curl --fail-with-body -X DELETE "$SURFSKY_API_BASE_URL/extensions/EXTENSION_UUID" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
To update extension code, upload a new ZIP and use its new UUID on the next browser start. The rename endpoint does not replace an archive. Deleting an upload does not change the copy already loaded in a running browser.
## Persisting extension state
To retain extension data between sessions, enable extension storage when creating a persistent profile:
```json theme={null}
{
"title": "my profile",
"fingerprint": { "os": "win" },
"storage_options": { "extensions": true }
}
```
Supply the extension UUIDs on each start. The storage option preserves extension state; it does not select which extensions to load.
## Limits and failure modes
| Error | Meaning |
| - | - |
| `extension_limit_reached` | The account already has 100 uploaded extensions. |
| `no_file_provided` | The upload is missing a file. |
| `extension_upload_failed` | The archive could not be stored. Retry a temporary failure; contact support if it persists. |
If a page behaves differently with an extension loaded, compare the same workflow without it. Content scripts and request modifications can change both the page and the signals it observes. Use [DevTools](/debugging) to inspect those differences.
# Fingerprints
Source: https://docs.surfsky.io/fingerprints
How Surfsky builds a profile fingerprint, what it handles automatically, and when to override it.
A fingerprint is the set of browser and device properties a website can observe. Surfsky generates each profile's fingerprint from data collected on real devices: GPU and WebGL parameters, OS-specific screens and fonts, and compatible CPU and memory combinations. Selection is weighted toward common hardware.
Use the defaults unless the task requires a specific device or setting. A one-time profile can omit `fingerprint` entirely; the default OS is Windows. A persistent profile needs `fingerprint.os`.
## When to override
Set the OS or device required by your task:
```json theme={null}
{"fingerprint": {"os": "mac"}}
```
Setting one field does not adjust the others. A user agent alone does not change the GPU, screen, or Client Hints. If you set several fields, make sure the OS, hardware, screen, and user agent describe the same kind of device. An inconsistent set can fail validation or browser startup.
## Chrome version
Surfsky updates its browser build on Chrome's release schedule. Generated user agents, Client Hints, and other version-dependent fingerprint fields update with the browser for both one-time and persistent profiles.
The generated user agent uses Chrome's reduced form, `Chrome/.0.0.0`. A custom `user_agent` stays fixed across browser updates. If you set one, keep its major version aligned with the engine; Client Hints use the version from that value.
## Fields
| Field | Value | Purpose |
| - | - | - |
| `os` | `win`, `mac`, `android` | Operating system; required for persistent profile creation |
| `os_version` | String | Operating system version |
| `os_arch` | String | Architecture, such as `x86` or `arm` |
| `user_agent` | String | User agent reported by the browser |
| `screen` | String | Screen geometry from the screens endpoint |
| `renderer` | String | GPU renderer from the renderers endpoint |
| `device_model` | String | Android device model |
| `device_type` | String | Android device type, such as `phone` or `tablet` |
| `languages` | Array of strings | Browser language preferences; derived from the IP when unset |
| `timezone` | String | IANA timezone name, such as `Europe/Berlin`; derived from the IP when unset |
| `geolocation` | Object | Latitude, longitude, and accuracy exposed by the geolocation API; derived from the IP when unset |
| `cpu`, `ram` | Integers | Reported CPU cores and memory |
| `noise` | Object | Per-API noise switches, see [Noise](#noise) |
| `media_devices` | Object | Reported media device counts |
| `webrtc` | Object | WebRTC configuration |
| `dns` | String | DNS resolver setting |
## Accepted values
Look up renderer, screen, and Android model values before supplying them:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/fingerprint/renderers" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
curl --fail-with-body "$SURFSKY_API_BASE_URL/fingerprint/screens" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
curl --fail-with-body "$SURFSKY_API_BASE_URL/fingerprint/device_models" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
The [fingerprint reference](/api-reference/fingerprints/list-renderers) describes query filters for these lists.
## Fields that follow the IP
When unset, `timezone`, `languages`, and `geolocation` are derived from the exit IP at each start. The IP lookup supplies the timezone and coordinates, and the country determines the languages. These values update when a persistent profile changes proxy location.
Explicit values remain fixed when the proxy changes. For example, `timezone: "Europe/Berlin"` still reports Berlin with a US exit IP. Use overrides when your task requires that difference.
```json theme={null}
{
"proxy": {"tier": "premium", "country": "de"},
"fingerprint": {
"os": "win",
"timezone": "Europe/Berlin",
"languages": ["de-DE", "de", "en"]
}
}
```
## Canvas and WebGL
Canvas and WebGL fingerprinting scripts render an image and hash its pixels. The result depends on the GPU and driver, so devices with the same hardware can share a hash. Sites can also compare it with the reported renderer.
For the fingerprinting scripts used by Akamai, ThreatMetrix, Cloudflare, Google, and similar services, Surfsky returns canvas and WebGL images recorded on a real machine with the profile's GPU. The images match the reported renderer and remain the same across sessions. No configuration is required. Canvases outside those scripts render as in stock Chrome.
WebGPU is enabled, and its adapter information describes the same GPU as WebGL. You can check it on the [browserleaks WebGPU page](https://browserleaks.com/webgpu).
## Noise
Noise applies a per-profile adjustment to an API's output, changing the resulting fingerprint hash. The adjustment is generated once and reused on every start. Different profiles can still produce the same hash, as can real devices.
`noise` is off by default on desktop profiles. Android profiles use a fixed preset; a `noise` object sent for Android is ignored. Leave noise off unless a site links your profiles by one of these hashes. Test the target site before changing it on an existing profile, since a changed hash can affect device recognition.
Disabling a noise setting discards its adjustment. Enabling it again generates a new adjustment and hash for that API without recreating the profile.
```json theme={null}
{"fingerprint": {"noise": {"canvas": false, "webgl": false, "audio": true, "client_rects": true}}}
```
| Key | What it changes | Notes |
| - | - | - |
| `canvas` | 2D canvas pixels | Applies an adjustment to the recorded canvas image. Keep it off for Google accounts. For Amazon accounts, use one persistent profile per account to preserve the canvas hash between logins. |
| `webgl` | WebGL image hash | Most services do not score it. |
| `audio` | AudioContext output | The sine wave a site renders through the audio stack and hashes. |
| `client_rects` | `getClientRects` dimensions | Adjusts element dimensions used in fingerprinting. Disable it if it causes layout problems. |
## Persistent profiles
A persistent profile keeps its fingerprint between starts. Update it while it is stopped if you need to change its settings. `os`, `os_arch`, and `os_version` are fixed for the life of the profile; create a new one for a different OS.
On a desktop profile you can change `user_agent`, `screen`, `cpu`, `ram`, `renderer`, `media_devices`, `noise`, and the connection fields (`languages`, `timezone`, `geolocation`, `dns`, `webrtc`). On an Android profile only the connection fields and `user_agent` take effect; hardware fields are ignored.
Changing hardware fields changes the device fingerprint even when cookies are restored. Updating `renderer` also changes the WebGL preset and recorded canvas images. Keep it stable unless the account needs a different device.
See [Sessions](/sessions#persistent-profiles) for profile creation and [Cookies](/cookies) for login persistence.
## Mobile
Use `os: "android"` for an Android profile:
```json theme={null}
{"fingerprint": {"os": "android"}}
```
To specify a model, choose one from `GET /fingerprint/device_models`. `device_model` and `device_type` are accepted only with Android. Use a matching device type and screen instead of combining a phone model with desktop settings.
An Android profile always reports one microphone, one speaker, and front and back cameras. `media_devices` is ignored for it.
# Human behavior
Source: https://docs.surfsky.io/human-behavior
Choose input methods, sequence page actions, and set timing for realistic browser interactions.
Sites can evaluate interaction patterns alongside network and browser properties. Surfsky manages the browser fingerprint and proxy connection, and provides Human commands for mouse, keyboard, and scroll input. Your script controls the sequence of actions and the time between them.
## Use Human commands for all input
Use [Human commands](/human_emulation) for clicks, typing, and scrolling instead of the default Playwright and Puppeteer input methods. Methods such as `page.click`, `page.type`, and `page.mouse.move` do not simulate human movement and timing by default. The [Python](/sdk/python) and [TypeScript](/sdk/typescript) SDK input methods, including `browser.click()` and `browser.type()`, already use the Human command layer.
Use Human commands consistently within a session. Surfsky tracks the cursor position between commands; moving it through Playwright or Puppeteer can leave that state out of sync. Avoid `element.click()` inside `evaluate` as well, since it dispatches an untrusted event.
## Follow the page layout
Wait for the content to load, then use `Human.scroll` to reach the section you need. Use `Human.moveTo` for controls that require hovering. `Human.click` scrolls its target into view automatically, but allow time before the click if the page requires reading or choosing an option.
Fill forms one field at a time, in the order shown on the page. Click each field before typing.
Check that each selector matches the control you intend to use. Some pages include hidden fields or links as bot traps, known as honeypots. Avoid filling every input or clicking the first match from a broad selector. `Human.click` checks that an element has a visible box; it does not check whether the element belongs to the actual form or navigation.
## Timing
`Human.click` includes a short pause before pressing the button. If you call it immediately after `waitForSelector` returns, that pause is the only delay before the click. Keep the default `preDelay` and `postDelay` values, and allow extra time for reading or making a selection where the page requires it.
Avoid adding random mouse movements or delays to every command. Scroll to read content and hover to open controls where needed; additional movement does not necessarily make a session more realistic.
Repeated sessions can follow a recognizable sequence even when their timing varies. Where the task allows, vary the entry page, optional steps, or scroll distance.
## Where it matters
Pay particular attention to timing and navigation on login, registration, and checkout pages. Use the site's menus or links to reach the form, and allow time to read each step before submitting it.
If the first request is blocked before JavaScript runs, start by checking the [proxy](/proxies) and [fingerprint](/fingerprints). Request headers and IP reputation can be assessed before the page loads, so changing input timing will not address that failure.
# Human emulation
Source: https://docs.surfsky.io/human_emulation
Use Human.* CDP commands for clicks, typing, scrolling, dragging, and touch input.
Surfsky adds `Human.*` commands to CDP for mouse, keyboard, scroll, drag, and touch input. Each command performs a complete interaction inside the browser session. For example, `Human.click` moves the cursor to the element, clicks it, and pauses briefly.
Your script still needs to wait for page content and check the result of each action. Human commands do not guarantee that a site will accept a session. See [Human behavior](/human-behavior) for action order and timing.
## How it works
Surfsky generates input with a proprietary model of human movement. It does not use open-source humanization libraries or combine Bezier curves with Fitts' law.
Commands send events through the browser's input pipeline. Cursor movement includes changes in speed, small corrections, and tremor, with deceleration near the target. Click positions vary within the element. Typing uses variable intervals and occasionally enters an incorrect key followed by Backspace. Drags may overshoot the target before correcting their position.
On Android profiles, the commands produce taps and swipes with touch contact area and pressure.
## Commands
| Command | Purpose |
| - | - |
| [`Human.click`](#human-click) | Move to a target and click it |
| [`Human.dblclick`](#human-dblclick) | Move to a target and double-click it |
| [`Human.moveTo`](#human-moveto) | Move the cursor to a target without clicking |
| [`Human.type`](#human-type) | Type text into the focused element |
| [`Human.press`](#human-press) | Press one key or key combination |
| [`Human.scroll`](#human-scroll) | Scroll by a relative distance over a duration |
| [`Human.wheel`](#human-wheel) | Send a single wheel step |
| [`Human.scrollIntoView`](#human-scrollintoview) | Scroll until an element is in the viewport |
| [`Human.scrollTo`](#human-scrollto) | Scroll to an absolute document position |
| [`Human.drag`](#human-drag) | Press, drag, and release between two points |
| [`Human.mouseDown`](#human-mousedown-and-human-mouseup) | Press and hold a button |
| [`Human.mouseUp`](#human-mousedown-and-human-mouseup) | Release a held button |
All time values are in **seconds**. Coordinates are CSS pixels in the viewport. Every successful response includes `success: true` plus the fields listed for the command. A missing required parameter returns CDP error `-32602`; a failure during the action, such as an element that never appears, returns `-32603` with the reason in the message.
## Connect a page session
Start with the [Playwright](/quickstart/playwright) or [Puppeteer](/quickstart/puppeteer) quickstart. Once `page` is connected, create a page-level CDP session:
```python Python (async Playwright) theme={null}
cdp = await page.context.new_cdp_session(page)
```
```javascript JavaScript (Playwright) theme={null}
const cdp = await page.context().newCDPSession(page);
```
```javascript JavaScript (Puppeteer) theme={null}
const cdp = await page.createCDPSession();
```
The examples below use this `cdp` object. No `Human.enable` call or profile setting is required.
## Click and move
```python Python theme={null}
await cdp.send("Human.click", {"selector": "input[name='email']"})
await cdp.send("Human.type", {"text": "reader@example.com"})
await cdp.send("Human.click", {"selector": "button[type='submit']"})
```
```javascript JavaScript theme={null}
await cdp.send("Human.click", { selector: "input[name='email']" });
await cdp.send("Human.type", { text: "reader@example.com" });
await cdp.send("Human.click", { selector: "button[type='submit']" });
```
Replace the selectors with elements on your page.
### Targeting
`Human.click`, `Human.dblclick`, and `Human.moveTo` accept either a CSS `selector` or both `x` and `y`. Prefer selectors when the layout can change; Surfsky picks a natural point inside the element for you. With a selector, the command waits for the element to have a visible box, scrolls it into the viewport if it is outside, then moves to it.
| Parameter | Default | Meaning |
| - | - | - |
| `selector` | | CSS selector resolved in the main document. |
| `x`, `y` | | Viewport coordinates, as an alternative to a selector. If both are given with a selector, the coordinates win. |
| `waitForVisible` | `true` | Poll until the element has a visible box. With `false`, a single lookup is made and a missing element fails immediately. |
| `scrollIntoView` | `true` | Scroll the element into the viewport before moving to it. |
| `timeout` | `30` | Seconds to wait for the element. |
### `Human.click`
Moves to the target, then presses and releases the button.
| Parameter | Default | Meaning |
| - | - | - |
| `button` | `"left"` | `"left"`, `"right"`, or `"middle"`. |
| `clickCount` | `1` | Click count reported to the page. Use `Human.dblclick` for a double-click. |
| `modifiers` | | Array of `"Alt"`, `"Control"`, `"Meta"`, `"Shift"`. Held for the click and released afterwards. |
| `preDelay`, `postDelay` | generated | Seconds to pause before and after the click. Pass `0` to skip a pause. |
Response: `x`, `y`, `button`.
```javascript theme={null}
await cdp.send("Human.click", { selector: "#menu" });
await cdp.send("Human.click", { x: 320, y: 240, button: "right" });
await cdp.send("Human.click", { selector: "a.result", modifiers: ["Control"] });
```
### `Human.dblclick`
Same targeting and parameters as `Human.click`, without `clickCount`. Response: `x`, `y`, `button`.
```javascript theme={null}
await cdp.send("Human.dblclick", { selector: ".editable-cell" });
```
### `Human.moveTo`
Moves the cursor to the target without pressing anything. Use it to hover, to open a menu, or to position the cursor before `Human.mouseDown`. Takes only the targeting parameters. Response: `x`, `y`.
```javascript theme={null}
await cdp.send("Human.moveTo", { selector: "#menu" });
```
On Android profiles a finger cannot hover, so `Human.moveTo` records the position and dispatches nothing.
A successful response confirms the input was delivered. Check the page to confirm its effect.
## Type text and press keys
### `Human.type`
Types `text` into the currently focused element, one keystroke at a time. Click or focus the field first. It does not clear an existing value, and it does not take a selector.
| Parameter | Default | Meaning |
| - | - | - |
| `text` | `""` | Text to type. Newlines are sent as Enter. |
Response: `text`. Avoid logging typed secrets.
Typing includes occasional corrected mistakes: a wrong adjacent key followed by Backspace. The final field value matches `text`, but a page that reacts to every input event will see the correction. Do not use `Human.type` for fields that reject Backspace or act on the first keystroke.
### `Human.press`
Presses and releases one key.
| Parameter | Default | Meaning |
| - | - | - |
| `key` | required | Key name or combination joined with `+`, such as `Enter`, `Tab`, `Escape`, `Backspace`, `ArrowDown`, `Control+A`, or `Shift+Tab`. |
| `delay` | generated | Seconds between key down and key up. |
Response: `key`, `delay`.
```javascript theme={null}
await cdp.send("Human.click", { selector: "#search" });
await cdp.send("Human.press", { key: "Control+A" });
await cdp.send("Human.type", { text: "wireless keyboard" });
await cdp.send("Human.press", { key: "Enter" });
```
Use the shortcut appropriate to the browser's OS, such as `Meta+A` for a Mac fingerprint.
## Scroll
### `Human.scroll`
Scrolls by a relative distance as a series of eased wheel events. On Android profiles it becomes one or more swipes.
| Parameter | Default | Meaning |
| - | - | - |
| `deltaX`, `deltaY` | `0` | Distance in pixels. Positive `deltaY` scrolls down. |
| `duration` | `1.0` | Seconds the scroll takes. |
Response: `deltaX`, `deltaY`, `duration`.
### `Human.wheel`
Sends a single wheel step at the current cursor position, with no easing. Use it for one notch of a scroll wheel or to nudge a scrollable element under the cursor.
| Parameter | Default | Meaning |
| - | - | - |
| `deltaX`, `deltaY` | `0` | Wheel deltas in pixels. |
Response: `deltaX`, `deltaY`.
### `Human.scrollIntoView`
Scrolls until the element is inside the viewport. Does nothing if it already is.
| Parameter | Default | Meaning |
| - | - | - |
| `selector` | required | CSS selector in the main document. |
| `behavior` | `"smooth"` | `"smooth"` for a paced scroll, `"auto"` for a single wheel event. |
Response: `selector`, `behavior`.
### `Human.scrollTo`
Scrolls to an absolute document position.
| Parameter | Default | Meaning |
| - | - | - |
| `x`, `y` | `0` | Target document coordinates in pixels. |
| `behavior` | `"smooth"` | `"smooth"` for a paced scroll, `"auto"` for a single wheel event. |
Response: `x`, `y`, `behavior`.
```javascript theme={null}
await cdp.send("Human.scroll", { deltaY: 600, duration: 1.2 });
await cdp.send("Human.wheel", { deltaY: -120 });
await cdp.send("Human.scrollIntoView", { selector: "footer" });
await cdp.send("Human.scrollTo", { x: 0, y: 0 });
```
A scroll can trigger lazy loading. Wait for the resulting content before reading it.
## Drag and hold
### `Human.drag`
Moves to the start point, presses the button, drags to the end point, and releases. Drags decelerate into the target and may overshoot and correct, which suits sliders and drag-and-drop targets.
| Parameter | Default | Meaning |
| - | - | - |
| `startX`, `startY` | required | Viewport coordinates to press at. |
| `endX`, `endY` | required | Viewport coordinates to release at. |
| `button` | `"left"` | `"left"`, `"right"`, or `"middle"`. |
Response: `x`, `y`, `button`, where `x` and `y` are the release position.
```javascript theme={null}
await cdp.send("Human.drag", {
startX: 100,
startY: 250,
endX: 400,
endY: 250,
});
```
If the drag fails part way, Surfsky releases the button before returning the error.
### `Human.mouseDown` and `Human.mouseUp`
Press and release separately when you need a custom hold or a path with intermediate stops.
| Parameter | Default | Meaning |
| - | - | - |
| `x`, `y` | required | Viewport coordinates of the press or release. |
| `button` | `"left"` | `"left"`, `"right"`, or `"middle"`. |
Response for both: `x`, `y`, `button`.
`Human.mouseDown` presses at `x`, `y` without moving there first. Call `Human.moveTo` with the same coordinates before it so the cursor arrives along a natural path. While the button is held, the cursor keeps a small tremor until `Human.mouseUp`. Release the button even if an intermediate action fails:
```javascript theme={null}
await cdp.send("Human.moveTo", { x: 100, y: 250 });
await cdp.send("Human.mouseDown", { x: 100, y: 250 });
try {
await cdp.send("Human.moveTo", { x: 400, y: 250 });
} finally {
await cdp.send("Human.mouseUp", { x: 400, y: 250 });
}
```
## Android profiles
When the profile's fingerprint is Android, Surfsky switches input to touch. The commands and parameters stay the same, and the following changes apply:
* `Human.click` and `Human.dblclick` become taps. `button` and `clickCount` are ignored.
* `Human.moveTo` records the target and sends no event.
* `Human.scroll`, `Human.wheel`, `Human.scrollIntoView`, and `Human.scrollTo` become swipes. Long distances are split into several swipes with the finger lifting in between, so they take longer than `duration`.
* `Human.mouseDown`, `Human.mouseUp`, and `Human.drag` become touch start, touch end, and a finger drag.
* `Human.type` and `Human.press` send keyboard events as on desktop.
## Selector and timing problems
Selectors are resolved in the main document. An element inside an iframe or shadow root does not resolve, and the error message says so. Use your framework's frame or shadow-root support to find the element, then pass its viewport coordinates as `x` and `y`.
If a click lands in the wrong place, inspect the page for overlays, scrolling, and layout changes. Recalculate coordinates after a resize or navigation. See [debugging](/debugging).
Human timing values are in **seconds**. CAPTCHA timeouts and most Playwright timeouts are in **milliseconds**; do not copy those values between APIs.
## Using the SDK
The [Python](/sdk/python) and [TypeScript](/sdk/typescript) browser APIs wrap these commands. Use them when you want browser actions without managing CDP sessions yourself.
# Intro
Source: https://docs.surfsky.io/index
Surfsky is a cloud browser API for antidetect Chrome sessions.
Surfsky is a cloud browser API. Each session is an antidetect Chrome running on Surfsky's servers, with a real device fingerprint, a proxy, CAPTCHA solving, and human mouse and keyboard emulation.
Start a browser with an API request, connect Playwright, Puppeteer, Selenium, or a Surfsky SDK to it, and stop it when the run is done. Surfsky hosts the browsers and the proxies.
## Set up with a coding agent
Give this prompt to Claude Code, Codex, Cursor, or another agent that can run commands in your project:
```text theme={null}
Read https://surfsky.io/SKILL.md and set up Surfsky in this project.
```
The [skill file](https://surfsky.io/SKILL.md) tells the agent how to install the integration that fits your project and check it with one browser session.
## Start a browser
Choose the guide that fits your project. Each quickstart includes setup, an example, and session cleanup.
Get your credentials and run your first browser session.
Use our SDKs to manage browser sessions and automate web pages.
Connect your Playwright scripts to a cloud browser.
Interact with a cloud browser through HTTP requests.
Other integrations: [Puppeteer](/quickstart/puppeteer), [Selenium](/quickstart/selenium), [Scrapy](/quickstart/scrapy), and [chromedp](/quickstart/chromedp).
## Configure your browser
| Task | Guide |
| - | - |
| Reuse a login across browser sessions | [Sessions](/sessions) and [Cookies](/cookies) |
| Choose an exit IP or use your own proxy | [Proxies](/proxies) |
| Configure the browser fingerprint | [Fingerprints](/fingerprints) |
| Emulate human interactions | [Human emulation](/human_emulation) |
| Handle a CAPTCHA challenge | [CAPTCHA solving](/captcha-solving) |
| Load a Chrome extension | [Extensions](/extensions) |
## Inspect and scale
Use [DevTools](/debugging) to inspect a page or [Screencast](/screencast) to watch a session. If something fails, start with [Troubleshooting](/troubleshooting) or look up the API's [error code](/errors).
For larger workloads, see [Concurrency](/concurrency), [Speed optimization](/speed-optimization), [Shared cache](/shared-cache), and [Limits](/limits).
The [API reference](/api-reference) covers authentication, request parameters, and responses. [How Surfsky works](/intro-to-surfsky) explains profiles, sessions, and the connection to your code.
# How it works
Source: https://docs.surfsky.io/intro-to-surfsky
How Surfsky runs isolated cloud browsers, routes their traffic, builds their fingerprints, and connects them to your code.
Surfsky runs antidetect Chrome in its own cloud. Each session has a browser process in an isolated container with a private filesystem and network stack. Your automation client connects over the Chrome DevTools Protocol (CDP), ChromeDriver, or HTTP.
## Starting a browser
A start request passes through these stages:
1. **Validation and limits.** Surfsky checks the request body, concurrency limit, and quota.
2. **Container.** Surfsky allocates a fresh container for the session. For a persistent profile, it also starts downloading saved data.
3. **Network.** Surfsky connects your proxy or VPN, or a proxy from a Surfsky pool, inside the container's network namespace. It checks the exit IP through that connection and looks up its country, region, city, timezone, and coordinates.
4. **Fingerprint.** Surfsky selects a device from fingerprints collected on real machines, matching the OS and any hardware fields you set. Location-dependent fields such as timezone and language are derived from the exit IP unless you set them. A persistent profile reuses the device chosen at creation. Surfsky signs the configuration and sends it to the browser.
5. **Browser.** Chrome starts inside the namespace, reads the configuration at engine level, and restores profile data.
The response includes `ws_url` for CDP and `internal_uuid` for HTTP calls, including stopping the session. The inactivity timer starts here; see [Sessions](/sessions#how-a-session-ends). If a stage fails, the [error code](/errors#where-the-failure-came-from) points back to it.
## Connect to the browser
* **CDP over WebSocket.** Connect to `ws_url` from your [automation framework](/quickstart#connect-your-existing-code) or an [SDK](/sdk). The connection goes through a Surfsky layer that adds [human emulation](/human_emulation), [CAPTCHA](/captcha-solving), and [screencast](/screencast) commands on top of standard CDP.
* **ChromeDriver.** [Selenium](/quickstart/selenium) connects over the WebDriver protocol to a ChromeDriver started next to the browser.
* **HTTP.** The [Scraping API](/quickstart/scraping_api) loads a page and returns its content without a persistent connection.
Every CDP command, WebDriver request, or scrape call resets the inactivity timer. An open but idle socket does not. [Stop the session](/quickstart#3-stop-it) when you are done.
## Fingerprint
A fingerprint consists of browser and device properties exposed to a page, including the user agent, Client Hints, OS, hardware, screen, fonts, media devices, location, and WebRTC settings. Surfsky generates a signed configuration on the server. The browser engine applies it at startup without an injected script or extension.
### Consistent fingerprint
Fingerprint fields are selected as a compatible set. The OS determines the available GPU presets, screen sizes, and fonts. Each GPU preset includes WebGL parameters recorded on a real machine. CPU and memory values follow supported hardware combinations: an Apple M-series renderer determines its core count and memory options, and a Windows core count determines its memory options. The generated user agent and Client Hints match the browser build. Random selection is weighted toward common hardware.
You can override fields in the [fingerprint configuration](/fingerprints). Setting one field does not adjust the others, so keep overrides consistent.
### Fields that follow the IP
When unset, `timezone`, `languages`, and `geolocation` are derived from the exit IP at each start. Explicit values remain fixed when the proxy changes. For example, changing a persistent profile's proxy country updates its generated timezone but preserves an explicit timezone override.
### What stays fixed
A persistent profile's device identity (OS, hardware, GPU, screen, user agent, fonts, etc.) is generated once at creation and reused on every start. Change it with `PATCH /profiles/{profile_uuid}` while the profile is stopped. Changing hardware fields changes the device a site sees, even when cookies are restored.
### Canvas and WebGL
For the fingerprinting scripts used by Akamai, ThreatMetrix, Cloudflare, Google, and similar services, the browser returns a canvas image recorded on a real machine with the profile's GPU. The same profile returns the same image in every session. WebGPU adapter information describes the same GPU. No configuration is needed.
### Noise
`noise` can perturb `webgl`, `canvas`, `audio`, and `client_rects` outputs. It is off by default on desktop profiles, and each profile's perturbation is constant across sessions. Leave it off unless a site links your profiles by these hashes. Android profiles use their own preset.
### Headless and screen
The browser runs headless on the server and reports screen dimensions from the fingerprint's `screen` setting. Window sizes, pixel ratio, available screen area, and orientation are consistent with that setting. Android profiles report touch input, and human emulation uses touch gestures for them.
### Beyond the fingerprint
Detection scripts also check browser behavior and consistency across execution contexts. Surfsky handles these checks in the engine:
* **Protocol traces.** Surfsky suppresses observable effects of CDP automation, including runtime-domain activation, console serialization hooks, and automation stack frames.
* **Cross-realm consistency.** The engine reports consistent device properties in the main window, iframes, and workers.
* **Network stack.** The browser uses Chrome's TLS and HTTP/2 implementations, so their fingerprints match the browser build.
* **Anti-debugging.** Detection scripts can use `debugger` statements and execution timing to check for an attached inspector. Surfsky preserves the behavior of a browser without an inspector attached.
* **Port scanning.** Pages probe localhost for the control ports of automation tools and other antidetect browsers.
* **Feature parity.** The browser exposes DRM, a WebAuthn platform authenticator, Bluetooth, and speech voices consistent with the configured device.
## Network
### Full tunnel with kill switch
The browser runs in a network namespace that routes outbound traffic through your proxy or VPN. A firewall blocks traffic outside the tunnel, including DNS lookups. If the proxy disconnects, requests fail rather than falling back to the datacenter connection.
### UDP, QUIC, and WebRTC
UDP support depends on the connection type:
| Connection | TCP | UDP (HTTP/3, WebRTC) |
| - | - | - |
| SOCKS5 with UDP support | Through the proxy | Through the proxy |
| SOCKS5 without UDP, HTTP, HTTPS | Through the proxy | Blocked. Chrome falls back to HTTP/2. |
| OpenVPN, WireGuard | Through the tunnel | Through the tunnel |
WebRTC uses the proxy when it supports UDP. Otherwise, the browser reports the proxy's IP in WebRTC responses. This behavior is configured automatically.
### Blocking and routing
Use these settings to block or route requests:
* **Block domains** with `proxy_blacklist`. Requests to blocked domains do not reach the proxy or consume metered traffic. Blocking happens at the network layer, and the page receives a connection failure.
* **Route domains** to a different proxy with `domain_routes`. For example, send static assets through a datacenter proxy while keeping the target site on a residential connection. The first matching rule applies; unmatched traffic uses the main proxy.
See [Proxies](/proxies) for patterns and rule syntax.
## Profiles and sessions
A **profile** holds the fingerprint, network settings, and saved browser data. A **session** is a running browser using that profile.
**One-time profiles** suit tasks that do not need saved state, such as a single scrape or form submission. One request creates and starts the browser. Its container and profile data are discarded when the session stops.
**Persistent profiles** preserve a device identity and selected browser state between runs. Use them for account automation or to reuse valid login and challenge cookies. Create the profile once; each start restores its fingerprint and saved storage.
When a persistent session stops, the browser closes cleanly and the selected storage is packed, encrypted with AES (AES-128-CBC with HMAC-SHA256, key derived per profile), and uploaded to S3 as a new profile version. Profile data is never stored unencrypted at rest.
See [Sessions](/sessions) for storage options and lifecycle details.
Start your first browser with the [Quickstart](/quickstart).
# Limits
Source: https://docs.surfsky.io/limits
Check API rate limits, browser capacity, and proxy traffic.
A `429` response can mean several different things. Read its `code` before deciding whether to wait, stop a browser, or check your account quota.
| Limit | Error code | What restores capacity |
| - | - | - |
| API requests | `rate_limits_reached` | Requests age out of the minute or hour window. |
| Running browsers | `parallel_browsers_limit_reached` | A browser stops. |
| Shared proxy traffic | `shared_traffic_limit_reached` | Traffic allowance resets or changes. |
| Premium proxy traffic | `premium_traffic_limit_reached` | Traffic allowance is replenished or changes. |
| Site reports | `site_report_limit_reached` | A minute passes, up to 20 reports per day. |
## Requests per minute and per hour
Profile API calls are subject to minute and hour limits. Responses report the allowance and remaining requests:
```text theme={null}
X-Ratelimit-Limit: 60
X-Ratelimit-Limit-Hour: 1000
X-Ratelimit-Remaining: 58
X-Ratelimit-Remaining-Hour: 921
```
These numbers are examples; read the headers for your account. Either window can block a request. Waiting one minute is not enough when the hour window is exhausted.
Use bounded backoff with jitter and reduce the request rate after `rate_limits_reached`. Avoid frequent polling of account or session endpoints when an application record can answer the same question.
## Parallel browsers
Running browsers count against the slot limit even if the worker that started them has crashed. `GET /users/browser-limits` reports the current count and, on capped accounts, the available slots; see [concurrency](/concurrency#check-available-capacity) for the response and how to use it. Availability can change immediately, so your application must still handle a rejected start.
## Traffic
Shared and premium proxy traffic have separate quota endpoints:
```bash theme={null}
curl "$SURFSKY_API_BASE_URL/proxies/shared/quota" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
curl "$SURFSKY_API_BASE_URL/proxies/premium/quota" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Quota availability and response fields depend on your plan. In particular, `premium_quota_not_applicable` means the plan has no premium quota to report.
Using another tier requires that tier to be enabled for your account. A different proxy may also change the site's behavior or invalidate a login tied to an IP. Choose it deliberately. See [proxy selection](/proxies#choosing-the-tier).
Reduce unnecessary downloads with [request blocking](/proxies#proxy-blacklist) or [shared cache](/shared-cache), and measure the effect on completed jobs.
## Stored objects
| Object | Limit |
| - | - |
| Persistent profiles | Plan-dependent; `profiles_limit_reached` when full. |
| Uploaded extensions | 100 per account. |
| Extensions loaded in one browser | 5. |
Deleting a profile also deletes its saved state. Export anything you need before deleting it.
## Check your plan
```bash theme={null}
curl "$SURFSKY_API_BASE_URL/users/plan" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Use the dashboard and account endpoints for current allowances. See [errors](/errors) for response codes and [troubleshooting](/troubleshooting) for failed jobs.
# Proxies
Source: https://docs.surfsky.io/proxies
Choose a Surfsky proxy pool or your own connection, target a location, and route or block domains.
Every Surfsky browser needs an outbound connection. By default it uses a Surfsky proxy pool: premium when the account has it, shared otherwise. The premium pool has over 100 million residential and mobile IPs, with targeting by country, region, city, ASN, or coordinates.
You can also supply an HTTP, HTTPS, SOCKS5, SSH, Shadowsocks, OpenVPN, or WireGuard connection. See [using your own proxies](#using-your-own-proxies).
## Quick start
This starts a one-time browser using your account's default available pool:
```bash theme={null}
curl -X POST "$SURFSKY_API_BASE_URL/profiles/one_time" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
Set your [credentials](/quickstart#credentials) first. If no pool is available, supply a proxy URL:
```json theme={null}
{"proxy": "socks5://user:pass@proxy.example.com:1080"}
```
Use the returned `internal_uuid` to [stop the session](/sessions#stop-a-session) when the job finishes.
## Choosing the tier
| Selection | Behavior |
| - | - |
| `"tier": "shared"` | Use shared proxies if enabled for the account. Only country targeting is supported. Recommended for testing only. |
| `"tier": "premium"` | Use premium proxies if enabled and configured. Supports the targeting and session options below. |
| Omit `tier` | Prefer premium when it is enabled and usable; otherwise select shared if available. |
| Supply a proxy URL | Use that connection instead of selecting a Surfsky pool. |
An explicitly requested tier is not replaced with the other tier. Specify `tier` when the choice matters to your workload.
```json theme={null}
{"proxy": {"tier": "shared", "country": "us"}}
```
```json theme={null}
{"proxy": {"tier": "premium", "country": "us", "region": "texas"}}
```
Omitting `proxy` on a persistent profile's start keeps its stored proxy. An explicit start override applies to that run; it does not update the saved profile. See [changing a profile's proxy](/sessions#change-the-proxy).
`timezone`, `languages`, and `geolocation` follow the exit IP unless the profile pins them. See [fields that follow the IP](/fingerprints#matching-the-proxy).
## Shared proxies
Shared proxies support an optional two-letter `country`. Use [List shared countries](/api-reference/proxies/list-shared-countries) to see current availability.
If the requested country has no healthy shared proxy, the API returns `shared_pool_country_unavailable`. Select another country or omit country targeting if any location is acceptable.
Do not send `region`, `city`, `asn`, `pool`, `type`, coordinates, or session options with `tier: "shared"`; those combinations are rejected.
Shared IPs are also used by other sessions, so the shared tier is recommended for testing only. Run production on premium or your own proxy. Check the account's allowance through [shared quota](/api-reference/proxies/get-shared-quota) and usage through [shared traffic stats](/api-reference/proxies/get-shared-traffic-stats).
## Surfsky proxies
Use `tier: "premium"` to request premium targeting. Access depends on your account configuration; check the dashboard or [contact support](mailto:hello@surfsky.io) if it is unavailable.
Set a location that fits your job. A request without targeting draws peers worldwide, so the exit
IP can sit far from the target site and add latency to every page load.
### Location targeting
Choose one location mode:
| Mode | Fields | Rules |
| - | - | - |
| Country and locality | `country`, `region`, `city`, `asn` | `region` requires `country`; `city` requires `region`; `asn` requires `country`. |
| Geographic pool | `pool` | A region such as `europe`; cannot be combined with country, locality, ASN, or coordinates. |
| Coordinates | `lat`, `lon` | Supply both. Cannot be combined with the other location modes. |
`pool` selects a geographic region, not a pricing tier. Use `tier` for `shared` or `premium`.
```json theme={null}
{"proxy": {"tier": "premium", "country": "us", "region": "texas", "city": "dallas"}}
```
```json theme={null}
{"proxy": {"tier": "premium", "pool": "europe"}}
```
```json theme={null}
{"proxy": {"tier": "premium", "lat": 48.8584, "lon": 2.2945}}
```
Use the lookup endpoints instead of guessing provider-specific names:
| Lookup | Endpoint |
| - | - |
| Countries | [GET /proxies/countries](/api-reference/proxies/list-premium-countries) |
| Regions | [GET /proxies/regions/\{country}](/api-reference/proxies/list-premium-regions) |
| Cities | [GET /proxies/cities/\{country}/\{region}](/api-reference/proxies/list-premium-cities) |
| ASNs | [GET /proxies/asns/\{country}](/api-reference/proxies/list-premium-asns) |
| Geographic pools | [GET /proxies/pools](/api-reference/proxies/list-proxy-pools) |
Set `type` to `residential` or `mobile` when your job needs a particular connection type. Requests without `type` use residential. More restrictive targeting can reduce the number of available peers.
### Session control
| Field | Meaning |
| - | - |
| `session_minutes` | Requested sticky session duration, in minutes. |
| `unique_ip` | Request an IP that is not assigned to another of your proxy sessions. |
| `keep_asn` | Request that replacement peers remain on the same ASN. |
| `keep_ip` | Fail instead of changing to another IP when the current peer is lost. |
A sticky duration cannot keep an offline peer connected. Use `keep_ip` when changing IP is less acceptable than failing the job.
```json theme={null}
{
"proxy": {
"tier": "premium",
"country": "us",
"session_minutes": 60,
"keep_ip": true
}
}
```
Check [premium quota](/api-reference/proxies/get-premium-quota) and [traffic stats](/api-reference/proxies/get-premium-traffic-stats) for your account. Current pricing and allowances are shown in the dashboard.
## Using your own proxies
Send a proxy URL in `proxy`, or a VPN configuration in its own field:
| Connection | Request field | Format |
| - | - | - |
| HTTP | `proxy` | `http://username:password@host:port` |
| HTTPS | `proxy` | `https://username:password@host:port` |
| SOCKS5 | `proxy` | `socks5://username:password@host:port` |
| SSH | `proxy` | `ssh://username:password@host:port` |
| Shadowsocks | `proxy` | `ss://method:password@host:port` |
| OpenVPN | `open_vpn` | [Configuration object](#openvpn-configuration) |
| WireGuard | `wireguard` | [Configuration object](#wireguard-configuration) |
URL-encode reserved characters in credentials, or omit credentials if the proxy does not require them. Shadowsocks takes your provider's encryption method in place of the username. SSH requires a password; key files are not supported.
Surfsky treats an `https://` URL as an HTTP proxy. Browser traffic is tunneled through it, but the connection to the proxy itself is not TLS-wrapped.
UDP is carried end to end on a SOCKS5 proxy that supports UDP ASSOCIATE, and on OpenVPN and
WireGuard. HTTP/3 and QUIC, WebRTC, and UDP DNS all travel inside the tunnel. Surfsky probes the
proxy at start and uses what it supports.
**DNS and UDP.** By default the proxy resolves domain names, so DNS does not leave the tunnel. HTTP, HTTPS, SSH, Shadowsocks, and SOCKS5 proxies without UDP support carry TCP only. The browser's UDP traffic is rejected on these connections, and QUIC and HTTP/3 fall back to TCP. A custom `fingerprint.dns` resolver is queried outside the tunnel on such a connection, so pair one with a UDP-capable SOCKS5 proxy.
### OpenVPN configuration
Use `open_vpn` with the complete configuration file contents as a JSON string. Add `username` and `password` when your VPN requires them:
```json theme={null}
{
"open_vpn": {
"config": "CONTENTS_OF_YOUR_OVPN_FILE",
"username": "vpn_user",
"password": "vpn_password"
}
}
```
The placeholder must be replaced with a real configuration, including any required certificates.
### WireGuard configuration
```json theme={null}
{
"wireguard": {
"private_key": "BASE64_PRIVATE_KEY",
"address": ["10.0.0.2/32"],
"peers": [{
"public_key": "BASE64_PEER_PUBLIC_KEY",
"endpoint": "vpn.example.com:51820",
"allowed_ips": ["0.0.0.0/0", "::/0"]
}]
}
}
```
Supply keys and addresses from your WireGuard configuration. Optional fields include interface `mtu` and peer `pre_shared_key` and `persistent_keepalive_interval`. See the [start request schema](/api-reference/profiles/start-one-time-session) for their types and defaults.
Use only one of `proxy`, `open_vpn`, or `wireguard` in a request.
## Proxy blacklist
Block unneeded ad, analytics, or tracking domains to reduce downloads. Requests to blocked domains do not reach the proxy or consume metered proxy traffic.
Block domains with `proxy_blacklist` in the start request:
```json theme={null}
{
"proxy_blacklist": ["*.doubleclick.net", "analytics.google.com", "*.facebook.net"]
}
```
Use `*` for a wildcard. Include the root domain separately if your rule also needs to block it. Start with domains you know are unnecessary and compare the page before and after blocking. Blocking a login provider, challenge script, or API host can prevent the page from working.
An entry that parses as an IP address or CIDR range is not blocked. Those addresses are excluded from the tunnel and connect directly from the container, outside your proxy. Use domain patterns to block. The list applies to proxy and WireGuard sessions; an OpenVPN session ignores it.
For resource types such as images or fonts, use [SDK resource blocking](/speed-optimization#block-unneeded-resources).
## Domain routing
Domain routing can reduce residential or mobile proxy costs by sending static assets through a cheaper datacenter proxy. Keep the target site on the main connection and test that asset hosts accept the alternate proxy. You can also use routing when a particular host needs a different exit location.
Send selected domains through a different proxy while the rest use the browser's main connection:
```json theme={null}
{
"proxy": "socks5://user:pass@default-proxy.example.com:1080",
"domain_routes": [{
"proxy": "http://user:pass@regional-proxy.example.com:8080",
"domain": ["example.com"],
"domain_suffix": [".example.com"]
}]
}
```
Each rule requires `proxy` and at least one matching condition:
| Field | Matches |
| - | - |
| `domain` | An exact domain. |
| `domain_suffix` | A domain suffix; `.example.com` selects subdomains. |
| `domain_keyword` | A substring in the domain. |
| `domain_regex` | A regular expression matched against the domain. |
Rules are evaluated in order; the first matching rule supplies the route. Unmatched traffic uses the main connection. Keep rules narrow so an unrelated service does not receive an unintended IP or location. Domain routes apply to `proxy` sessions; a session started with `open_vpn` or `wireguard` ignores them.
## Proxy errors
| Code | What to check |
| - | - |
| `proxy_required` or `proxy_pool_unavailable` | Supply your own connection or enable a pool. |
| `premium_pool_unavailable` / `shared_pool_unavailable` | The explicitly selected tier must be available to the account. |
| `shared_pool_country_unavailable` | Check the current shared country list. |
| `premium_traffic_limit_reached` / `shared_traffic_limit_reached` | Read the corresponding quota endpoint. |
| `bad_proxy` | Check the proxy URL, credentials, and provider availability. |
| `validation_error` | Inspect `data` for incompatible or missing targeting fields. |
See [errors](/errors) for retry guidance. If the browser starts but the site rejects the connection, use [troubleshooting](/troubleshooting#the-site-is-blocking-you) to compare one variable at a time.
# Quickstart
Source: https://docs.surfsky.io/quickstart
Open your first page in a cloud browser and stop the session.
The quickest way to try Surfsky is to open a single page in a cloud browser. Do it with a coding agent, a Surfsky SDK, your existing automation framework, or the API directly.
## Before you start
In the [Surfsky dashboard](https://app.surfsky.io):
1. Copy your API token.
2. Pick the region closest to the machine that runs your code and copy the API base URL shown for it.
Replace `YOUR_API_TOKEN` and `https://YOUR_API_HOST` below with those values.
```bash theme={null}
export SURFSKY_API_TOKEN="YOUR_API_TOKEN"
export SURFSKY_API_BASE_URL="https://YOUR_API_HOST"
```
## Set up with a coding agent
Give this prompt to Claude Code, Codex, Cursor, or another agent that can run commands in your project:
```text theme={null}
Read https://surfsky.io/SKILL.md and set up Surfsky in this project.
```
The [skill file](https://surfsky.io/SKILL.md) tells the agent how to install the integration that fits your project and check it with one browser session.
## SDK quickstart
The SDK is the recommended way to work with Surfsky. It controls the browser directly, without the extra scripts and bindings that Playwright, Puppeteer, and ChromeDriver inject into the page, and it does almost everything you need from an automation library.
Requires Python 3.12 or newer.
```bash pip theme={null}
pip install surfsky
```
```bash uv theme={null}
uv init # For a new project
uv add surfsky
```
Save as `first_browser.py`:
```python theme={null}
import asyncio
from surfsky import AsyncSurfsky
async def main():
# Or: AsyncSurfsky(api_token="YOUR_API_TOKEN", base_url="https://YOUR_API_HOST")
async with AsyncSurfsky() as client:
async with client.browser() as browser:
await browser.goto("https://www.google.com")
print(await browser.title())
asyncio.run(main())
```
Run it:
```bash Python theme={null}
python first_browser.py
```
```bash uv theme={null}
uv run first_browser.py
```
Requires Node.js 22+ or Bun.
```bash npm theme={null}
npm install surfsky
```
```bash Bun theme={null}
bun add surfsky
```
Save as `first-browser.mjs`:
```javascript theme={null}
import { Surfsky } from "surfsky";
// Or: new Surfsky({ apiToken: "YOUR_API_TOKEN", baseUrl: "https://YOUR_API_HOST" });
const client = new Surfsky();
const browser = await client.browser();
try {
await browser.goto("https://www.google.com");
console.log(await browser.title());
} finally {
await browser.close();
}
```
Run it:
```bash Node.js theme={null}
node first-browser.mjs
```
```bash Bun theme={null}
bun first-browser.mjs
```
Both examples use a one-time profile, so the session is destroyed when the script finishes, together with its cookies and anything else the browser stored. If you want to keep a login and reuse it on the next run, use a [persistent profile](/sessions#persistent-profiles) instead, as in the [Python](/sdk/python#sessions) and [TypeScript](/sdk/typescript#sessions) examples.
## Connect your existing code
Follow the guide for your framework:
* [Playwright](/quickstart/playwright)
* [Puppeteer](/quickstart/puppeteer)
* [Selenium](/quickstart/selenium)
* [chromedp](/quickstart/chromedp)
* [Scrapy](/quickstart/scrapy)
## Using the API directly
### 1. Start a browser
Start a browser on a one-time profile. It lives for a single session, and its state is discarded when the browser stops:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles/one_time" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
Create a persistent profile once and reuse it on every run:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "My profile", "fingerprint": {"os": "win"}, "storage_options": {"cookies": true, "localstorage": true}}' # os: win, mac, or android
```
Save `data.uuid` from the response and replace `PROFILE_UUID` below with it. Use this same UUID on every run; do not create a new profile each time.
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles/PROFILE_UUID/start" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
After each run, [stop the session](#3-stop-it) and wait for completion so Surfsky saves its state. On the next run, repeat only the start request above to restore it. See [Sessions](/sessions#reuse-a-login) for login reuse.
Response:
```json theme={null}
{
"success": true,
"internal_uuid": "a8fb62f90611456aa75422b01c385a62",
"ws_url": "wss://YOUR_API_HOST/proxy/a8fb62f90611456aa75422b01c385a62"
}
```
### 2. Connect
Pass the returned `ws_url` to your framework's CDP connection method. Use the existing browser context to work with the profile's state.
The default inactivity timeout is 30 seconds. To allow more time, set `inactive_kill_timeout` in the start request. For example, `"browser_settings": {"inactive_kill_timeout": 300}` allows 300 seconds of inactivity.
To interact with the browser through HTTP requests, use the [Scraping API](/quickstart/scraping_api) with the returned `internal_uuid`.
### 3. Stop it
A running browser occupies 1 browser slot until it stops.
You can stop the browser in 3 ways:
* Call a method that closes the remote browser, such as Puppeteer's `browser.close()`.
* Leave it idle until `inactive_kill_timeout` expires.
* Send the stop request below.
Replace `INTERNAL_UUID` with the value from your start response:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Playwright's `browser.close()` over CDP only disconnects. Use the stop request or
[close it through CDP](/quickstart/playwright#stop-the-session)
to end the session immediately.
## If the example fails
| Error | Check |
| - | - |
| `not_authorized` | The token is set and has no leading or trailing whitespace. |
| `namespace_not_allowed` | The base URL matches the one assigned to your account. |
| `region_not_chosen` | A region is selected in the dashboard. |
| `proxy_required` or `proxy_pool_unavailable` | Your account has a proxy pool, or the start request supplies your own proxy. |
| `parallel_browsers_limit_reached` | An existing browser must stop before another can start. |
See [Errors](/errors) for other API failures. Continue with [Sessions](/sessions) to reuse browser state, or the [Python](/sdk/python) and [TypeScript](/sdk/typescript) SDK guides for browser actions and pooling.
# Chromedp
Source: https://docs.surfsky.io/quickstart/chromedp
Run your chromedp code against a Surfsky cloud browser.
chromedp connects to a Surfsky browser through a remote allocator instead of launching one itself. The browser runs in Surfsky's cloud, so you do not need Chrome on your machine.
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
Use [human-emulation commands](/human_emulation) for clicks, typing, and scrolling.
Keep chromedp for navigation, selectors, waiting, and page content.
## Prerequisites
Go 1.26 or newer for the current chromedp release. Set your [API token and base URL](/quickstart#before-you-start), then create a project:
```bash theme={null}
mkdir surfsky-chromedp && cd surfsky-chromedp
go mod init example.com/surfsky-chromedp
go get github.com/chromedp/chromedp
```
The example starts a browser with an empty request body, which uses your account's default proxy pool. Proxy, fingerprint, and other start options are in the [API reference](/api-reference/profiles/start-one-time-session).
## Run an example
The example uses a one-time profile. To reuse a saved login, start a [persistent profile](/sessions#persistent-profiles) instead; the connection code is the same.
Save as `main.go`:
```go theme={null}
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/chromedp/chromedp"
)
func main() {
baseURL := strings.TrimRight(os.Getenv("SURFSKY_API_BASE_URL"), "/")
req, _ := http.NewRequest(http.MethodPost, baseURL+"/profiles/one_time", strings.NewReader("{}"))
req.Header.Set("X-Cloud-Api-Token", os.Getenv("SURFSKY_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 120 * time.Second}).Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("start: HTTP %d", resp.StatusCode)
}
var session struct {
InternalUUID string `json:"internal_uuid"`
WSURL string `json:"ws_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
log.Fatal(err)
}
defer stopSession(baseURL, session.InternalUUID)
allocator, cancelAllocator := chromedp.NewRemoteAllocator(context.Background(), session.WSURL, chromedp.NoModifyURL)
defer cancelAllocator()
ctx, cancel := chromedp.NewContext(allocator)
defer cancel()
var title string
if err := chromedp.Run(ctx,
chromedp.Navigate("https://example.com"),
chromedp.Title(&title),
); err != nil {
log.Println(err)
return
}
fmt.Println(title)
}
func stopSession(baseURL, internalUUID string) {
req, _ := http.NewRequest(http.MethodPost, baseURL+"/profiles/"+internalUUID+"/stop", nil)
req.Header.Set("X-Cloud-Api-Token", os.Getenv("SURFSKY_API_TOKEN"))
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
log.Println("stop:", err)
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("stop: HTTP %d", resp.StatusCode)
}
}
```
Run it:
```bash theme={null}
go run .
```
Expected output:
```text theme={null}
Example Domain
```
`chromedp.NoModifyURL` keeps Surfsky's WebSocket address as returned. Without it chromedp rewrites the URL and the connection fails.
## Click, type, and scroll
Prefer [human emulation](/human_emulation) over `chromedp.Click`, `chromedp.SendKeys`, and scripted scrolling. Add `"github.com/chromedp/cdproto/cdp"` to the imports and this action to the existing `chromedp.Run` call, with a selector from your own page:
```go theme={null}
chromedp.ActionFunc(func(ctx context.Context) error {
if err := cdp.Execute(ctx, "Human.click", map[string]any{
"selector": "input[name='email']",
}, nil); err != nil {
return err
}
if err := cdp.Execute(ctx, "Human.type", map[string]any{
"text": "reader@example.com",
}, nil); err != nil {
return err
}
return cdp.Execute(ctx, "Human.scroll", map[string]any{
"deltaY": 600, "duration": 1.2,
}, nil)
}),
```
## Stop the session
Stop the session through the API with the `internal_uuid` from the start response, as the deferred `stopSession` call does above. `chromedp.Cancel(ctx)` and the `cancel` functions only disconnect. chromedp sends `Browser.close` only for browsers it launched itself, so the remote browser keeps running until you stop it or the inactivity timeout expires.
Register the deferred stop call before connecting so it also runs after a connection failure. Avoid `log.Fatal` after starting the session because it skips deferred calls.
The same request from the shell:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise the browser stops after the [inactivity timeout](/sessions#how-a-session-ends).
# Playwright
Source: https://docs.surfsky.io/quickstart/playwright
Run your Playwright scripts against a Surfsky cloud browser.
Playwright connects to a Surfsky browser over the session's CDP WebSocket URL instead of launching one itself. The browser runs in Surfsky's cloud, so you do not need Chromium on your machine.
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
Playwright has known leaks that anti-bot scripts can detect. On sites with bot
protection, use a patched fork such as Patchright or rebrowser-playwright.
Use [human-emulation commands](/human_emulation) for clicks, typing, and scrolling.
Keep Playwright for navigation, selectors, waiting, and page content.
## Prerequisites
Set your [API token and base URL](/quickstart#before-you-start). The examples use your account's default proxy pool. Proxy, fingerprint, and other start options are in the [API reference](/api-reference/profiles/start-one-time-session).
## Run an example
The examples use a one-time profile. To reuse a saved login, pass a [persistent profile](/sessions#persistent-profiles) UUID to the session call instead.
Requires Python 3.12 or newer.
```bash theme={null}
pip install surfsky playwright
```
Save as `surfsky_playwright.py`:
```python theme={null}
import asyncio
from playwright.async_api import async_playwright
from surfsky import AsyncSurfsky
async def main():
async with (
AsyncSurfsky() as client,
client.session() as session,
async_playwright() as pw,
):
browser = await pw.chromium.connect_over_cdp(session.connect_url)
context = browser.contexts[0]
page = context.pages[0] if context.pages else await context.new_page()
await page.goto("https://example.com")
print(await page.title())
asyncio.run(main())
```
Run it:
```bash theme={null}
python surfsky_playwright.py
```
Requires Node.js 24 or newer, or Bun, for `await using`.
```bash theme={null}
npm install surfsky playwright-core
```
Save as `surfsky-playwright.mjs`:
```javascript theme={null}
import { chromium } from "playwright-core";
import { Surfsky } from "surfsky";
const client = new Surfsky();
await using session = await client.session();
const browser = await chromium.connectOverCDP(session.ws_url);
try {
const context = browser.contexts()[0];
const page = context.pages()[0] ?? (await context.newPage());
await page.goto("https://example.com");
console.log(await page.title());
} finally {
await browser.close();
}
```
Run it:
```bash theme={null}
node surfsky-playwright.mjs
```
Add Playwright to your project using the [official instructions](https://playwright.dev/java/docs/intro). Start a browser with the [REST quickstart](/quickstart#using-the-api-directly) and export the returned `ws_url` as `SURFSKY_WS_URL`.
```java theme={null}
import com.microsoft.playwright.*;
public class SurfskyExample {
public static void main(String[] args) {
String wsUrl = System.getenv("SURFSKY_WS_URL");
try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().connectOverCDP(wsUrl);
BrowserContext context = browser.contexts().get(0);
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
page.navigate("https://example.com");
System.out.println(page.title());
browser.newBrowserCDPSession().send("Browser.close");
}
}
}
```
```bash theme={null}
dotnet add package Microsoft.Playwright
```
Start a browser with the [REST quickstart](/quickstart#using-the-api-directly) and export the returned `ws_url` as `SURFSKY_WS_URL`. Put this in `Program.cs`:
```csharp theme={null}
using Microsoft.Playwright;
var wsUrl = Environment.GetEnvironmentVariable("SURFSKY_WS_URL");
using var playwright = await Playwright.CreateAsync();
var browser = await playwright.Chromium.ConnectOverCDPAsync(wsUrl);
var context = browser.Contexts[0];
var page = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
await page.GotoAsync("https://example.com");
Console.WriteLine(await page.TitleAsync());
var browserCdp = await browser.NewBrowserCDPSessionAsync();
await browserCdp.SendAsync("Browser.close");
```
Run with `dotnet run`.
Expected output:
```text theme={null}
Example Domain
```
## Click, type, and scroll
Prefer [human emulation](/human_emulation) over Playwright's own click, fill, and scroll. `Human.*` commands move the cursor and type with human timing, and they run through a page-level CDP session. Replace the selector with an input on your page:
```python Python (async) theme={null}
cdp = await page.context.new_cdp_session(page)
await cdp.send("Human.click", {"selector": "input[name='email']"})
await cdp.send("Human.type", {"text": "reader@example.com"})
await cdp.send("Human.scroll", {"deltaY": 600, "duration": 1.2})
```
```javascript JavaScript theme={null}
const cdp = await page.context().newCDPSession(page);
await cdp.send("Human.click", { selector: "input[name='email']" });
await cdp.send("Human.type", { text: "reader@example.com" });
await cdp.send("Human.scroll", { deltaY: 600, duration: 1.2 });
```
```java Java theme={null}
CDPSession cdp = page.context().newCDPSession(page);
cdp.send("Human.click", com.google.gson.JsonParser.parseString(
"{\"selector\":\"input[name='email']\"}").getAsJsonObject());
cdp.send("Human.type", com.google.gson.JsonParser.parseString(
"{\"text\":\"reader@example.com\"}").getAsJsonObject());
cdp.send("Human.scroll", com.google.gson.JsonParser.parseString(
"{\"deltaY\":600,\"duration\":1.2}").getAsJsonObject());
```
```csharp .NET theme={null}
var cdp = await page.Context.NewCDPSessionAsync(page);
await cdp.SendAsync("Human.click", new() { ["selector"] = "input[name='email']" });
await cdp.SendAsync("Human.type", new() { ["text"] = "reader@example.com" });
await cdp.SendAsync("Human.scroll", new() { ["deltaY"] = 600, ["duration"] = 1.2 });
```
## Stop the session
Playwright's `browser.close()` only disconnects from a browser attached over CDP. Without the SDK, stop it through CDP as the Java and .NET examples do. In async Python:
```python theme={null}
cdp = await browser.new_browser_cdp_session()
await cdp.send("Browser.close")
```
or through the API with the `internal_uuid` from the start response:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise the browser stops after the [inactivity timeout](/sessions#how-a-session-ends).
# Puppeteer
Source: https://docs.surfsky.io/quickstart/puppeteer
Run your Puppeteer scripts against a Surfsky cloud browser.
Puppeteer connects to a Surfsky browser over the session's WebSocket URL instead of launching one itself. The browser runs in Surfsky's cloud, so you do not need Chrome on your machine.
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
Puppeteer has known leaks that anti-bot scripts can detect. On sites with bot
protection, use a patched fork such as rebrowser-puppeteer.
Use [human-emulation commands](/human_emulation) for clicks, typing, and scrolling.
Keep Puppeteer for navigation, selectors, waiting, and page content.
## Prerequisites
Set your [API token and base URL](/quickstart#before-you-start), then install the package for your language below.
Puppeteer provides a JavaScript/TypeScript API. The Java and .NET examples use the community ports [Jvppeteer](https://github.com/fanyong920/jvppeteer) and [Puppeteer Sharp](https://www.puppeteersharp.com/). For Python, use [async Playwright](/quickstart/playwright): the Python port [Pyppeteer is unmaintained](https://github.com/pyppeteer/pyppeteer#readme).
The examples use your account's default proxy pool. Proxy, fingerprint, and other start options are in the [API reference](/api-reference/profiles/start-one-time-session).
## Run an example
The examples use a one-time profile. To reuse a saved login, pass a [persistent profile](/sessions#persistent-profiles) UUID to the session call instead.
Requires Node.js 24 or newer, or Bun, for `await using`. Puppeteer's JavaScript API is asynchronous; the same calls work in TypeScript.
```bash theme={null}
npm install surfsky puppeteer-core
```
Save as `surfsky-puppeteer.mjs`:
```javascript theme={null}
import puppeteer from "puppeteer-core";
import { Surfsky } from "surfsky";
const client = new Surfsky();
await using session = await client.session();
const browser = await puppeteer.connect({
browserWSEndpoint: session.ws_url,
defaultViewport: null,
});
try {
const page = (await browser.pages())[0] ?? (await browser.newPage());
await page.goto("https://example.com");
console.log(await page.title());
} finally {
await browser.disconnect();
}
```
Run it:
```bash theme={null}
node surfsky-puppeteer.mjs
```
Requires Java 11 or newer. Add the dependency to your Maven project:
```xml theme={null}
io.github.fanyong920
jvppeteer
3.6.5
```
Start a browser with the [REST quickstart](/quickstart#using-the-api-directly) and export its `ws_url` as `SURFSKY_WS_URL`:
```java theme={null}
import com.ruiyun.jvppeteer.api.core.Browser;
import com.ruiyun.jvppeteer.api.core.Page;
import com.ruiyun.jvppeteer.cdp.core.Puppeteer;
import com.ruiyun.jvppeteer.cdp.entities.ConnectOptions;
import com.ruiyun.jvppeteer.cdp.entities.Protocol;
public class SurfskyExample {
public static void main(String[] args) throws Exception {
ConnectOptions options = new ConnectOptions();
options.setBrowserWSEndpoint(System.getenv("SURFSKY_WS_URL"));
options.setProtocol(Protocol.CDP);
options.setDefaultViewport(null);
Browser browser = Puppeteer.connect(options);
try {
var pages = browser.pages();
Page page = pages.isEmpty() ? browser.newPage() : pages.get(0);
page.goTo("https://example.com");
System.out.println(page.title());
} finally {
browser.close();
}
}
}
```
```bash theme={null}
dotnet add package PuppeteerSharp
```
Start a browser with the [REST quickstart](/quickstart#using-the-api-directly) and export its `ws_url` as `SURFSKY_WS_URL`. Put this in `Program.cs`:
```csharp theme={null}
using PuppeteerSharp;
var browser = await Puppeteer.ConnectAsync(new ConnectOptions
{
BrowserWSEndpoint = Environment.GetEnvironmentVariable("SURFSKY_WS_URL"),
DefaultViewport = null,
});
try
{
var pages = await browser.PagesAsync();
var page = pages.Length > 0 ? pages[0] : await browser.NewPageAsync();
await page.GoToAsync("https://example.com");
Console.WriteLine(await page.GetTitleAsync());
}
finally
{
await browser.CloseAsync();
}
```
Run with `dotnet run`.
Expected output:
```text theme={null}
Example Domain
```
`defaultViewport: null` keeps the window size from the profile's fingerprint. Without it Puppeteer forces its own 800x600 viewport.
## Click, type, and scroll
Prefer [human emulation](/human_emulation) over Puppeteer's own click, type, and scroll. `Human.*` commands move the cursor and type with human timing, and they run through a page-level CDP session. Replace the selector with an input on your page:
```javascript JavaScript theme={null}
const cdp = await page.createCDPSession();
await cdp.send("Human.click", { selector: "input[name='email']" });
await cdp.send("Human.type", { text: "reader@example.com" });
await cdp.send("Human.scroll", { deltaY: 600, duration: 1.2 });
```
```java Java (Jvppeteer) theme={null}
var cdp = page.createCDPSession();
cdp.send("Human.click", java.util.Map.of("selector", "input[name='email']"));
cdp.send("Human.type", java.util.Map.of("text", "reader@example.com"));
cdp.send("Human.scroll", java.util.Map.of("deltaY", 600, "duration", 1.2));
```
```csharp .NET (Puppeteer Sharp) theme={null}
var cdp = await page.CreateCDPSessionAsync();
await cdp.SendAsync("Human.click", new { selector = "input[name='email']" });
await cdp.SendAsync("Human.type", new { text = "reader@example.com" });
await cdp.SendAsync("Human.scroll", new { deltaY = 600, duration = 1.2 });
```
## Stop the session
In the JavaScript example, `browser.disconnect()` only drops the Puppeteer connection; the SDK stops the browser when the session scope exits, including on errors.
The Java and .NET examples close the remote browser in `finally`. If you start a session through the API and the framework cannot connect, stop it using the `internal_uuid` from the start response:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise the browser stops after the [inactivity timeout](/sessions#how-a-session-ends).
# Scraping API
Source: https://docs.surfsky.io/quickstart/scraping_api
Use a Surfsky browser with plain HTTP requests instead of an automation framework.
The Scraping API loads pages in a Surfsky browser over plain HTTP and returns the rendered HTML. It also waits for content, takes screenshots, and solves CAPTCHAs, with no framework connection to hold open.
Use it when you need HTML or a screenshot and do not want to hold a WebSocket connection. For forms, multiple tabs, or click sequences, use a [framework](/quickstart#choose-an-integration) or an [SDK](/sdk).
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
For clicks, typing, and scrolling, use [human-emulation commands](/human_emulation)
through the SDK or CDP. The Scraping API's `human_actions` option only performs
random browsing actions; it does not accept a custom sequence.
Scraping more than one page? Send the URLs as a
[batch to one browser](#scrape-several-pages) instead of starting a browser per page.
## Prerequisites
Set your [API token and base URL](/quickstart#before-you-start). The examples start a browser with an empty request body, which uses your account's default proxy pool. Proxy, fingerprint, and other start options are in the [API reference](/api-reference/profiles/start-one-time-session).
## Run an example
The examples use a one-time profile. To reuse a saved login, pass a [persistent profile](/sessions#persistent-profiles) UUID to the session call instead.
```bash theme={null}
INTERNAL_UUID=$(curl -s -X POST "$SURFSKY_API_BASE_URL/profiles/one_time" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" -d '{}' | jq -r .internal_uuid)
curl -s -X POST "$SURFSKY_API_BASE_URL/profiles/$INTERNAL_UUID/scrape" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "wait_for": "h1"}'
curl -s -X POST "$SURFSKY_API_BASE_URL/profiles/$INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Requires Python 3.12 or newer.
```bash theme={null}
pip install surfsky
```
Save as `scrape.py`:
```python theme={null}
import asyncio
from surfsky import AsyncSurfsky
async def main():
async with AsyncSurfsky() as client, client.session() as session:
result = await client.profiles.scrape(
session, "https://example.com", wait_for="h1"
)
print(result.status)
print(result.content)
asyncio.run(main())
```
Run it:
```bash theme={null}
python scrape.py
```
Requires Node.js 24 or newer, or Bun, for `await using`.
```bash theme={null}
npm install surfsky
```
Save as `scrape.mjs`:
```javascript theme={null}
import { Surfsky } from "surfsky";
const client = new Surfsky();
await using session = await client.session();
const result = await client.profiles.scrape(session, "https://example.com", {
wait_for: "h1",
});
console.log(result.status);
console.log(result.content);
```
Run it:
```bash theme={null}
node scrape.mjs
```
The SDK examples print `200` followed by HTML containing `Example Domain`. The curl example returns the JSON response described below.
## Click, type, and scroll
For a specific input sequence, use `client.browser()` instead of the `client.session()` and `profiles.scrape()` workflow. Its input methods call Surfsky's `Human.*` primitives. After creating a client, run this on your target form, replacing the URL and selector:
```python Python (inside an AsyncSurfsky context) theme={null}
async with client.browser() as browser:
await browser.goto("https://your-site.example/form")
await browser.click("input[name='email']")
await browser.type("input[name='email']", "reader@example.com")
await browser.scroll(delta_y=600, duration=1.2)
```
```javascript JavaScript theme={null}
await using browser = await client.browser();
await browser.goto("https://your-site.example/form");
await browser.click("input[name='email']");
await browser.type("input[name='email']", "reader@example.com");
await browser.scroll({ deltaY: 600, duration: 1.2 });
```
The SDK stops the browser when the block or scope exits. Use this as a separate workflow: the HTTP scraper takes over the page and closes extra tabs, so do not combine it with CDP actions in the same browser. See [Human emulation](/human_emulation) for the underlying commands.
## Request parameters
`POST /profiles/{internal_uuid}/scrape` accepts:
| Field | Default | Meaning |
| - | - | - |
| `url` | Required | Page to visit. |
| `wait_until` | `"domcontentloaded"` | Navigation condition: `commit`, `domcontentloaded`, `load`, or `networkidle`. |
| `wait_for` | Omitted | CSS or XPath selector to wait for after navigation. |
| `timeout` | `30000` | Navigation and selector timeout, in milliseconds. |
| `wait` | `0` | Extra delay after loading, in seconds, up to `60`. |
| `screenshot` | `false` | Include a Base64 PNG screenshot. |
| `auto_captcha_solve` | `false` | Solve CAPTCHAs during the scrape. Requires `anti_captcha` at browser start. |
| `human_actions` | `0` | Random browsing actions before returning, up to `3`. |
`wait_for` with a selector for the content you need is more reliable than `networkidle`, which can time out on pages with background traffic.
The gateway gives each `/scrape` call 120 seconds in total, covering every page in a batch. `timeout` and `wait` apply inside that budget.
## Read the response
```json theme={null}
{
"success": true,
"msg": "",
"data": {
"url": "https://example.com/",
"status": 200,
"status_text": "OK",
"content": "...",
"cookies": [],
"screenshot": "iVBORw0KGgo..."
}
}
```
`success` reports whether the Surfsky request succeeded. `data.status` is the target site's HTTP status, so a `200` from Surfsky can carry a `403` or a challenge page from the site. Check the target status and content. `screenshot` is present only when requested; decode it with Base64 to get a PNG.
## Scrape several pages
Whenever your job allows it, send several URLs to one browser instead of starting a browser per page. Each start costs time and a browser slot, and the pages in a batch share the same cookies. Send them as an array of request objects to the same endpoint:
```json theme={null}
[
{"url": "https://example.com", "wait_for": "h1"},
{"url": "https://example.org", "screenshot": true}
]
```
Pages run one after another in request order, and `data` comes back as an array in that order; failed items carry `error` and `status_code` instead of a page. Keep a batch within the 120-second limit, and use several browsers when you need pages fetched in parallel.
## Stop the session
The SDK stops the session when its scope exits, including on errors.
For direct HTTP requests, stop the browser with the `internal_uuid` from the start response:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise the browser stops after the [inactivity timeout](/sessions#how-a-session-ends). Do not mix the Scraping API with CDP automation in the same browser; the scraper takes over the page and closes extra tabs.
# Scrapy
Source: https://docs.surfsky.io/quickstart/scrapy
Route Scrapy requests through a pool of Surfsky browsers.
Surfsky's Scrapy integrations use a pool of cloud browsers to fetch pages. The download handler returns an ordinary Scrapy response for your spider to parse.
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
Use [human-emulation commands](/human_emulation) for clicks, typing, and scrolling.
Keep Scrapy selectors and Playwright's navigation and waiting for reading and
preparing the page.
| Package | Use it for |
| - | - |
| `scrapy-cloud-browser` | Browser-backed downloads with ordinary Scrapy parsing. |
| `scrapy-playwright-cloud-browser` | Playwright page actions before parsing the response. |
## Prerequisites
Set your [API token and base URL](/quickstart#before-you-start). Both integrations require at least one proxy URL; unlike a direct API request, the account's default pool is not used:
```bash theme={null}
export SURFSKY_PROXY_URL="http://user:pass@proxy.example.com:8080"
```
Create a Scrapy project if you do not have one:
```bash theme={null}
pip install scrapy
scrapy startproject surfsky_spider
cd surfsky_spider
```
### Installation
```bash theme={null}
pip install scrapy-cloud-browser
```
### Configuration
Add to `surfsky_spider/settings.py`:
```python theme={null}
import os
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
EXTENSIONS = {"scrapy_cloud_browser.CloudBrowserExtension": 500}
DOWNLOAD_HANDLERS = {
"http": "scrapy_cloud_browser.CloudBrowserHandler",
"https": "scrapy_cloud_browser.CloudBrowserHandler",
}
CLOUD_BROWSER = {
"API_HOST": os.environ["SURFSKY_API_BASE_URL"],
"API_TOKEN": os.environ["SURFSKY_API_TOKEN"],
"PROXIES": [os.environ["SURFSKY_PROXY_URL"]],
"NUM_BROWSERS": 1,
"PAGES_PER_BROWSER": 100,
"START_SEMAPHORES": 1,
"PROXY_ORDERING": "round-robin",
}
```
### Example spider
Save as `surfsky_spider/spiders/example.py`:
```python theme={null}
import scrapy
class ExampleSpider(scrapy.Spider):
name = "example"
start_urls = ["https://example.com"]
def parse(self, response):
yield {"title": response.css("title::text").get()}
```
This integration does not expose `BROWSER_SETTINGS` or `FINGERPRINT` through `CLOUD_BROWSER`. Use the Playwright integration below if you need those settings.
### Installation
```bash theme={null}
pip install scrapy-playwright-cloud-browser
```
### Configuration
Add to `surfsky_spider/settings.py`:
```python theme={null}
import os
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
EXTENSIONS = {"scrapy_playwright_cloud_browser.CloudBrowserExtension": 500}
DOWNLOAD_HANDLERS = {
"http": "scrapy_playwright_cloud_browser.CloudBrowserHandler",
"https": "scrapy_playwright_cloud_browser.CloudBrowserHandler",
}
CLOUD_BROWSER = {
"API_HOST": os.environ["SURFSKY_API_BASE_URL"],
"API_TOKEN": os.environ["SURFSKY_API_TOKEN"],
"PROXIES": [os.environ["SURFSKY_PROXY_URL"]],
"NUM_BROWSERS": 1,
"PAGES_PER_BROWSER": 100,
"START_SEMAPHORES": 1,
"PROXY_ORDERING": "round-robin",
"BROWSER_SETTINGS": {"inactive_kill_timeout": 60},
"FINGERPRINT": {"os": "win"},
}
```
### Example spider with page actions
Save as `surfsky_spider/spiders/example.py`. The adapter uses Scrapy 2.12, so this spider defines `start_requests()`. Page actions can still be asynchronous.
```python theme={null}
import scrapy
from scrapy_playwright.page import PageMethod
class ExampleSpider(scrapy.Spider):
name = "example"
def start_requests(self):
yield scrapy.Request(
"https://example.com",
meta={
"playwright": True,
"playwright_page_methods": [PageMethod("wait_for_selector", "h1")],
},
)
def parse(self, response):
yield {"title": response.css("title::text").get()}
```
The integration creates a browser context for its requests. Do not use it to restore the default context's persistent profile storage; use [Playwright directly](/quickstart/playwright) for that workflow.
## Run the example
From the project directory:
```bash theme={null}
scrapy crawl example -O results.json
```
`results.json` should contain an item with `"title": "Example Domain"`.
## Click, type, and scroll
With `scrapy-playwright-cloud-browser`, pass an async callable to `PageMethod` to send page-level CDP commands before Scrapy receives the response:
```python theme={null}
async def fill_email(page):
cdp = await page.context.new_cdp_session(page)
try:
await cdp.send("Human.click", {"selector": "input[name='email']"})
await cdp.send("Human.type", {"text": "reader@example.com"})
await cdp.send("Human.scroll", {"deltaY": 600, "duration": 1.2})
finally:
await cdp.detach()
```
Define the function above the spider class, replace the request URL with your form page, and set `"playwright_page_methods": [PageMethod(fill_email)]` in its request metadata. Replace the selector with an input on that page.
The `scrapy-cloud-browser` handler does not expose a Playwright page. Use the Playwright integration for these actions.
## Use the Surfsky SDK
To manage a session directly from a spider, use the SDK with the [Scraping API](/quickstart/scraping_api). This standalone example uses Scrapy to parse the returned HTML. It requires Python 3.12+ and Scrapy 2.13+. Use a separate environment from the Playwright cloud browser integration, which pins Scrapy 2.12:
```bash theme={null}
python -m venv sdk-env
source sdk-env/bin/activate
pip install surfsky "scrapy>=2.13"
```
Save as `sdk_spider.py` outside your configured Scrapy project. This example does not use the cloud browser extensions or download handlers:
```python theme={null}
import scrapy
from surfsky import AsyncSurfsky
class SdkSpider(scrapy.Spider):
name = "sdk_example"
custom_settings = {
"TWISTED_REACTOR": "twisted.internet.asyncioreactor.AsyncioSelectorReactor",
}
async def start(self):
async with AsyncSurfsky() as client, client.session() as session:
result = await client.profiles.scrape(
session, "https://example.com", wait_for="h1"
)
yield {"title": scrapy.Selector(text=result.content).css("title::text").get()}
```
Run it:
```bash theme={null}
scrapy runspider sdk_spider.py -O results.json
```
The SDK stops the browser when the session scope exits, including on errors. It uses your account's default proxy pool unless you pass `proxy` to `client.session()`, and a [persistent profile](/sessions#persistent-profiles) UUID reuses saved state.
## Pool settings
| Setting | Default | Purpose |
| - | - | - |
| `NUM_BROWSERS` | `1` | Browser workers. Keep this within your account's available capacity. |
| `PAGES_PER_BROWSER` | `100` | Number of requests processed before recycling a browser. |
| `START_SEMAPHORES` | `10` | Maximum concurrent startups within a handler. |
| `PROXY_ORDERING` | `"random"` | Select proxies randomly or use `"round-robin"`. |
A browser serves several requests before it is recycled, so do not assume each response comes from a fresh session. If startup repeats without producing responses, check the API host, token, and proxy URL, then [limits](/limits). Start with one browser until the spider works.
## Stop the sessions
The pool closes its browsers when the crawl finishes. After an interrupted crawl, list [active sessions](/sessions#find-running-sessions) and stop any browser left running, or stop them all:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise each browser stops after its inactivity timeout, 30 seconds by default.
# Selenium
Source: https://docs.surfsky.io/quickstart/selenium
Run your Selenium scripts against a Surfsky cloud browser.
Selenium talks to a Surfsky browser over HTTP, through a ChromeDriver that runs next to it in the cloud. You do not need a local ChromeDriver or Chrome.
To have a coding agent set this up in your project, give it the [setup prompt](/coding-agents).
ChromeDriver has known leaks that anti-bot scripts can detect. On sites with bot
protection, use a patched fork of [Playwright](/quickstart/playwright) or
[Puppeteer](/quickstart/puppeteer), or a Surfsky [SDK](/sdk).
Use [human-emulation commands](/human_emulation) for clicks, typing, and scrolling.
Keep Selenium for navigation, selectors, waiting, and page content.
## Prerequisites
Set your [API token and base URL](/quickstart#before-you-start), then install the packages for your language below.
Other than `enable_chromedriver`, the example uses default start options, including your account's default proxy pool. Proxy, fingerprint, and other options are in the [API reference](/api-reference/profiles/start-one-time-session).
## Run an example
The examples use a one-time profile. To reuse a saved login, pass a [persistent profile](/sessions#persistent-profiles) UUID to the session call, keeping `enable_chromedriver` on.
Requires Python 3.12 or newer. Selenium's Python API is synchronous, so use the synchronous Surfsky client:
```bash theme={null}
pip install surfsky selenium
```
Save as `surfsky_selenium.py`:
```python theme={null}
from selenium import webdriver
from surfsky import Surfsky
with Surfsky() as client, client.session(enable_chromedriver=True) as session:
driver = webdriver.Remote(
command_executor=f"{client.base_url}/chromedriver/{session.internal_uuid}",
options=webdriver.ChromeOptions(),
)
try:
driver.get("https://example.com")
print(driver.title)
finally:
driver.quit()
```
Run it:
```bash theme={null}
python surfsky_selenium.py
```
Requires Node.js 24 or newer, or Bun, for `await using`.
```bash theme={null}
npm install surfsky selenium-webdriver
```
Save as `surfsky-selenium.mjs`:
```javascript theme={null}
import { Builder } from "selenium-webdriver";
import { Surfsky } from "surfsky";
const client = new Surfsky();
await using session = await client.session({ enable_chromedriver: true });
const driver = await new Builder()
.usingServer(`${client.baseUrl}/chromedriver/${session.internal_uuid}`)
.forBrowser("chrome")
.build();
try {
await driver.get("https://example.com");
console.log(await driver.getTitle());
} finally {
await driver.quit();
}
```
Run it:
```bash theme={null}
node surfsky-selenium.mjs
```
Expected output:
```text theme={null}
Example Domain
```
## Click, type, and scroll
Prefer [human emulation](/human_emulation) over Selenium's `click`, `send_keys`, and action chains. `Human.*` commands move the cursor and type with human timing, and they go through ChromeDriver. Replace the selector with an input on your page:
```python Python theme={null}
driver.execute_cdp_cmd("Human.click", {"selector": "input[name='email']"})
driver.execute_cdp_cmd("Human.type", {"text": "reader@example.com"})
driver.execute_cdp_cmd("Human.scroll", {"deltaY": 600, "duration": 1.2})
```
```javascript JavaScript theme={null}
await driver.sendDevToolsCommand("Human.click", { selector: "input[name='email']" });
await driver.sendDevToolsCommand("Human.type", { text: "reader@example.com" });
await driver.sendDevToolsCommand("Human.scroll", { deltaY: 600, duration: 1.2 });
```
## Connection settings
Use `/chromedriver/{internal_uuid}` as `command_executor`, not the CDP `ws_url`. The URL grants access to the browser, so keep it private. Do not pass local launch arguments for proxy or fingerprint; those are set in the start request. `chromedriver_not_enabled` means the browser was started without the flag.
## Stop the session
`driver.quit()` ends the WebDriver session but leaves the browser running, because ChromeDriver attached to a browser it did not launch. The SDK stops it when the session scope exits, including on errors.
Without the SDK, stop the browser through the API:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
Otherwise the browser stops after the [inactivity timeout](/sessions#how-a-session-ends).
# Screencast
Source: https://docs.surfsky.io/screencast
Watch a running browser in the built-in viewer or receive image frames over WebSocket.
Screencast streams a page's screen over WebSocket. It is view-only. Use [DevTools](/debugging) when you need to inspect elements or interact with the browser.
## Get the stream URL
Starting a session returns `inspector.screencast`. Use it directly; it includes the page target selected for the stream.
A stream URL looks like this:
```text theme={null}
wss://YOUR_API_HOST/screencast/INTERNAL_UUID/devtools/page/PAGE_ID
```
The session and target page must remain open. Closing the viewer does not stop the browser, and an open viewer does not keep it running.
After the connection opens, the server sends `{"type": "started"}`, then a `frame` message for each captured frame, and `{"type": "stopped"}` when the browser side ends. The client does not need to send anything.
## Quick view
Open the built-in viewer at your API base URL with the stream URL in its `ws` query parameter. This browser JavaScript constructs the URL from the start response:
```javascript theme={null}
// baseUrl is your API base URL; session is the parsed start response.
const viewer = new URL("/screencast", baseUrl);
viewer.searchParams.set("ws", session.inspector.screencast);
console.log(viewer.href);
```
Copy the resulting URL into your browser.
Keep the viewer URL private: anyone who can use it can see the live page.
## Embed a stream
This HTML displays frames at their natural dimensions. Replace `SCREENCAST_URL_FROM_START_RESPONSE` with the returned URL before opening the file:
```html theme={null}
Surfsky screencast
Connecting…
```
Each `frame` message contains a Base64 JPEG in `data` and the CDP screencast frame `metadata`, such as the device dimensions and timestamp. Streaming provides live frames; this example does not store a recording.
## If the stream stops
Watching a screencast does not reset the inactivity timer. After `browser_settings.inactive_kill_timeout` seconds without CDP, ChromeDriver, or scraping traffic, the browser stops and the stream sends a `stopped` message. The default is 30 seconds. Check `GET /profiles/active` before reconnecting. See [the browser stopped by itself](/troubleshooting#the-browser-stopped-by-itself).
The stream follows one page. `inspector.screencast` points at the first page open at start; if your automation opened a new tab, the stream keeps showing the original one. Open `inspector.list` to get the current pages with their `page_id`, then build the stream URL for the page you want:
```text theme={null}
wss://YOUR_API_HOST/screencast/INTERNAL_UUID/devtools/page/PAGE_ID
```
# SDKs
Source: https://docs.surfsky.io/sdk
Choose the Python or TypeScript client for browser automation and the Surfsky REST API.
Surfsky's SDKs manage sessions, profiles, proxies, and account limits. They also provide browser actions and pools for processing multiple jobs.
Python 3.12+. Async browser automation and sync or async REST calls.
Node.js 22+ or Bun. ES modules with TypeScript definitions.
## Browser actions or your existing framework
Use the SDK's browser API for navigation, selectors, mouse and keyboard actions, and access to page content. Input commands use [human emulation](/human_emulation).
If you use another framework to control the browser, the SDK can start the session and provide its connection URL. Your framework handles page operations; the SDK handles the session lifecycle. See the [Python Playwright example](/sdk/python#use-playwright).
## REST API access
Both clients expose profile management, proxy lookups, fingerprint values, extensions, and account limits. They parse API responses into objects and raise SDK exceptions for failed resource calls.
You can also call the [REST API](/api-reference) directly. The [framework guides](/quickstart#connect-your-existing-code) show complete examples without an SDK dependency.
# Python SDK
Source: https://docs.surfsky.io/sdk/python
Install the Python SDK, automate a page, run concurrent jobs, and reuse a persistent profile.
Requires Python 3.12 or newer. Install the published `surfsky` package:
```bash theme={null}
pip install surfsky
```
Set `SURFSKY_API_TOKEN` and `SURFSKY_API_BASE_URL` from the [dashboard](https://app.surfsky.io). See [credential setup](/quickstart#before-you-start). You can also pass `api_token` and `base_url` to the client constructor.
## Start a browser
Save as `first_browser.py` and run `python first_browser.py`:
```python theme={null}
import asyncio
from surfsky import AsyncSurfsky
async def main():
async with AsyncSurfsky() as client:
async with client.browser() as browser:
await browser.goto("https://example.com")
print(await browser.title())
asyncio.run(main())
```
Expected output is `Example Domain`. The browser context manager stops the session on exit, including when a page operation raises an exception. Cleanup still requires a running process and a working API connection.
`AsyncSurfsky` provides browser automation and asynchronous REST calls. The synchronous `Surfsky` client provides REST calls and managed sessions for use with other browser frameworks.
## Interact with the page
Page operations run inside the `client.browser()` block. Common methods include `goto`, `click`, `type`, `hover`, `inner_text`, and `wait_for_selector`. Wait timeouts are in seconds and default to 30; expiration raises `BrowserTimeoutError`.
Use selectors from your target page. For example, in a form with an email field:
```python theme={null}
await browser.type('input[name="email"]', "reader@example.com")
await browser.click('button[type="submit"]')
await browser.wait_for_selector(".confirmation", timeout=15)
print(await browser.inner_text(".confirmation"))
```
`evaluate()` executes JavaScript. It uses an isolated execution context by default; pass `isolated=False` when the script needs variables defined by the page. The DOM is shared, so page code can observe DOM changes made by your script.
```python theme={null}
print(await browser.evaluate("document.title"))
```
### Save traffic
Pass `block_resources` when starting the browser:
```python theme={null}
async with client.browser(block_resources={"image", "font", "media"}) as browser:
await browser.goto("https://example.com")
```
Keep resources needed for screenshots, layout, or challenges. See [Speed optimization](/speed-optimization).
### Capture a page's API response
Start capture before the request occurs. Adapt the URL and matching path to your application:
```python theme={null}
await browser.capture_responses("/api/search")
await browser.goto("https://your-site.example/search")
response = await browser.wait_for_response("/api/search")
data = response.json()
```
### Multiple tabs
`browser` operates on the first tab. `browser.pages` includes other tabs and popups:
```python theme={null}
await browser.new_page()
await browser.pages[1].goto("https://example.com")
print(await browser.pages[1].title())
```
Close tabs you no longer need. See the [session page limit](/troubleshooting#session-page-limit).
## Running many browsers
This example runs two jobs concurrently and reports each outcome:
```python theme={null}
import asyncio
from surfsky import AsyncSurfsky
async def title(browser, url):
await browser.goto(url)
return await browser.title()
async def main():
async with AsyncSurfsky() as client:
outcomes = await client.map(
title,
["https://example.com", "https://example.org"],
concurrency=2,
)
for outcome in outcomes:
print(outcome.value if outcome.ok else outcome.error)
asyncio.run(main())
```
The pool can reuse browsers between jobs. A leased browser retains its fingerprint, proxy, and cookies between leases. Use `browser.retire()` to replace it after the current lease when a job needs a fresh identity.
For manual leasing:
```python theme={null}
async with client.browsers(concurrency=2) as pool:
async with pool.lease() as browser:
await browser.goto("https://example.com")
```
Account limits include browsers started by other workers. See [Concurrency](/concurrency) before distributing work across processes.
## Proxies
Pass a proxy choice to `browser()` or `session()`:
```python theme={null}
from surfsky import PremiumProxy, SharedProxy
proxy = PremiumProxy(country="us")
# Other choices:
proxy = SharedProxy(country="us")
proxy = "socks5://username:password@proxy.example.com:1080"
```
`client.proxies` exposes location lookups and quota calls. See [Proxies](/proxies) for tier availability and targeting rules.
## Sessions
By default, `client.browser()` starts a one-time session whose state is discarded when it stops. To reuse login state, cookies, and local storage across runs, create a persistent profile and pass its UUID to `client.browser(profile_uuid=...)`.
Save this as `reuse_session.py`. On the first run it creates a profile and saves its UUID in `profile-uuid.txt`. Later runs read that UUID and restore the same profile:
```python theme={null}
import asyncio
from pathlib import Path
from surfsky import AsyncSurfsky, Fingerprint, StorageOptions
async def main():
profile_file = Path("profile-uuid.txt")
async with AsyncSurfsky() as client:
if not profile_file.exists():
profile = await client.profiles.create(
title="Reusable session",
fingerprint=Fingerprint(os="win"),
storage_options=StorageOptions(cookies=True, localstorage=True),
)
profile_file.write_text(profile.uuid)
async with client.browser(profile_uuid=profile_file.read_text().strip()) as browser:
await browser.goto("https://example.com")
print("Previous run:", await browser.evaluate('localStorage.getItem("previous-run")'))
await browser.evaluate('localStorage.setItem("previous-run", "saved")')
asyncio.run(main())
```
Run `python reuse_session.py` twice from the same directory, waiting for each run to finish. The first run prints `Previous run: None`; the second prints `Previous run: saved`. Surfsky stores the browser state; the local file stores only the profile UUID.
The browser context manager stops the session and saves the [selected browser state](/sessions#what-gets-saved) on exit. To [reuse a login](/sessions#reuse-a-login), replace the example page operations with your site's sign-in flow on the first run, then open an authenticated page on later runs.
## Use Playwright
Use `client.session()` to let the SDK manage the session while Playwright handles page operations. Install `surfsky` and `playwright`, and set the environment variables before running this example:
```python theme={null}
from playwright.sync_api import sync_playwright
from surfsky import Surfsky
with Surfsky() as client, client.session() as session, sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(session.connect_url)
try:
page = browser.contexts[0].pages[0]
page.goto("https://example.com")
print(page.title())
finally:
browser.close()
```
The session context stops the remote browser when the block exits.
## Raw requests
```python theme={null}
response = await client.request("GET", "/proxies/shared/quota")
response.raise_for_status()
print(response.json())
```
`request()` returns an `httpx.Response`. Unlike resource methods, it does not raise for an HTTP error unless you call `raise_for_status()`.
The [SDK repository](https://github.com/surfskyio/surfsky-py) includes the full method reference and additional examples.
# TypeScript SDK
Source: https://docs.surfsky.io/sdk/typescript
Use the Surfsky client from Node.js or Bun to automate pages, pool browsers, and manage profiles.
Requires Node.js 22 or newer, or Bun. The `surfsky` package uses ES modules and includes TypeScript definitions.
```bash theme={null}
npm install surfsky
```
Set `SURFSKY_API_TOKEN` and `SURFSKY_API_BASE_URL` from the [dashboard](https://app.surfsky.io). See [credential setup](/quickstart#before-you-start). You can also pass `apiToken` and `baseUrl` to the constructor.
## Start a browser
Save as `first-browser.mjs` and run `node first-browser.mjs`. The same code can be used in a TypeScript project configured for ESM.
```javascript theme={null}
import { Surfsky } from "surfsky";
const client = new Surfsky();
const browser = await client.browser();
try {
await browser.goto("https://example.com");
console.log(await browser.title());
} finally {
await browser.close();
}
```
Expected output is `Example Domain`. `browser.close()` stops the session. Put it in `finally` so it also runs when a page operation throws. Cleanup cannot complete if the process is forcibly terminated or loses access to the API.
On runtimes that support explicit resource management, such as Node.js 24+ or Bun, `await using` can handle cleanup at scope exit:
```typescript theme={null}
await using browser = await client.browser();
await browser.goto("https://example.com");
```
For Node.js 22, use `try`/`finally` or a TypeScript build that transforms `await using` for your runtime.
## Interact with the page
Methods include `goto`, `click`, `type`, `hover`, `innerText`, and `waitForSelector`. Run page operations while the browser is open, using selectors from your target page:
```typescript theme={null}
await browser.type('input[name="email"]', "reader@example.com");
await browser.click('button[type="submit"]');
await browser.waitForSelector(".confirmation");
console.log(await browser.innerText(".confirmation"));
```
`evaluate()` runs JavaScript in an isolated execution context by default. Use `isolated: false` to access variables defined by the page. DOM changes remain visible to the page.
```typescript theme={null}
console.log(await browser.evaluate("document.title"));
```
### Save traffic
Choose resource types when starting a browser:
```typescript theme={null}
const browser = await client.browser({
blockResources: ["image", "font", "media"],
});
```
Close this browser after use. Keep resources required by your target page; see [Speed optimization](/speed-optimization).
### Capture a page's API response
Start capture before navigation. Adapt the URL and response path to your application:
```typescript theme={null}
await browser.captureResponses("/api/search");
await browser.goto("https://your-site.example/search");
const response = await browser.waitForResponse("/api/search");
const data = response.json();
```
### Multiple tabs
`browser` operates on the first tab. `browser.pages` contains other tabs and popups:
```typescript theme={null}
await browser.newPage();
await browser.pages[1].goto("https://example.com");
console.log(await browser.pages[1].title());
```
See the [session page limit](/troubleshooting#session-page-limit).
## Running many browsers
```javascript theme={null}
import { Surfsky } from "surfsky";
const client = new Surfsky();
const outcomes = await client.map(
async (browser, url) => {
await browser.goto(url);
return browser.title();
},
["https://example.com", "https://example.org"],
{ concurrency: 2 },
);
for (const outcome of outcomes) {
console.log(outcome.ok ? outcome.value : outcome.error);
}
```
The pool collects an outcome for each job and can reuse browsers between jobs. Leases retain browser state, including cookies. Do not assume each item receives an empty browser.
For manual leasing on Node.js 24+ or Bun:
```typescript theme={null}
await using pool = await client.browsers({ concurrency: 2 });
await pool.lease(async (browser) => {
await browser.goto("https://example.com");
console.log(await browser.title());
});
```
See [Concurrency](/concurrency) for account limits and work distributed across processes.
## Proxies
Pass `proxy` to `browser()` or `session()`:
```typescript theme={null}
const browser = await client.browser({
proxy: { tier: "premium", country: "us" },
});
```
You can also select `tier: "shared"` or supply a proxy URL. Close the browser after use. `client.proxies` provides location and quota lookups; see [Proxies](/proxies).
## Sessions
By default, `client.browser()` starts a one-time session whose state is discarded when it stops. To reuse login state, cookies, and local storage across runs, create a persistent profile and pass its UUID to `client.browser({ profileUuid })`.
Save this as `reuse-session.mjs`. On the first run it creates a profile and saves its UUID in `profile-uuid.txt`. Later runs read that UUID and restore the same profile:
```javascript theme={null}
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { Surfsky } from "surfsky";
const client = new Surfsky();
const profileFile = "profile-uuid.txt";
if (!existsSync(profileFile)) {
const profile = await client.profiles.create({
title: "Reusable session",
fingerprint: { os: "win" },
storage_options: { cookies: true, localstorage: true },
});
writeFileSync(profileFile, profile.uuid);
}
const profileUuid = readFileSync(profileFile, "utf8").trim();
const browser = await client.browser({ profileUuid });
try {
await browser.goto("https://example.com");
console.log("Previous run:", await browser.evaluate('localStorage.getItem("previous-run")'));
await browser.evaluate('localStorage.setItem("previous-run", "saved")');
} finally {
await browser.close();
}
```
Run `node reuse-session.mjs` twice from the same directory, waiting for each run to finish. The first run prints `Previous run: null`; the second prints `Previous run: saved`. Surfsky stores the browser state; the local file stores only the profile UUID.
`await browser.close()` stops the session and saves the [selected browser state](/sessions#what-gets-saved). To [reuse a login](/sessions#reuse-a-login), replace the example page operations with your site's sign-in flow on the first run, then open an authenticated page on later runs.
## Raw requests
```typescript theme={null}
const response = await client.request("GET", "/proxies/shared/quota");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());
```
`request()` returns the raw response and does not throw on HTTP status. The [SDK repository](https://github.com/surfskyio/surfsky-js) contains the complete method reference and examples.
# Sessions
Source: https://docs.surfsky.io/sessions
Start and stop browsers, reuse persistent profiles, and choose what browser state to save.
A session is a running browser, and it holds one [concurrency slot](/concurrency) until it stops. A one-time profile discards its state then; a persistent profile saves the state you select for later sessions.
## One-time profiles
One request creates and starts the browser. Use a one-time profile when each task can begin fresh:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles/one_time" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
Set the environment variables as shown in the [Quickstart](/quickstart#before-you-start). If you omit `proxy`, the browser uses an available account pool. Supply your own proxy if your account has none.
The response has `ws_url` and `internal_uuid`. Connect to `ws_url`, then use `internal_uuid` to stop the session. Everything the browser stored is discarded with it.
## Persistent profiles
Create a profile once:
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "my profile",
"fingerprint": {"os": "win"},
"storage_options": {"cookies": true, "localstorage": true}
}'
```
Save `data.uuid` from the response and use it to start the profile:
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/PROFILE_UUID/start" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
Creation does not start a browser. Each start restores saved profile state. Settings supplied to `/start` apply to the running session. To change the stored settings, [update the profile](/api-reference/profiles/update-profile) while it is stopped.
A persistent profile runs one browser at a time. Starting it again returns the running session and ignores the settings sent with that request, so stop the session first when a run needs different ones. Use a separate profile for work that runs in parallel.
### What gets saved
`storage_options` selects the data saved with the profile:
| Option | Default | Data |
| - | - | - |
| `cookies` | `true` | Website cookies |
| `passwords` | `true` | Browser-saved passwords |
| `bookmarks` | `true` | Bookmarks |
| `localstorage` | `false` | Local storage and related site storage |
| `extensions` | `false` | Extension state |
| `history` | `false` | Browsing history |
| `serviceworkers` | `false` | Service worker data |
Enable the storage your application uses. Cookies alone may not preserve a login that also depends on local storage. See [Cookies](/cookies#sessions-that-will-not-stay-logged-in).
### Reuse a login
Sign in once in a persistent profile with the [storage](#what-gets-saved) the site needs, through your automation or the session's [DevTools](/debugging). Stop the session and wait for the response: that is what saves the state for the next start.
The site can still expire its own session, and the exit IP can differ on the next run. Use [session control](/proxies#session-control) when the IP has to stay the same.
### Change the proxy
Change the proxy when the stored one stops working, which happens with residential and mobile peers, or when a run needs a different location. Replace the saved value for future runs, or override it for a single session.
**Change the stored proxy.** [Update the profile](/api-reference/profiles/update-profile) while it is stopped; the next start uses the new value.
```json theme={null}
{"proxy": {"tier": "premium", "country": "de"}}
```
When you save a pool selection, the profile stores one proxy from that pool and reuses it on every run. The exit IP behind a residential or mobile proxy still changes between sessions; use [session control](/proxies#session-control) when the IP must stay the same.
Saving the same selection again selects a different proxy. Surfsky does not re-check a stored pool proxy at startup, so save the selection again if it stops working.
Send a URL to store your own proxy, or `null` to remove the stored proxy and let later starts select from an available account pool:
```json theme={null}
{ "proxy": "socks5://user:pass@proxy.example.com:1080" }
```
```json theme={null}
{ "proxy": null }
```
Edit a stopped profile. A running profile returns `profile_is_running`; see [errors](/errors#request-errors).
**Override for one session.** Send the same `proxy` field in the [start request](/api-reference/profiles/start-profile) instead. It applies to that browser only and leaves the stored proxy unchanged.
A proxy URL, `open_vpn`, or `wireguard` also overrides the stored proxy for that run. A shared-tier override needs a country, for example `{"proxy": {"tier": "shared", "country": "de"}}`. Without a country, a shared request leaves the stored proxy in place.
Read the profile with [GET /profiles/\{profile\_uuid}](/api-reference/profiles/get-profile) to see its current connection.
A site that tied its session to the previous IP or country can ask for verification again, so keep the country stable unless the account is meant to move. See [proxies](/proxies) for targeting and session options.
## Identifiers
| Identifier | Use it for |
| - | - |
| `data.uuid` from profile creation | Starting, reading, updating, or deleting the saved profile |
| `internal_uuid` from a start response | Stopping the running browser, scraping, CDP, ChromeDriver, and screencast |
Always use the identifier returned for the operation. Do not depend on profile and session identifiers being different, or on a persistent profile receiving a new identifier on every start.
## How a session ends
**Explicit stop.** For persistent profiles, a normal stop saves the selected browser state. Wait for completion before starting the next run.
```bash theme={null}
curl --fail-with-body -X POST \
"$SURFSKY_API_BASE_URL/profiles/INTERNAL_UUID/stop" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
**Inactivity.** The browser stops after `browser_settings.inactive_kill_timeout` seconds without a CDP command, ChromeDriver request, or scrape. The default is 30 seconds; an open but idle connection does not count.
```json theme={null}
{ "browser_settings": { "inactive_kill_timeout": 300 } }
```
Set a longer timeout for work that includes pauses, such as manual input. An
abandoned browser then also runs longer.
**Failure.** A browser or infrastructure failure can end the session before a normal stop completes. Recent unsaved state may be lost. Check active sessions before reconnecting or starting replacement work.
## Finding what is running
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/profiles/active" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN"
```
`data` lists the running sessions. Use `one_time` to identify disposable ones; do not infer the profile type from whether an identifier is present.
This lists running browsers only. [GET /profiles](/api-reference/profiles/list-profiles) lists saved profiles page by page, each with a `status` of `started` or `stopped`.
Stop sessions owned by completed or failed jobs. Age alone does not prove that a session is abandoned.
`POST /profiles/stop` stops every running browser on the account, including
browsers used by other workers. Its response reports `stopped` and `failed`
separately.
## Browser capacity
A failed or interrupted start request can have an uncertain outcome, so check for an existing session before retrying it.
# Shared cache
Source: https://docs.surfsky.io/shared-cache
Reuse cached browser resources across sessions that visit the same sites.
Enable shared cache when separate browser sessions repeatedly download the same resources. Sessions using the same cache key can reuse cached content, subject to the browser's normal cache behavior.
## Enable shared cache
Pass these settings when starting a browser:
```json theme={null}
{
"browser_settings": {
"cache_enabled": true,
"cache_key": "product-pages"
}
}
```
| Field | Requirement |
| - | - |
| `cache_enabled` | Defaults to `false`. Set to `true` to use shared cache. |
| `cache_key` | Required when caching is enabled. Letters, digits, dots, underscores, and hyphens; do not start with a dot. |
The cache is scoped using your API token and the key. Using the same key with another account's token does not select your cache.
Shared cache does not save a login. Use a [persistent profile](/sessions) for cookies and other saved browser state.
## Choose a key
Use a descriptive key for a workload, such as `catalog-example-com`. Reuse it across sessions that should share cached resources.
Use different keys, or leave shared cache disabled, when jobs need independent cache state. Separate keys also make comparisons easier: you can test a fresh key without changing the configuration of other workers.
## Check whether it helps
Compare similar runs with caching off and on. Keep the URLs, proxy targeting, request blocking, and worker count the same. Measure page duration and proxy traffic per successful result, allowing an initial run to populate the cache.
Caching may offer little benefit when resources change on every request or cannot be cached. Blocking an unwanted resource avoids the download entirely; see [speed optimization](/speed-optimization).
## Investigate unexpected content
If a problem appears only in sessions sharing a key, repeat the request with caching disabled or a fresh key. Also check the response's cache headers and whether the page is serving personalized or challenge content. Keep the original session available for [inspection](/debugging) while you compare results.
# Speed optimization
Source: https://docs.surfsky.io/speed-optimization
Measure browser startup, page loading, and automation time before tuning throughput.
Measure one representative job before changing its settings. Record browser startup time, navigation time, time spent waiting or interacting, proxy traffic, and whether the result contains the data you need.
The connection from your worker to Surfsky carries automation commands. The browser's proxy carries website traffic. These are separate paths; changing the website proxy does not automatically shorten the worker's CDP round trips.
## What costs time
A short job spends most of its time starting the browser. A long one spends it on the pages.
| Step | What it involves | How to spend less |
| - | - | - |
| Browser start | A fresh container, the proxy or VPN connection inside it, the exit-IP lookup, fingerprint selection, and Chrome itself. See [how it works](/intro-to-surfsky). | Reuse one browser for several pages, and start the next one while the current job finishes. |
| Persistent profile | Saved state is downloaded at start and written back at stop, and you wait for the stop to finish. | Use a one-time profile for jobs that do not need saved state. |
| Navigation | The proxy path to the site, plus whatever you told the browser to wait for. | Target the proxy location, and wait for a selector instead of `networkidle`. |
| Each CDP command | One round trip between your worker and the Surfsky region. | Read related values in one evaluation; run the worker near the region. |
| Human input | Real cursor and typing time, set by `duration`, `preDelay`, and `postDelay`. | Budget for it on pages that score behavior; use plain navigation elsewhere. |
The fingerprint is applied once at startup, in the browser engine, so no page is patched and no command carries extra cost for it.
## Pick the region first
Surfsky runs in two regions, Germany and the United States. Choose the one closest to your automation worker in the [dashboard](https://app.surfsky.io). Each region has its own API base URL; use the one shown for your account. Network latency between the worker and browser adds to the time needed for each CDP command.
The region applies to the whole account and can be changed at most once every 24 hours, with no browsers running. Choose the proxy location separately, based on the location required by your task. A browser in Germany can use a US proxy.
Target that location explicitly. Without it the exit IP can sit far from the target site and add latency to every page load; see [location targeting](/proxies#location-targeting).
## Reduce command round trips
Read related values in a single page evaluation to reduce command round trips. For example, collect link text and URLs together in Playwright:
```python theme={null}
links = await page.locator("a").evaluate_all(
"elements => elements.map(a => ({text: a.textContent, href: a.href}))"
)
```
Do not run dependent actions concurrently. A click that submits a form must happen after the fields are filled.
## Wait for the content you need
Navigate with `domcontentloaded`, then wait for a selector or response that shows the required content is ready:
```python theme={null}
await page.goto("https://example.com", wait_until="domcontentloaded")
await page.locator("h1").wait_for()
```
Use `load` when the job needs later resources, such as a screenshot of the complete page. Avoid `networkidle` on pages with continuous background requests. A short fixed delay can appear fast while returning incomplete data.
If the page loads the required data from a JSON response, [capture that response](/debugging#reading-the-pages-requests) before doing additional DOM queries.
## Block unneeded resources
For text extraction, test whether you can omit images, fonts, or media. In the Python SDK:
```python theme={null}
# Inside an AsyncSurfsky client context.
async with client.browser(block_resources={"image", "font", "media"}) as browser:
await browser.goto("https://example.com")
print(await browser.title())
```
See the [Python SDK setup](/sdk/python) for imports and credentials. You can also [block domains](/proxies#proxy-blacklist) in the browser start request.
Compare output after each change. Images may contain the data you need, fonts affect screenshot layout, and blocking challenge resources can stop navigation.
For assets you need to load, test [domain routing](/proxies#domain-routing) through a cheaper datacenter proxy. Keep the target site on the residential connection and compare asset loading time and traffic cost before applying the rules to more jobs.
## Reuse state where it helps
A [persistent profile](/sessions) can avoid repeated logins when its saved state is still valid. Enable the storage types the site needs, and use the default context when reconnecting.
A [shared cache](/shared-cache) can reduce repeated downloads across sessions. Measure a warm run and keep cache keys separate for workloads that need independent cache state.
Reuse a browser for multiple related pages when the jobs can safely share cookies and storage. This saves startup work, but it also carries state from one job to the next.
## Scale with a bounded pool
Use the [SDK pool](/concurrency#use-an-sdk-pool) with an explicit concurrency setting. Increase it gradually and measure completed results per minute alongside target-site failures and traffic per result. Start the next browser while the current job finishes so startup overlaps with work instead of adding to it.
More workers stop helping when browser capacity, API rate limits, proxy throughput, or the target site becomes the bottleneck. See [limits](/limits) for the signals to monitor.
## Close what you open
Stop the browser when the job ends, including error paths. Choose `inactive_kill_timeout` to allow legitimate pauses, and investigate sessions left running after a worker exits.
For long-running pools, retire a browser after a job leaves unexpected state or repeated failures. Do not recycle on an arbitrary schedule without checking whether it improves the workload.
If command round trips dominate after reducing unnecessary calls, move your worker closer to the browser or [switch the region](#pick-the-region-first). Keep the same proxy and target URLs so the measurement isolates that change.
# Troubleshooting
Source: https://docs.surfsky.io/troubleshooting
Diagnose failed starts, blocked pages, lost logins, timeouts, and CDP disconnections.
Reproduce the failure with one browser and keep it open for [inspection](/debugging). Record the request status, error body, and `x-cloud-tracing-uuid` response header before changing configuration.
## Find the failing step
| Failure | Start here |
| - | - |
| Browser will not start | Read the API error `code`; check credentials, host, proxy, and account limits. |
| Browser starts but the site is blocked | Inspect the page and target response, then compare proxy and session settings. |
| Previously saved login is missing | Check whether the profile was still running, then profile storage and browser context. |
| Navigation or scraping times out | Check the wait condition, failed network requests, and the inactivity timeout. |
| Connection closes during a job | Check the inactivity timeout, active sessions, and cleanup from other workers. |
| More workers reduce throughput | Check request limits, browser capacity, target failures, and traffic. |
## The browser will not start
A `401 not_authorized` usually means the API token is absent or invalid. Send it as `X-Cloud-Api-Token` and use the base URL assigned to your account. A `namespace_not_allowed` error means the request reached a host that does not serve the account.
| Code | What to do |
| - | - |
| `cluster_is_full`, `service_unavailable`, `app_outdated` | Temporary. Retry with bounded backoff. |
| `parallel_browsers_limit_reached` | Stop a browser your job owns, or check `GET /users/browser-limits` before starting. |
| `bad_proxy`, `tunnel_not_ready` | The browser started but could not reach the internet through the configured proxy. Check the proxy URL, credentials, and provider availability; `tunnel_not_ready` can be retried. See [proxy errors](/proxies#proxy-errors). |
| `browser_failed_to_start`, `profile_start_failed` | Retry once. If it repeats, inspect `data` and the proxy configuration. |
| `region_not_chosen` | Pick a region in the dashboard first. |
Use `"tier": "shared"` for testing and `"tier": "premium"` for production; see [choosing the tier](/proxies#choosing-the-tier). The shared tier accepts only `country` targeting, and other targeting or session fields return `422 validation_error` with the message `the shared tier targets 'country' only`.
For other `422 validation_error` responses, inspect `data` for the field that failed.
See [errors](/errors) for the full code list. Avoid retrying a timed-out start without checking whether it created a browser.
## The site is blocking you
Confirm that you received a block or challenge page. HTTP `200` alone does not prove that the expected content loaded.
### Start with the proxy
Compare the same small test through another suitable proxy while keeping other settings unchanged. Check the exit country, connection type, and whether the IP changes during a session. A residential or mobile IP may behave differently from a datacenter IP, but no proxy type guarantees access.
If the job runs on `"tier": "shared"`, switch to `"tier": "premium"` before comparing other settings. Keep the IP stable when the site ties a login or challenge to it. More restrictive targeting can reduce availability; see [proxies](/proxies).
### Check fingerprint overrides
Remove unnecessary overrides and retest. If you set language, timezone, user agent, screen, or device fields manually, make sure they describe the browser you intend to use. See [fingerprints](/fingerprints).
### Tune the fingerprint
If the block persists with default settings, try another OS. Some sites treat desktop and mobile traffic differently, and a fresh profile with a different device can pass where the current one does not:
```json theme={null}
{ "fingerprint": { "os": "android" } }
```
### Check the automation client
Make sure your Playwright or Puppeteer code does not override the browser: user agent, viewport, locale, timezone, or device emulation set by the framework replace parts of the profile's fingerprint. Connect with the defaults.
Use a patched build of Playwright or Puppeteer, such as Patchright or rebrowser.
Do not use the framework's default interactions for clicks, typing, and scrolling; use [Human commands](/human_emulation) instead.
### Keep the page intact
Detection scripts can inspect changes to `window`, `document`, `navigator`, and the DOM, including redefined properties and wrapped native functions. Do not use stealth plugins or other anti-detection libraries with Surfsky: their overrides can conflict with the browser's fingerprint configuration or introduce detectable changes. Check your own init scripts for the same problems.
### Check the request pattern
Reduce concurrency and repeat a single user flow. Wait for each page state before triggering the next action. Use Human commands for input and follow [Human behavior](/human-behavior) for action order and timing. Compare the results after each change.
Test navigation through the site's search, menus, or internal links if opening the target URL directly fails. Where the task allows, vary the route between sessions.
If the target requires many navigation steps and the site allows it, you can change an existing internal link's destination and click it. The navigation then originates from that page. This modifies the DOM and may be detected (see [Keep the page intact](#keep-the-page-intact)), so test it on the target first.
If a challenge appears, confirm the selected [CAPTCHA solver](/captcha-solving) is enabled and observe its completion or failure events.
### Keep a profile that got through
On a well-protected site a browser is often let in only after a proof-of-work check, a CAPTCHA, or a few pages of ordinary browsing. A one-time profile has to earn that again on every start, and each attempt is a fresh chance to be blocked.
Once a session gets through, keep it. Run such jobs on a [persistent profile](/sessions#persistent-profiles) with cookies enabled, plus local storage if the site keeps its clearance there. Stop the session through the API so the state is saved, then start the same profile for the next run. The site sees a returning browser with valid cookies instead of a new one to challenge.
Keep the proxy stable for that profile. A clearance tied to one IP is useless from another; see [session control](/proxies#session-control).
### Report the site
If the site still blocks you after these steps, tell us with [`POST /site-reports`](/api-reference/site-reports/report-a-blocked-site) or the **Report a blocked site** button in the dashboard. Describe the block or challenge you saw, the status code, and the proxy and profile settings you tried.
```bash theme={null}
curl --fail-with-body "$SURFSKY_API_BASE_URL/site-reports" \
-H "X-Cloud-Api-Token: $SURFSKY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/login", "description": "Challenge loop on every start with a premium US proxy and a persistent profile."}'
```
## Sessions that will not stay logged in
Work through the [checklist in cookies](/cookies#sessions-that-will-not-stay-logged-in) for storage options, browser context, and expired logins. Two causes come from session handling:
* **The profile was still running.** Starting a running profile returns the existing session and ignores the settings in the new request, so a proxy, fingerprint, or storage change appears to have no effect. Check `GET /profiles/active`, stop the session, then start again.
* **The previous session ended in a crash or infrastructure failure.** An explicit stop and an inactivity stop both save the enabled storage. A crash does not, so anything done after the last saved stop is lost.
Importing cookies alone may not reproduce a login that depends on other browser state.
## Navigation and scraping timeouts
An expired wait looks the same from your code whether the page never loaded, the state you waited for never arrived, or the wait was too short. Capture the page where it fails:
```python theme={null}
from playwright.async_api import TimeoutError as PlaywrightTimeout
try:
await page.goto(url, wait_until="domcontentloaded")
await page.locator("h1").wait_for(timeout=15000)
except PlaywrightTimeout:
print(page.url)
await page.screenshot(path="timeout.png")
print((await page.content())[:2000])
```
* **The page never loaded.** `goto` raised, or the URL and content are empty. Look at the failed requests in [DevTools](/debugging), and check whether [domain blocking](/proxies#proxy-blacklist) removed something the page needs. A longer timeout will not bring back a resource you blocked.
* **The state never arrived.** The HTML is there but your selector is not. Compare the captured page with the one you expect: a challenge page, a different language, or an element inside an iframe all end up here.
* **The wait was too short.** The content sits in the captured HTML, or appears when you repeat the run with a much longer timeout. Measure how long it really takes, then set the timeout above that.
`networkidle` never settles on a site with continuous background traffic, so wait for a selector that proves the content is ready instead.
A wait that outlives the [inactivity timeout](#the-browser-stopped-by-itself) ends with a closed target rather than a timeout from your framework.
A failed [Scraping API](/quickstart/scraping_api) call returns `400 scrape_timeout` and no page to inspect. Repeat it with `wait_until: "domcontentloaded"` and no `wait_for` to see what the browser actually received, then put the wait condition back. Its per-page `timeout` applies to each navigation or selector wait inside the 120-second budget.
## The browser stopped by itself
A browser stops after `browser_settings.inactive_kill_timeout` seconds without activity. The default is 30 seconds. Activity means CDP commands sent by your client, ChromeDriver requests, and Scraping API calls. A connection that sends nothing, an idle DevTools tab, and a screencast viewer do not count.
One long client-side wait can be a single CDP command. A `waitForSelector` with a 60-second timeout under the default setting can fail with `Target closed` because the browser stopped at 30 seconds. Raise the timeout for such waits and for manual inspection:
```json theme={null}
{ "browser_settings": { "inactive_kill_timeout": 300 } }
```
A longer timeout also leaves an abandoned browser running, and counting against your [concurrency limit](/concurrency), for longer. See [how a session ends](/sessions#session-lifetime).
## CDP errors and disconnections
```text theme={null}
Protocol error Target.detachFromTarget: Target closed
Protocol error (Target.sendMessageToTarget): No session with given id
```
These messages can occur during normal tab closure or cleanup. Treat them as harmless only if the expected work already completed. During an active job, check whether the page closed, the browser crashed, the inactivity timeout expired, or another worker stopped the session.
Use `GET /profiles/active` to confirm the browser still exists. A stopped session's connection URL will not restart it: the WebSocket opens and closes at once with the reason `Profile not found`. Start a session and use the returned URL.
## Page limit
```text theme={null}
Protocol error (Target.createTarget): User page limit reached
```
The browser already has five automation-created pages. The limit is per browser and shared by every CDP client connected to it. It counts `Target.createTarget` calls, including ones still in flight. The initial tab and popups opened by the site are not counted. A slot is released when your client closes the page or the browser destroys it.
Close pages you no longer need, or reuse one. For independent parallel work, use separate browsers within your account's concurrency limit.
## Everything feels slow
Measure startup, navigation, interaction, and extraction separately. Check [speed optimization](/speed-optimization) for reducing commands, waits, and downloads. Increase concurrency only while it increases completed jobs per minute.
If pages are slow through a Surfsky pool, check whether the request sets a location. Without targeting, peers are drawn worldwide and the exit IP can be far from the target site. Set a `country`, or a `pool` such as `europe`, near it; see [location targeting](/proxies#location-targeting).
For framework-specific connection and cleanup details, use the [Playwright](/quickstart/playwright), [Puppeteer](/quickstart/puppeteer), or [Selenium](/quickstart/selenium) quickstart.
## Reporting a problem
Send [support](mailto:hello@surfsky.io):
* A minimal request or script that reproduces the failure.
* The HTTP status, error response, and `x-cloud-tracing-uuid` header.
* The approximate time in UTC and the API host used.
* The expected page result and what happened instead.
Share the `internal_uuid` privately if support needs the session identifier. Remove API tokens, proxy passwords, cookies, provider keys, and live connection URLs before sharing logs or screenshots.
# BLS Captcha Bypass - Automatic Visa Portal Solver
Source: https://docs.surfsky.io/use-cases/bls
Bypass BLS International's number-grid captcha automatically across visa appointment portals. Setup, when the challenge appears, and troubleshooting with code examples.
BLS International runs visa and consular appointment portals for multiple countries, protected by a number-grid captcha. Use `Captcha.autoSolve` for BLS. This type is handled by the external provider integration; sending it to `Captcha.solve` returns an error.
## Before you start
Enable the solver when [starting the browser](/captcha-solving#enable-solving):
```json theme={null}
{
"anti_captcha": {
"enabled": true,
"disable_external_providers": false,
"auto_captcha_types": ["bls"]
}
}
```
Your account needs the corresponding external provider configuration. Check [solver balance](/api-reference/captcha/get-solver-balance) and [provider settings](/captcha-solving#provider-configuration) if solving does not begin.
## Automatic captcha solving
Use the [Playwright](/quickstart/playwright) or [Puppeteer](/quickstart/puppeteer) lifecycle, adding the settings above to the start request. Create a page-level CDP session and attach listeners before enabling automatic mode:
```python Python (async Playwright) theme={null}
cdp = await page.context.new_cdp_session(page)
cdp.on("Captcha.solveCompleted", lambda event: print("Completed:", event))
cdp.on("Captcha.solveFailed", lambda event: print("Failed:", event))
await cdp.send("Captcha.autoSolve", {"type": "bls"})
await page.goto(target_url, wait_until="domcontentloaded")
```
```javascript JavaScript (Playwright) theme={null}
const cdp = await page.context().newCDPSession(page);
cdp.on("Captcha.solveCompleted", event => console.log("Completed:", event));
cdp.on("Captcha.solveFailed", event => console.error("Failed:", event));
await cdp.send("Captcha.autoSolve", { type: "bls" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
Replace the target URL with your page. Add a bounded wait for the form result or protected content after navigation. `{"status": "started"}` only confirms that automatic mode started.
## BLS challenge
BLS captchas typically show up at three points in the booking flow: checking appointment availability, submitting applicant details, or continuing through a protected booking step. The exact trigger point varies by portal — the same regional site can ask for it more than once in a single session.
The challenge itself is a 3×3 number grid with distorted, noisy, or color-shifted digits — a static OCR-style puzzle, not a behavioral or token-based check like reCAPTCHA or Turnstile. This means detection and solving both work differently here: there's no score to interpret and no widget state to watch, just an image to read.
BLS runs separate regional portals — Spain, Italy, Hungary, Portugal, and others — each under its own domain, but the captcha format and the `Captcha.autoSolve` flow are the same across them.
BLS portal sessions commonly time out after 15–20 minutes of inactivity, and some regional sites clear the form on a failed captcha attempt rather than just reprompting. Budget for that when setting timeouts — a slow solve can cost more than the captcha attempt itself if it triggers a session reset.
## Verify bypass worked
The BLS integration uses the external solver for the challenge on the page. It does not book appointments or complete the surrounding form. Continue those steps in your automation after the site accepts verification.
Keep the CDP connection open while solving. Avoid running a second solver against the same challenge. If verification completes but the page is still blocked, inspect the next network response or form validation error.
## Troubleshooting
If nothing happens, confirm that the external integration was loaded, `"bls"` is allowed in `auto_captcha_types`, and the challenge is present. Observe failure events and check provider availability before retrying.
See [CAPTCHA troubleshooting](/captcha-solving#troubleshooting) for shared errors and [DevTools](/debugging) to inspect a running attempt. Stop the browser after the job completes.
## BLS captcha FAQ
No — BLS only supports automatic mode through `Captcha.autoSolve`. Sending `"bls"` to `Captcha.solve` returns an error; there is no manual solving path for this type.
Yes. Spain, Italy, Hungary, Portugal, and other regional BLS sites use the same captcha format, so the same `auto_captcha_types` configuration and `Captcha.autoSolve` call apply regardless of which portal you're automating.
BLS can trigger the challenge at more than one step — availability checks, form submission, and final confirmation are common trigger points, and some portals reprompt at each one rather than once per session.
It only confirms automatic mode began — not that a challenge was found, solved, or accepted. Attach `Captcha.solveCompleted` and `Captcha.solveFailed` listeners and wait for the actual page result before treating the job as done.
Questions? Contact [support](mailto:hello@surfsky.io).
# Click CAPTCHA
Source: https://docs.surfsky.io/use-cases/click
Click a known verification control using a CSS selector or viewport coordinates.
The `click` solver clicks a target you provide. It does not identify images, answer questions, or automatically detect the correct control.
## Before you start
Enable `anti_captcha.enabled` and create a page-level `cdp` session using the [CAPTCHA setup](/captcha-solving#connect-and-solve). Navigate to your page and locate the verification control.
## Manual solving
Use a selector from your page:
```python Python (async) theme={null}
result = await cdp.send("Captcha.solve", {
"type": "click",
"timeout": 30000,
"options": {"selector": "#verify-button"},
})
```
```javascript JavaScript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "click",
timeout: 30000,
options: { selector: "#verify-button" },
});
```
Or pass `[x, y]` coordinates in viewport CSS pixels:
```javascript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "click",
options: { selector: [320, 240] },
});
```
`selector` is required. Automatic mode is not supported for `click`.
## Check the result
A `success` result means the click action completed. Wait for the expected page state to confirm verification. Recalculate coordinates after scrolling or a layout change; a completed click can still hit the wrong control.
If the element is inside an iframe, a main-document selector may not find it. Inspect it with your framework's frame support and use an appropriate target. For ordinary UI interaction without CAPTCHA configuration, use [Human.click](/human_emulation#click-and-move).
# Datadome Bypass - Automatic Captcha Solver
Source: https://docs.surfsky.io/use-cases/datadome
Bypass DataDome audio and slider CAPTCHAs automatically. Challenge types, proxy setup, IP-ban recovery, and manual/auto solving with code examples.
DataDome is a bot protection system used by many e-commerce and high-traffic websites. Surfsky handles DataDome CAPTCHA challenges in a few ways: audio or slider — automatically or manually with Surfsky's cloud browser. Choose the solver that matches the challenge displayed by the page. `datadome` is an alias for the audio solver; it is not an automatic choice between audio and slider challenges.
## DataDome challenge types
| Type | Challenge | Provider requirement |
| - | - | - |
| `datadome` | Audio, alias of `datadome_audio` | Gemini key. |
| `datadome_audio` | Audio | Gemini key. |
| `datadome_click` | Slider | No Gemini key required. |
For audio solving, configure a Gemini key on the account or pass `anti_captcha.gemini_api_key` when starting the browser.
## Before you start
Follow the [CAPTCHA setup](/captcha-solving#connect-and-solve). Enable `anti_captcha` in the start request and connect a page-level CDP session. The snippets below use an async `cdp` session on a page showing the challenge.
Use residential or mobile proxies. Datacenter IPs get challenged far more often and are the most common reason `datadome_click` or `datadome_audio` never clears on the first try.
## Manual captcha solving
For a slider challenge:
```python Python theme={null}
result = await cdp.send("Captcha.solve", {
"type": "datadome_click",
"timeout": 60000,
})
if result["status"] != "success":
raise RuntimeError(result)
```
```javascript JavaScript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "datadome_click",
timeout: 60000,
});
if (result.status !== "success") throw new Error(JSON.stringify(result));
```
For audio, use `type: "datadome_audio"`. The timeout is in milliseconds. Detection may raise a CDP error if the selected challenge is absent.
## Automatic captcha solving
Enable both variants if the target can present either:
```json theme={null}
{
"anti_captcha": {
"enabled": true,
"auto_captcha_types": ["datadome_audio", "datadome_click"]
}
}
```
Then omit `type` so detection can choose among those allowed variants:
```javascript theme={null}
await cdp.send("Captcha.autoSolve", {});
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
For a slider-only job, allow `datadome_click` at startup and pass `{ type: "datadome_click" }` to `autoSolve`. Attach [event listeners](/captcha-solving#automatic-solving) before starting.
## Reducing challenges
DataDome scores every request before deciding whether to challenge it. A few things lower that score and cut how often the challenge appears at all:
* Use residential or mobile proxies — datacenter IPs get challenged more often.
* Rotate proxies every 3–5 requests for aggressive scraping.
* Add delays and use [Human Emulation](/human_emulation) for mouse and keyboard.
* Reuse profiles that already passed a challenge — the cookie is good for 25+ follow-up requests, so avoid re-solving on every session.
## DataDome IP ban detection
If the `captchaUrl` contains `t=bv` instead of `t=fe`, the request IP is already banned — no solve will get through it. Switch to a different proxy before retrying; re-solving on the same IP wastes the attempt.
Prefer avoiding the slider entirely rather than solving it. `datadome_click` clears the visible challenge, but a clean fingerprint and a trusted proxy that never trigger the challenge in the first place are more reliable than solving it every session.
## Verify bypass worked
Keep the same browser and proxy while the challenge completes. Wait for the protected content or a successful site response after the solve; do not accept the solver's status as proof that the entire job succeeded.
`Captcha.solveFinished` confirms the challenge UI was cleared — it does not confirm the site accepted the resulting cookie. If the same challenge reappears right after a reported success, check whether the exit IP changed mid-session (common with rotating proxy pools) before assuming the solver failed.
If the site repeats the challenge, inspect the response and whether the exit IP changed. For audio failures, check Gemini configuration and whether an audio control is available. For slider failures, inspect the challenge frame and layout in [DevTools](/debugging).
Stop the browser after the job, including failed attempts. See [CAPTCHA troubleshooting](/captcha-solving#troubleshooting) for common errors.
## DataDome bypass FAQ
`datadome` is an alias for `datadome_audio` — it always targets the audio challenge, it does not auto-detect which challenge type is on the page. Use `datadome_click` explicitly for slider puzzles, or enable both in `auto_captcha_types` and let detection pick.
Only for audio challenges (`datadome` or `datadome_audio`). Slider challenges (`datadome_click`) don't require one — skip the Gemini key entirely if your target mostly serves sliders.
A `success` status confirms the challenge UI was cleared, not that the site accepted the resulting session. Check whether your exit IP changed between the solve and the follow-up request, and whether the `t` parameter in the captcha URL was `bv` (banned) rather than `fe`.
Every 3–5 requests for aggressive scraping on residential or mobile proxies. Datacenter IPs get challenged regardless of rotation frequency.
Yes — the cookie from a passed challenge is valid for 25+ follow-up requests. Reusing the profile avoids paying the solve cost on every session.
Questions? Contact [support](mailto:hello@surfsky.io).
# FunCaptcha Bypass - Arkose Labs Solver
Source: https://docs.surfsky.io/use-cases/funcaptcha
Bypass FunCaptcha (Arkose Labs) with automatic captcha solving. Setup, manual and automatic modes, proxy tips, and troubleshooting with code examples.
FunCaptcha (Arkose Labs) is an interactive CAPTCHA system commonly found on gaming, fintech, and e-commerce sites. Surfsky solves the audio challenge presented by the widget — automatically or manually with Surfsky's cloud browser. It requires an available audio flow; some deployments only present the visual puzzle, in which case there is no audio prompt for the solver to answer.
## Before you start
Use the [CAPTCHA setup example](/captcha-solving#connect-and-solve) to start a browser with `anti_captcha.enabled: true` and create a page-level `cdp` session. The snippets below use an async Playwright or Puppeteer session after navigating to your target.
Configure a Gemini key on the account or pass it as `anti_captcha.gemini_api_key`. For automatic mode, explicitly include `"funcaptcha"` in `auto_captcha_types`; it is not in the default list.
Use quality proxies. Datacenter IPs raise the FunCaptcha challenge frequency the same way they do for other anti-bot systems, and Human Emulation for mouse and keyboard timing further reduces how often the widget appears.
## Manual captcha solving
Wait for the challenge to appear, then send:
```python Python theme={null}
result = await cdp.send("Captcha.solve", {
"type": "funcaptcha",
"timeout": 60000,
})
if result["status"] != "success":
raise RuntimeError(result)
```
```javascript JavaScript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "funcaptcha",
timeout: 60000,
});
if (result.status !== "success") throw new Error(JSON.stringify(result));
```
The timeout is in milliseconds. Detection can raise a CDP error when the expected challenge is absent. After success, wait for a selector or response that identifies the protected content.
## Automatic captcha solving
Include `"funcaptcha"` in `anti_captcha.auto_captcha_types` at startup. Attach [solve event listeners](/captcha-solving#automatic-solving), then start detection before navigating:
```javascript theme={null}
await cdp.send("Captcha.autoSolve", { type: "funcaptcha" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
`status: "started"` confirms the background loop started. Observe failures and use a bounded wait for your page result. Keep the CDP session connected until the job completes.
## Reducing challenges
* **Gemini API Key Required** — the audio solver needs AI analysis; there is no keyless path for FunCaptcha.
* **Quality proxies** reduce challenge frequency — datacenter IPs trigger the widget more often.
* **Human Emulation** for mouse and keyboard helps avoid triggering the challenge in the first place. See [Human Emulation](/human_emulation).
* **Be patient** — Arkose Labs frequently chains multiple audio rounds before releasing the page; 1-2 minutes to complete is normal, not a stuck job.
## Verify bypass worked
A `success` status from `Captcha.solve` confirms the audio prompt was answered and submitted — it does not confirm the site's own risk check accepted it. Check that the protected content actually loaded before treating the job as done.
Inspect the widget when audio controls or challenge frames cannot be found. The solver does not support every visual challenge variant, and a site can change the audio flow independently of your code.
Check [solver configuration](/captcha-solving#provider-configuration), inspect one attempt in [DevTools](/debugging), and record the result or error before retrying. Stop the browser when the job ends.
## FunCaptcha bypass FAQ
No. Surfsky's FunCaptcha solver targets the audio challenge specifically. If a target only presents the visual rotation or image-selection puzzle, there is no audio prompt for it to answer — this limitation applies to audio-based FunCaptcha solvers generally, not just Surfsky.
Yes. Unlike DataDome's slider option, FunCaptcha's audio solver has no keyless path — `anti_captcha.gemini_api_key` is required for the AI analysis step every time.
It carries a hard Gemini key dependency that other supported CAPTCHA types don't, so it's opt-in — add `"funcaptcha"` to the list yourself when you're ready to use it.
A `success` status means the audio prompt was answered, not that the site accepted it. Confirm the protected content actually loaded, and check whether the exit IP changed mid-session before assuming the solver failed.
Arkose Labs frequently chains multiple audio rounds before releasing the page. 1-2 minutes is normal for FunCaptcha specifically — set your timeout accordingly rather than treating it as a stuck job.
Questions? Contact [support](mailto:hello@surfsky.io).
# GeeTest Captcha Bypass - Automatic Solver
Source: https://docs.surfsky.io/use-cases/geetest
Bypass GeeTest slider, icon, and behavioral CAPTCHA challenges automatically. Setup, v3/v4 differences, and troubleshooting with code examples.
GeeTest protects sites with interactive puzzles — most commonly a slider you drag into place — backed by behavioral analysis of the drag itself. Use `Captcha.autoSolve` for GeeTest. This type is handled by the external provider integration; sending it to `Captcha.solve` returns an error.
## Before you start
Enable the solver when [starting the browser](/captcha-solving#enable-solving):
```json theme={null}
{
"anti_captcha": {
"enabled": true,
"disable_external_providers": false,
"auto_captcha_types": ["geetest"]
}
}
```
Your account needs the corresponding external provider configuration. Check [solver balance](/api-reference/captcha/get-solver-balance) and [provider settings](/captcha-solving#provider-configuration) if solving does not begin.
## Automatic captcha solving
Use the [Playwright](/quickstart/playwright) or [Puppeteer](/quickstart/puppeteer) lifecycle, adding the settings above to the start request. Create a page-level CDP session and attach listeners before enabling automatic mode:
```python Python (async Playwright) theme={null}
cdp = await page.context.new_cdp_session(page)
cdp.on("Captcha.solveCompleted", lambda event: print("Completed:", event))
cdp.on("Captcha.solveFailed", lambda event: print("Failed:", event))
await cdp.send("Captcha.autoSolve", {"type": "geetest"})
await page.goto(target_url, wait_until="domcontentloaded")
```
```javascript JavaScript (Playwright) theme={null}
const cdp = await page.context().newCDPSession(page);
cdp.on("Captcha.solveCompleted", event => console.log("Completed:", event));
cdp.on("Captcha.solveFailed", event => console.error("Failed:", event));
await cdp.send("Captcha.autoSolve", { type: "geetest" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
Replace the target URL with your page. Add a bounded wait for the form result or protected content after navigation. `{"status": "started"}` only confirms that automatic mode started.
## Understanding GeeTest challenge types
GeeTest ships two active generations with different bypass considerations:
* **v3** — the page loads a `gt` and `challenge` parameter pair; the classic version.
* **v4** — the page loads only a `captcha_id` with scripts served from `/v4/` paths; adds more challenge variants and encrypts interaction data into a signed payload before validation.
Within either version, the widget itself can present as more than a slider:
| Type | What it asks for |
| - | - |
| Slider puzzle | Drag a puzzle piece into a cutout in a background image |
| Icon CAPTCHA | Click or drag icons matching a shown condition |
| IconCrush (v4 only) | Match-three style board with matching symbols |
| Gobang / Space | Board or coordinate placement variants |
| OneTap / no challenge | A single "Verify" click, or no visible widget at all, when GeeTest already trusts the session from prior signals |
Whichever variant appears, GeeTest scores the interaction itself — drag trajectory, timing between actions, and final position — not just whether the puzzle was technically completed. `Captcha.autoSolve` handles the detection and interaction for the variant it finds; you don't need to branch your code by type.
## Verify bypass worked
Check the challenge version and the actual page flow. A GeeTest widget may appear only after a form action. Start the automatic solver before that action, then wait for the site to accept the result.
Keep the CDP connection open while solving. Avoid running a second solver against the same challenge. If verification completes but the page is still blocked, inspect the next network response or form validation error.
## Troubleshooting
If nothing happens, confirm that the external integration was loaded, `"geetest"` is allowed in `auto_captcha_types`, and the challenge is present. Observe failure events and check provider availability before retrying.
See [CAPTCHA troubleshooting](/captcha-solving#troubleshooting) for shared errors and [DevTools](/debugging) to inspect a running attempt. Stop the browser after the job completes.
## GeeTest bypass FAQ
No — GeeTest only supports automatic mode through `Captcha.autoSolve`. Sending `"geetest"` to `Captcha.solve` returns an error; there is no manual solving path for this type.
Check the page source or network requests. If you see a `gt` and `challenge` parameter pair, it's v3. If you only see `captcha_id` with scripts loading from `/v4/` paths, it's v4. `Captcha.autoSolve` handles both without needing you to specify which one.
GeeTest can skip the challenge entirely and accept a single click, or nothing visible at all, when it already trusts the session from prior behavior — this is normal, not a sign the page isn't protected. Start the automatic solver before the triggering form action so it's ready either way.
It only confirms automatic mode began — not that a challenge was found, solved, or accepted. Attach `Captcha.solveCompleted` and `Captcha.solveFailed` listeners and wait for the actual page result before treating the job as done.
Questions? Contact [support](mailto:hello@surfsky.io).
# Image CAPTCHA
Source: https://docs.surfsky.io/use-cases/image
Read text from a CAPTCHA image and submit the returned solution in your page.
The `image` solver sends an image to the configured CapMonster provider and returns recognized text. Your script must enter the text and submit the form.
## Before you start
Enable `anti_captcha.enabled`, configure a CapMonster key through your account, and create a page-level CDP session with the [CAPTCHA setup](/captcha-solving#connect-and-solve).
The image URL must be reachable by the solver's HTTP client. It is fetched separately from the browser, so browser cookies and headers are not automatically attached. A URL that works only in your logged-in tab may fail here.
## Manual solving
After navigating, obtain the absolute image URL from your page. Replace the selectors with those the site uses:
```python Python (async Playwright) theme={null}
image_url = await page.locator("img.captcha").evaluate("img => img.src")
result = await cdp.send("Captcha.solve", {
"type": "image",
"timeout": 60000,
"options": {"image_url": image_url},
})
if result.get("status") != "success" or not result.get("solution"):
raise RuntimeError(result)
await cdp.send("Human.click", {"selector": "input[name='captcha']"})
await cdp.send("Human.type", {"text": result["solution"]})
await cdp.send("Human.click", {"selector": "button[type='submit']"})
```
```javascript JavaScript (Playwright) theme={null}
const imageUrl = await page.locator("img.captcha").evaluate(img => img.src);
const result = await cdp.send("Captcha.solve", {
type: "image",
timeout: 60000,
options: { image_url: imageUrl },
});
if (result.status !== "success" || !result.solution) {
throw new Error(JSON.stringify(result));
}
await cdp.send("Human.click", { selector: "input[name='captcha']" });
await cdp.send("Human.type", { text: result.solution });
await cdp.send("Human.click", { selector: "button[type='submit']" });
```
Automatic mode is not supported for `image`. Supply an HTTP or HTTPS image URL; the current solver fetches it as a URL and does not accept a Base64 image body.
## Check the result
The response contains `status`, `type: "image"`, and `solution` on success. Recognition can succeed while the website rejects the answer. Wait for the site's submission result and check whether the image refreshed before the answer was entered.
If the solver fails, check the image URL's accessibility, the provider configuration, and balance. Keep the same page open so the image and form belong to the same challenge attempt. See [CAPTCHA troubleshooting](/captcha-solving#troubleshooting).
# PerimeterX Bypass - HUMAN Security Captcha Solver
Source: https://docs.surfsky.io/use-cases/perimeterx
Bypass PerimeterX (HUMAN Security) press-and-hold challenges automatically, no API key required. Manual and automatic modes with code examples.
PerimeterX — now rebranded as HUMAN Security — is a bot protection system found on high-traffic e-commerce, ticketing, and login pages. The visible challenge is a press-and-hold button. The Surfsky `perimeterx` solver presses and holds it in the browser page, automatically or manually, and reports whether the site accepted the hold. No API key is required.
## Before you start
Use the [CAPTCHA setup example](/captcha-solving#connect-and-solve) to start a browser with `anti_captcha.enabled: true` and create a page-level `cdp` session. The snippets below use an async Playwright session after navigating to your target.
The press-and-hold button often sits inside a closed Shadow DOM, specifically to break standard element locators like Selenium's `find_element`. Surfsky's solver interacts with the challenge at the browser level rather than through a locator, so this doesn't affect the solve — but it's why a manual DOM-based approach to this challenge tends to fail.
## Manual captcha solving
Wait for the challenge to appear, then send `Captcha.solve`:
```python Python theme={null}
await page.wait_for_selector("#px-captcha, iframe#px-captcha-modal", state="attached")
result = await cdp.send("Captcha.solve", {
"type": "perimeterx",
"timeout": 60000,
})
if result["status"] != "success":
raise RuntimeError(result)
```
```javascript JavaScript theme={null}
await page.waitForSelector("#px-captcha, iframe#px-captcha-modal", { state: "attached" });
const result = await cdp.send("Captcha.solve", {
type: "perimeterx",
timeout: 60000,
});
if (result.status !== "success") throw new Error(JSON.stringify(result));
```
Wait for `attached`, not the default `visible`: some sites keep an empty `#px-captcha` in the page and show the challenge in the modal iframe.
The solver takes no options and returns after the site has answered the hold.
| Result | Meaning |
| - | - |
| `success` | The challenge left the page. |
| `failed` | The challenge is still there: the site refused the hold, or the button did not render. |
| CDP error `No captcha detected on the page` | There was no challenge when the command arrived. |
The timeout is in milliseconds. An accepted attempt usually takes 10 to 20 seconds and a refused one up to 45, so keep it at 60000. Do not click, move the mouse, or navigate until the command returns. After `success`, a block page reloads into the page you asked for, so wait for your content before reading it.
## Automatic captcha solving
Include `"perimeterx"` in `anti_captcha.auto_captcha_types` at startup. Attach [solve event listeners](/captcha-solving#automatic-solving), then start detection before navigating:
```javascript theme={null}
await cdp.send("Captcha.autoSolve", { type: "perimeterx" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
Observe failures and use a bounded wait for your page result. Keep the CDP session connected until the job completes.
## Reducing challenges
PerimeterX scores the session, not only the hold. Keep the page in the same session while solving, and avoid clicking or navigating concurrently with the hold action — the challenge reads mouse movement and hold timing during that window, and a concurrent action can interrupt that signal.
## If the hold is refused
A refused hold shows "Please try again" and returns `failed`. Send `Captcha.solve` again. If that fails too, start a new browser on a different [proxy](/proxies).
## Verify bypass worked
`success` means the challenge left the page. Wait for the protected content or a successful response before treating the job as done. Inspect one attempt in [DevTools](/debugging), record the result or error before retrying, and stop the browser when the job ends.
## PerimeterX bypass FAQ
Yes. PerimeterX was rebranded as HUMAN Security; the press-and-hold challenge and the `perimeterx` solver type are unchanged.
No. The `perimeterx` solver works without a Gemini key or any other API key.
The challenge is reading mouse movement and hold duration during that window. A concurrent click or navigation interrupts that signal and can fail the solve — avoid interacting with the page until the command returns.
`failed` means the challenge is still on the page: the site refused the hold, or the button did not render. Retry once, then switch to a different proxy.
Questions? Contact [support](mailto:hello@surfsky.io).
# reCAPTCHA Bypass - Automatic v2, v3 & Enterprise Solver
Source: https://docs.surfsky.io/use-cases/recaptcha
Bypass Google reCAPTCHA v2, v3, and Enterprise automatically. Setup, provider configuration, and troubleshooting with code examples.
Google reCAPTCHA has three versions in active use — v2 (checkbox or invisible widget), v3 (score-based, often no visible interaction), and Enterprise (adds risk signals on top of standard v2/v3). Use `Captcha.autoSolve` for reCAPTCHA. This type is handled by the external provider integration; sending it to `Captcha.solve` returns an error.
## Before you start
Enable the solver when [starting the browser](/captcha-solving#enable-solving):
```json theme={null}
{
"anti_captcha": {
"enabled": true,
"disable_external_providers": false,
"auto_captcha_types": ["recaptcha"]
}
}
```
Your account needs the corresponding external provider configuration. Check [solver balance](/api-reference/captcha/get-solver-balance) and [provider settings](/captcha-solving#provider-configuration) if solving does not begin.
## Automatic captcha solving
Use the [Playwright](/quickstart/playwright) or [Puppeteer](/quickstart/puppeteer) lifecycle, adding the settings above to the start request. Create a page-level CDP session and attach listeners before enabling automatic mode:
```python Python (async Playwright) theme={null}
cdp = await page.context.new_cdp_session(page)
cdp.on("Captcha.solveCompleted", lambda event: print("Completed:", event))
cdp.on("Captcha.solveFailed", lambda event: print("Failed:", event))
await cdp.send("Captcha.autoSolve", {"type": "recaptcha"})
await page.goto(target_url, wait_until="domcontentloaded")
```
```javascript JavaScript (Playwright) theme={null}
const cdp = await page.context().newCDPSession(page);
cdp.on("Captcha.solveCompleted", event => console.log("Completed:", event));
cdp.on("Captcha.solveFailed", event => console.error("Failed:", event));
await cdp.send("Captcha.autoSolve", { type: "recaptcha" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
Replace the target URL with your page. Add a bounded wait for the form result or protected content after navigation. `{"status": "started"}` only confirms that automatic mode started.
## Reducing challenges
reCAPTCHA v2, v3, and Enterprise can behave differently. A v2 challenge may display a widget, while v3 relies on a score and can require no visible interaction. Verify the form submission or protected response for the version the site uses; Surfsky does not promise a particular score.
Keep the CDP connection open while solving. Avoid running a second solver against the same challenge — a second concurrent attempt at the same widget can invalidate both.
## reCAPTCHA v3 score
reCAPTCHA v3 never shows a widget. Instead, Google returns a numeric score from **0.0 to 1.0** for every request — 1.0 means the interaction looks very likely human, 0.0 means it looks very likely automated. The target site reads this score in its own server-side response and decides what to do with it; there is no universal "passing" number, because each site sets its own threshold.
Google's official response format for a v3 check includes:
```json theme={null}
{
"success": true,
"score": 0.9,
"action": "login",
"challenge_ts": "2026-09-08T12:00:00Z",
"hostname": "example.com"
}
```
A few things worth knowing about how the score behaves:
* **Google doesn't publish the scoring formula.** The score reflects a mix of browsing behavior, IP reputation, and session signals Google doesn't fully disclose — there's no fixed checklist to "pass."
* **0.5 is a common default threshold, not a Google-mandated one.** Sites choose their own cutoff; a score that clears one site's threshold can fail another's.
* **Enterprise adds more granularity.** The free tier effectively reports coarse bands, while an Enterprise project with billing enabled can request an 11-level scale across the same 0.0–1.0 range, plus reason codes explaining why a score landed where it did.
* **A score near 0.9 on Google's public test keys is expected**, not a sign the integration is broken — Google's shared test key pair always returns a fixed score for wiring up an integration, independent of real traffic.
Surfsky's solver interacts with the page the same way regardless of the version, but it cannot see or influence the score a target site's backend assigns after the fact — that check happens entirely on Google's and the site's side, not in the browser session.
## Verify bypass worked
If verification completes but the page is still blocked, inspect the next network response or form validation error — a v3 score that clears the widget does not guarantee the site's own check accepted the request.
## Troubleshooting
If nothing happens, confirm that the external integration was loaded, `"recaptcha"` is allowed in `auto_captcha_types`, and the challenge is present. Observe failure events and check provider availability before retrying.
See [CAPTCHA troubleshooting](/captcha-solving#troubleshooting) for shared errors and [DevTools](/debugging) to inspect a running attempt. Stop the browser after the job completes.
## reCAPTCHA bypass FAQ
No — reCAPTCHA only supports automatic mode through `Captcha.autoSolve`. Sending `"recaptcha"` to `Captcha.solve` returns an error; there is no manual solving path for this type.
That's often expected, not broken. Both v3 and invisible v2 are designed to require no visible widget for most visitors — the score-based check runs in the background, and a challenge only surfaces when the risk signal is high enough. If your automation never sees a checkbox or puzzle, verify the form submission or protected response instead of waiting for a widget that may never render.
v2 invisible still returns a pass/fail outcome and can escalate to a visible challenge if Google's risk check is uncertain. v3 never shows a widget and instead returns a numeric score for the target site to interpret on its own threshold — Surfsky solves both the same way through `auto_captcha_types`, but the target site's handling of the result differs by version.
No. v3 is score-based and the required threshold is set by the target site, not by the solver — see [Understanding the reCAPTCHA v3 score](#understanding-the-recaptcha-v3-score) for what the 0.0–1.0 range means and why Google doesn't publish the exact formula. Surfsky attempts to clear the widget but does not promise a particular score.
It only confirms automatic mode began — not that a challenge was found, solved, or accepted. Attach `Captcha.solveCompleted` and `Captcha.solveFailed` listeners and wait for the actual page result before treating the job as done.
Enterprise adds risk signals on top of standard v2 or v3 checks and is billed separately by Google. The solving flow through Surfsky is the same `Captcha.autoSolve` call — the difference is in what the target site checks on its side, not in how you call the API.
Questions? Contact [support](mailto:hello@surfsky.io).
# Cloudflare Turnstile Bypass - Automatic Captcha Solver
Source: https://docs.surfsky.io/use-cases/turnstile
Bypass Cloudflare Turnstile challenges automatically. Managed, Non-Interactive, and Interactive modes with manual and automatic solving code examples.
Cloudflare Turnstile runs invisibly for most visitors and only shows a visible challenge when traffic looks suspicious — it can appear as a Managed, Non-Interactive, or Interactive challenge depending on the risk signal. Surfsky includes an internal manual solver and automatic mode; for a first test, use manual mode after the challenge appears.
## Before you start
Use the [CAPTCHA setup example](/captcha-solving#connect-and-solve) to start a browser with `anti_captcha.enabled: true` and create a page-level `cdp` session. The snippets below use an async Playwright or Puppeteer session after navigating to your target.
The internal solver does not require an external provider key. To test it without another solver acting on the same challenge, start with `disable_external_providers: true`.
Turnstile signals include browser-level checks like TLS fingerprint and Client Hints, not just the visible widget. A page can still block a request that never shows a challenge at all — the invisible check is not the same as "no protection."
## Manual captcha solving
Wait for the challenge to appear, then send:
```python Python theme={null}
result = await cdp.send("Captcha.solve", {
"type": "turnstile",
"timeout": 60000,
})
if result["status"] != "success":
raise RuntimeError(result)
```
```javascript JavaScript theme={null}
const result = await cdp.send("Captcha.solve", {
type: "turnstile",
timeout: 60000,
});
if (result.status !== "success") throw new Error(JSON.stringify(result));
```
The timeout is in milliseconds. Detection can raise a CDP error when the expected challenge is absent. After success, wait for a selector or response that identifies the protected content.
## Automatic captcha solving
Include `"turnstile"` in `anti_captcha.auto_captcha_types` at startup. Attach [solve event listeners](/captcha-solving#automatic-solving), then start detection before navigating:
```javascript theme={null}
await cdp.send("Captcha.autoSolve", { type: "turnstile" });
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
```
`status: "started"` confirms the background loop started. Observe failures and use a bounded wait for your page result. Keep the CDP session connected until the job completes.
## Reducing challenges
Most Turnstile checks run invisibly and never surface a challenge for a clean session — a widget only appears once the risk signal is high enough. If detection consistently fails to find the challenge, inspect the page for a Turnstile iframe and wait for it to load; the page may have already passed the invisible check, or it may be blocked before a challenge ever renders.
## Verify bypass worked
`status: "success"` confirms the challenge widget accepted the interaction, not that the target page unblocked the protected content. Wait for a selector or response that identifies that content before treating the job as done.
Check [solver configuration](/captcha-solving#provider-configuration), inspect one attempt in [DevTools](/debugging), and record the result or error before retrying. Stop the browser when the job ends.
## Turnstile bypass FAQ
No — most visitors pass invisibly. Cloudflare only surfaces a Managed, Non-Interactive, or Interactive widget when the risk signal from behavior and browser fingerprint is high enough.
No. The internal solver handles it without one. Use `disable_external_providers: true` if you want to confirm the internal solver alone is doing the work.
The page may have already passed the invisible check with no widget shown, or it may be blocking the request before any challenge renders. Inspect for a Turnstile iframe and wait for it to load before assuming detection is broken.
A success status confirms the widget accepted the interaction, not that the site's own check passed. Wait for the protected content or a successful response before assuming the job is done.
Questions? Contact [support](mailto:hello@surfsky.io).
# Why Surfsky
Source: https://docs.surfsky.io/why-surfsky
What Surfsky includes, when to use it, what it costs, and how it handles your data.
Surfsky hosts Chrome browsers with device fingerprints, proxies, CAPTCHA solving, and human input emulation. Use it to run automation that needs these features without maintaining the browser infrastructure yourself.
## What is included
* Antidetect browser. The Chrome engine applies fingerprints collected from real desktop and mobile devices. See [fingerprints](/fingerprints).
* Proxies. Over 100 million residential and mobile IPs, with country, city, or ASN targeting and UDP support. You can also supply your own proxy. See [proxies](/proxies).
* CAPTCHA solver. Solves on request or automatically, in the same session. See [CAPTCHA solving](/captcha-solving).
* Human emulation. Mouse, keyboard, and touch input with human timing and paths. See [Human commands](/human_emulation).
Connect with Playwright, Puppeteer, or Selenium, use the [Python or TypeScript SDK](/sdk), or call the [Scraping API](/quickstart/scraping_api).
## Flexibility
You can supply your own proxy, OpenVPN or WireGuard configuration, and CAPTCHA solver keys. Start with the default browser settings and override the fingerprint, connection, or solver options your task requires.
## Where it fits
* Managing multiple accounts on one platform with a separate device identity for each.
* Sites using DataDome, Akamai, Cloudflare, and other bot protection systems.
* Registration, login, checkout, and other workflows that require a browser and human input emulation.
* Mobile workflows that need Android phone or tablet fingerprints.
* Workloads that need a large residential or mobile proxy pool. Both connection types have the same traffic price.
* Teams that want to outsource browser hosting and maintenance.
## When you do not need it
If the site has no bot protection, a local browser or HTTP client may be sufficient. Consider those options if you can maintain the setup yourself.
## How it compares
* Automation frameworks can leave traces that detection scripts recognize. See the connection guides for [Playwright](/quickstart/playwright), [Puppeteer](/quickstart/puppeteer), and [Selenium](/quickstart/selenium).
* Stealth plugins modify JavaScript properties. Surfsky applies its fingerprint in the browser engine; plugin overrides can conflict with it. See [troubleshooting](/troubleshooting#keep-the-page-intact).
* Hosting browsers yourself means maintaining the browser fleet and proxy connections. Surfsky manages that infrastructure and provides browser, CAPTCHA, and input APIs.
## What you pay for
On-demand plans bill browser hours, proxy traffic, and CAPTCHA solving by usage. Reserved plans provide fixed capacity for sustained workloads. See [pricing](https://surfsky.io/pricing) for rates.
## Your data
Each browser runs in an isolated container. Persistent profile data is encrypted at rest. Customer data and use cases are not disclosed.
Start with the [Quickstart](/quickstart).