REST API
The Scalebrowser daemon's native REST API: authentication, the profile, group and proxy endpoints, events, and the error codes a client has to handle.
The daemon's own HTTP API. It creates and starts profiles, assigns proxies and streams events, and it is the surface every other interface on this site sits on.
This page covers the routes you reach for, not all of them. The daemon serves 110 paths under /v1, and the ones left out here are either a detail of a surface described elsewhere or a block with its own page, such as profile sync or videos. Every one of them is a typed method on both SDKs, which is enforced by a test rather than by intention.
Base URL and authentication
Everything lives under /v1 on the native API, which binds 127.0.0.1:8787 by default. Every /v1 route requires a bearer token; it is compared in constant time, and a missing, malformed or wrong one is a 401 carrying error code 4010.
Authorization: Bearer <token>GET /health is the one exception. It sits outside the auth layer and answers {"status":"ok","service":"scalebrowser-api"} without a token, which is what makes it usable as a monitor probe. The token itself comes from your config file, from SCALEBROWSER_BEARER_TOKEN, or from scalebrowser-daemon --generate-token; see Install & run.
Loopback may serve plaintext; anything else must serve TLS. A non-loopback bind without a TLS configuration is refused at startup, so on a remote host the base URL is always https://.
Request and response shape
Bodies are JSON in both directions, and a success is the resource itself, a profile or a list of profiles, never an envelope. Errors are always these two fields:
{ "code": 4005, "message": "preflight failed: …" }code is a stable number for the failures you are expected to handle, and null for the generic ones: bad input, a conflict, an internal error. The full list is at the end of this page.
Updates are PATCH: partial, with absent fields left unchanged. PUT is routed to the identical handler on every updatable resource, so an older client that sends it keeps working; new code should send PATCH.
Profiles
| Method and path | What it does |
|---|---|
GET /v1/profiles | List profiles, newest first. Filters group, state, q (name substring) and enabled; paging limit (default 100) and offset; ordering sort and order. |
GET /v1/profiles/ids | The same filters, unpaged, ids only → { count, ids }. What a “select all matches” needs. |
GET /v1/profiles/count | How many profiles match group and q, split by state → { total, stopped, starting, running, crashed }. One small answer, whatever the fleet size. |
POST /v1/profiles | Create one profile → the Profile. |
GET /v1/profiles/:id | Fetch one, with its protection verdict. |
PATCH /v1/profiles/:id | Update name, enabled, geo_mode, expected_country, group_id, proxy_id, exit_exclusive. The last two are refused on a running profile. |
DELETE /v1/profiles/:id | Delete the row and remove the profile's browser directory. A running browser is stopped first. |
POST /v1/profiles/:id/start | Launch the browser → the CDP endpoint. |
POST /v1/profiles/:id/stop | Stop it → { "stopped": true }. Idempotent. |
POST /v1/profiles/:id/input | Dispatch one humanized gesture: move, click, type or scroll. See Human input. |
/v1/profiles/ids and /v1/profiles/count are registered ahead of /v1/profiles/:id, so neither literal is read as a profile id. /v1/proxies/check is handled the same way.
count takes no state parameter on purpose. Its caller is usually a screen that sits in one state and has to put a number on all of them, so a count narrowed by the active state would report zero for every other one. total counts rows rather than adding the four fields up, which keeps a profile in a state an older build does not know visible in the total instead of silently counted as stopped.
A listed or fetched profile carries a protection object alongside its own fields. That verdict is produced by the same functions the launch runs, so blocked means “this profile will refuse to start” rather than a second opinion. Coherence & proxies explains what is checked.
seed and persona_country are fixed at create time. Neither is part of the PATCH body: the whole identity is drawn from them, so a different value means a different profile rather than an edit. A PATCH carrying persona_country is refused with 400 instead of being quietly dropped, and the message names the way a live profile's region really moves.
The two calls everyone makes first
Create a profile. Only name is required. Everything else is drawn from a seed.
$ curl -X POST http://127.0.0.1:8787/v1/profiles \
-H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "shop-01", "geo_mode": "follow_exit", "proxy_id": "px_7f3a"}'The reply is the created profile. It is long, because the identity alone is dozens of fields, so these are the parts you act on:
{
"id": "pr_9c21e4",
"name": "shop-01",
"runtime_state": "stopped",
"enabled": true,
"geo_mode": "follow_exit",
"proxy_id": "px_7f3a",
"engine_version": "…",
"seed": "…",
"created_at": 1754300000,
"persona": { /* the whole identity: see /coherence */ }
}The other accepted create fields are seed (reproduce a known identity), engine_version, expected_country, persona_country (the region the identity is drawn for, see Profiles & personas) and group_id. Then start it:
$ curl -X POST http://127.0.0.1:8787/v1/profiles/pr_9c21e4/start \
-H "Authorization: Bearer $SCALEBROWSER_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"headless": false}'{
"profile_id": "pr_9c21e4",
"cdp_ws": "ws://127.0.0.1:41337/devtools/browser/2f1c…",
"debug_port": 41337,
"headless": false,
"pid": 48221,
"started_at": 1754300112
}cdp_ws points at the browser's own DevTools socket on loopback, on a port the engine picks per launch, not at the daemon. That endpoint is how you drive the page yourself, and Direct CDP covers it. The request body is optional: an omitted headless means headless, and false asks for a visible window.
A start is where the pre-launch checks run, so this is the call that returns 4002, 4003, 4004, 4005 or 4006.
Bulk operations
| Method and path | Body | What it does |
|---|---|---|
POST /v1/bulk/profiles | { preset_id, count, … } | Create N profiles from a preset. All-or-nothing: a mid-batch failure rolls back what it already created. |
POST /v1/bulk/start | { ids, headless? } | Start the batch, one after another. |
POST /v1/bulk/stop | { ids } | Stop each. |
POST /v1/bulk/delete | { ids } | Delete each. |
POST /v1/bulk/assign-proxy | { ids, proxy_id } | Set the proxy on each; null clears it. |
POST /v1/bulk/assign-extensions | { ids, ext_refs } | Replace each profile's extension set. An empty array gives them none. |
Every bulk call answers with a per-id list of { id, ok, error? }, so one bad id does not fail the batch. bulk/start adds three fields: started (how many browsers came up), remaining (ids never attempted) and stopped_reason when it ended early. headless is one decision for the whole batch.
A bulk start stops at the first capacity refusal. Running out of memory or slots says nothing about the profile that happened to be next, so continuing would report fifty identical failures that read like fifty broken profiles. Look at remaining: those ids were never tried, and retrying them once capacity is free is the right move. Any other per-profile error does not stop the run.
Groups and presets
A group is a flat label a profile belongs to. A preset is a stored operating configuration that POST /v1/bulk/profiles stamps out. Both are plain CRUD:
| Method and path | What it does |
|---|---|
GET /v1/groups · POST /v1/groups | List · create. |
GET|PATCH|DELETE /v1/groups/:id | Fetch · rename · delete. Deleting a group leaves its profiles alone; their group_id becomes null. |
GET /v1/presets · POST /v1/presets | List · create. |
GET|PATCH|DELETE /v1/presets/:id | Fetch · update name, constraints or config · delete. |
Proxies
| Method and path | What it does |
|---|---|
GET /v1/proxies · POST /v1/proxies | List · create. |
GET|PATCH|DELETE /v1/proxies/:id | Fetch · update · delete. Profiles referencing a deleted proxy keep existing, with proxy_id set to null. |
POST /v1/proxies/:id/check | Run the real check through a saved proxy: reachability, exit address and country, and the fingerprint verdict. Stores the result and emits a proxy_checked event. |
POST /v1/proxies/check | The same check on an unsaved configuration: test before you persist. Stores nothing, emits nothing, and adds latency_ms. |
Credentials are write-only. No endpoint returns them, and the type that carries them cannot be serialised at all, so a proxy password cannot leave through a response by accident. An unreachable proxy is reported as healthy: false, not as an error.
Changing the connection identity clears the cached exit. A PATCH touching host, port, kind or credentials drops the stored country and health, because the geo gate must not judge a new endpoint by the previous one's exit. Run POST /v1/proxies/:id/check afterwards. Changing only the rotation setting keeps the cache.
A proxy whose stored credentials cannot be decrypted, for instance a database restored without its key, is flagged credentials_unreadable. It stays listable and deletable, and every path that would use it fails closed: an authenticated proxy is never run without its credentials, because that would send the session out over the host's real address.
Extensions
Two levels: a daemon-wide library of packages, and which of them a profile loads.
| Method and path | What it does |
|---|---|
POST /v1/extensions | Upload a package. The body is the raw .crx file, not JSON. The route raises its body limit to 128 MiB for it. |
GET /v1/extensions | The library, newest first. |
GET /v1/extensions/:id · DELETE /v1/extensions/:id | Fetch · remove the package, its files and every profile's assignment of it. |
GET /v1/profiles/:id/extensions | The profile's assigned set, plus the exact switches its next launch will emit. |
POST /v1/profiles/:id/extensions | { ext_ref }: assign. Idempotent. |
DELETE /v1/profiles/:id/extensions | { ext_ref }: unassign. Idempotent. |
ext_ref is a library id, never a path. Note that the unassign call carries it in the request body: an HTTP client that silently drops bodies on DELETE will unassign nothing and report success.
Session bundles
| Method and path | What it does |
|---|---|
POST /v1/profiles/:id/session/export | { password, kinds? } → a password-encrypted bundle of cookies, localStorage, IndexedDB and service-worker state. |
POST /v1/profiles/:id/session/import | { password, bundle } → the bundle is unsealed into the target profile. |
An export that could only capture cookies is flagged as degraded in its own response rather than passed off as complete.
Live detector audit
Drive a running profile through the external detector pages and keep the verdict against the launch it was measured on. A run takes minutes of real network traffic, so it is two calls rather than one:
| Method and path | What it does |
|---|---|
POST /v1/profiles/:id/audit | Start a run and answer immediately. 409 if the profile is not running, or if a run is already in flight for it. |
GET /v1/profiles/:id/audit | State (idle, running or finished) plus the last report. |
Metrics
GET /v1/metrics answers with the resource picture behind the dashboard: the live running count, the configured capacity and budgets, host memory, CPU and GPU figures, and a per-profile breakdown. It is served from a background sampler, so the request path probes nothing and the numbers are at most one sampling interval old.
Each measured value is paired with a probe state of measured, unsupported or unavailable, so “zero” and “we could not read this” never look alike. A separate Prometheus endpoint exists for a deployed daemon; it is off by default and configured under [observability].
Event streams
Two transports, the same events, both behind the bearer token: GET /v1/events is Server-Sent Events with a 15-second keep-alive, and GET /v1/ws is a WebSocket. Both are server-to-client only. Each connection subscribes before its response is produced, so nothing published between your request and the first frame is lost, and a slow client skips events rather than being disconnected.
Each message is one JSON object with a type, an at in Unix seconds and a seq. There are 23 types, and Events is the page for them, along with the resync frame that tells you when you fell behind.
/v1/ws is this event stream and nothing else. It is not a CDP connection, which comes back from start.
The MCP endpoint
/v1/mcp is mounted inside the same bearer-auth layer, but it is a nested service rather than a plain route: it speaks Streamable HTTP, which uses POST, GET and DELETE on that one path. MCP server is the page for it.
GET /v1/mcp/sse answers 410 Gone. The old HTTP+SSE transport was removed rather than left to fail quietly, and the response names both the replacement and the breaking change: over MCP, profile.start no longer returns a cdp_ws. The REST start above still does.
Error codes
Every failing request answers { code, message }, where code is a stable number for the failures you are expected to handle by kind and null for the generic ones. The numbers are append-only: none is ever reused, and a retired one stays reserved.
The full table, with the HTTP status and the remedy for each, is on Errors. The three that surprise people most: 4003 means this machine is full and waiting helps, 4008 means your subscription is full and waiting does not, and 4012 means that one profile is open on another machine of yours.
Next
- Direct CDP: what to do with the
cdp_wsa start hands back. - MCP server: the same daemon, driven by an AI agent.
- AdsPower adapter: the compatibility surface for existing scripts.
- Coherence & proxies: what the checks behind
4002and4005actually verify.