---
title: "REST API"
description: "The Scalebrowser daemon's native REST API: authentication, the profile, group and proxy endpoints, events, and the error codes a client has to handle."
canonical: "https://scalebrowser.net/docs/rest"
---

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

# 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.

<Note>

**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](/docs/sync) or [videos](/docs/agents/videos). Every one of them is a typed method on both [SDKs](/docs/sdks), which is enforced by a test rather than by intention.

</Note>

## 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`.

```http
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](/docs/install#the-api-token).

<Note>

**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://`.

</Note>

## 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:

```json
{ "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](#error-codes).

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](/docs/behaviour).                                                           |

`/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](/docs/coherence) explains what is checked.

<Note>

**`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.

</Note>

## The two calls everyone makes first
Create a profile. Only `name` is required. Everything else is drawn from a seed.

```bash
$ 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:

```json
{
  "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](/docs/profiles)) and `group_id`. Then start it:

```bash
$ 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}'
```

```json
{
  "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](/docs/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.

<Note>

**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.

</Note>

## 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.

<Warning>

**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.

</Warning>

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](/docs/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](/docs/agents/mcp-server) is the page for it.

<Warning>

**`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.

</Warning>

## 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](/docs/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](/docs/cdp): what to do with the `cdp_ws` a start hands back.
- [MCP server](/docs/agents/mcp-server): the same daemon, driven by an AI agent.
- [AdsPower adapter](/docs/adspower): the compatibility surface for existing scripts.
- [Coherence & proxies](/docs/coherence): what the checks behind `4002` and `4005` actually verify.
