# Proxies: Mosaic API reference

> Proxies in Mosaic’s local REST API: 24 routes, each with its method, path, fields, what it returns and the errors it names.

Source: https://mosaicbrowser.com/docs/api/proxies

**Proxies** REST API

## Proxies

A **library** of the exits you keep: named, checked on a schedule, shared between profiles, and edited in one place — edit one and every profile using it follows. It is not the only way to give a profile a proxy, and for most automation it is not the one you want: pass a proxy **string** in the `proxy` field of `POST /profiles` or `PATCH /profiles/:id` and it is attached to that profile alone, with nothing added here. Save a proxy when you will reuse it and want one place to change it; paste one when it is a session that will be gone by lunchtime.

- [GETList saved proxies](#list-proxies)
- [POSTSave a proxy](#create-proxy)
- [GETWhich proxies are being checked](#proxies-checking)
- [GETRead one proxy](#get-proxy)
- [PATCHUpdate a proxy](#update-proxy)
- [DELETEDelete a proxy](#delete-proxy)
- [POSTCheck a saved proxy](#check-proxy)
- [POSTScan a proxy’s reputation](#proxy-reputation)
- [POSTRead a proxy list without saving](#parse-proxies)
- [POSTImport a proxy list](#import-proxies)
- [GETExport proxies as text](#export-proxies)
- [POSTCheck several proxies](#check-proxies)
- [POSTRotate a proxy through its change-IP link](#rotate-proxy)
- [POSTRotate several proxies](#rotate-proxies)
- [POSTAttach proxies to profiles](#assign-proxy)
- [POSTMove proxies between projects](#proxy-scope)
- [GETList known providers](#proxy-providers)
- [POSTCheck a proxy without saving it](#check-adhoc-proxy)
- [POSTMatch the identity to the proxy exit](#match-exit)
- [POSTMatch many identities to their exits](#bulk-match-exit)
- [POSTMove the hardware onto a real machine](#repair-hardware)
- [POSTRepair hardware across many profiles](#bulk-repair-hardware)
- [POSTCheck a profile’s own proxy](#check-profile-proxy)
- [POSTRun a leak test](#leak-test)

## [List saved proxies](#list-proxies)

GET`/api/v1/proxies`

Every saved proxy, each with a `usedBy` count and its last check and reputation results.

#### Query parameters

| Field | Type | What it is |
| --- | --- | --- |
| `projectId` | `string` | Restrict to one project. |

#### Returns

`{ total, proxies: SavedProxy[] }` — each with `usedBy`.

## [Save a proxy](#create-proxy)

POST`/api/v1/proxies`

Stores a proxy for reuse. Credentials never reach the browser’s command line — Chromium cannot accept them there — so at launch the proxy is fronted by a loopback relay that authenticates upstream on the profile’s behalf.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `host`required | `string` | Hostname or IP. |
| `port`required | `number` | Port. |
| `type` | `"http" \| "https" \| "socks4" \| "socks5"` | Default http. |
| `name` | `string` | Defaults to host:port. |
| `username` | `string` | Upstream username. |
| `password` | `string` | Upstream password. |
| `provider` | `string` | Known provider id, which selects the sticky-session format. |
| `sessionId` | `string` | Sticky-session token injected into the username. |
| `rotationUrl` | `string` | URL hit before launch to force a new exit IP. |
| `rotateOnLaunch` | `boolean` | Mint a fresh sticky-session token at every launch: one exit for the whole run, a different one next time. Different from a rotating gateway, which changes exit on every *connection* — and on a rotating port this does nothing, because the gateway answers `400 Can not use -session- on rotating ports`. Applied in memory and never written back. Leave it off for an identity: a logged-in profile arriving from a new city each launch is telling the site so. |
| `countryCode` | `string` | Two-letter code the seller states. Shown as a flag until a check measures the real exit, which then wins. |
| `city` | `string` | Stated city. Equally unverified. |
| `projectId` | `string \| null` | null makes it available to every project. |

#### Returns

The created `SavedProxy`.

## [Which proxies are being checked](#proxies-checking)

GET`/api/v1/proxies/checking`

The ids of every proxy with a check in flight this second. A check is owned by the app rather than by the screen that started it, so it keeps running when you navigate away and when a caller disconnects — which means a caller that arrives mid-batch would otherwise have no way to tell forty running checks from none, and would start them all again. Poll this before `POST /proxies/:id/check` on anything you did not start yourself. An empty list means nothing is in flight, not that nothing has ever been checked: the last result lives on the proxy.

#### Returns

`{ total, checking: string[] }` — proxy ids.

## [Read one proxy](#get-proxy)

GET`/api/v1/proxies/:id`

One saved proxy, including its last check and reputation.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

A `SavedProxy`.

## [Update a proxy](#update-proxy)

PATCH`/api/v1/proxies/:id`

Merges the fields you send. Every profile referencing it picks up the change on next launch.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Body

`Partial<SavedProxy>`. Any subset.

#### Returns

The updated `SavedProxy`.

## [Delete a proxy](#delete-proxy)

DELETE`/api/v1/proxies/:id`

Removes it. Profiles using it fall back to a direct connection, so check `usedBy` first.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

`{ deleted: true }`

## [Check a saved proxy](#check-proxy)

POST`/api/v1/proxies/:id/check`

Establishes three things that fail independently, because a check that collapses them into one boolean leaves you guessing which one broke. **Where it exits** — IP, country, city, ISP, ASN and whether the range looks like a datacentre, measured rather than taken from what the proxy was sold as. **Whether it tunnels** — a second probe over `CONNECT`, which is the path effectively all browsing takes; a gateway can answer plain HTTP perfectly and refuse to tunnel, and that proxy passes a naive check and then loads nothing in a browser. **Whether it holds still** — the two probes are separate connections, so an endpoint that hands out a different exit each time is reported as `rotating`, which is not a fault but does mean no profile behind it can hold a timezone. **Where it resolves** — one more request through the same relay asks which recursive resolver served a one-time hostname, which is the exit node’s resolver rather than yours, and `dnsMismatch` is true when its country is not the exit’s. That is not a browser setting and no profile can change it: the gateway resolves the names we hand it. It is reported because consistency scanners score it and the deduction is large — Whoer charges **−30%** for “Your DNS server country differs from the IP country”, its single biggest. Measured on an IP Oasis US session with plain `curl` and no browser in the path: exit Verizon in the **United States**, resolver Google LLC in **Germany** and then the **Netherlands**. A resolver that could not be read leaves all three fields absent rather than reporting agreement, and never fails the check. A transport failure is retried up to three times before it is called a failure, because a residential pool hands out a dead exit some of the time and one observation is not a verdict; `attempts` says how many it took. Scoring the exit against blocklists happens here too rather than being a separate errand. The result is cached on the proxy, and a successful check corrects the stored country — unless the endpoint rotates, in which case there is no country to correct it to.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

`{ ok, ip, country, countryCode, city, region, postalCode, isp, asn, hosting, timezone, latitude, longitude, latencyMs, attempts, tunnel: "ok"|"failed", tunnelError, tunnelIp, tunnelCountryCode, dnsCountryCode, dnsIsp, dnsMismatch, rotating, reputation, error }`

## [Scan a proxy’s reputation](#proxy-reputation)

POST`/api/v1/proxies/:id/reputation`

Connecting is not the same as being usable. This resolves the exit IP against public DNS blocklists, looks for a hosting or datacentre PTR record, and scores the result. The lookups go out over your own resolver rather than through the proxy, because a proxy is not a witness to its own reputation. `POST /proxies/:id/check` already does this, so reach for it only to re-score an exit without re-checking the connection. It **refuses** a proxy that has never answered — there is no exit address to score, and running a connectivity check first would be an errand the caller did not ask for and cannot see. On a rotating endpoint the report says so: the address scored is one draw from the pool, not the exit a profile will browse from.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

`{ ip, risk (0–100), verdict: "clean"|"caution"|"risky", hosting, reverseDns, isp, asn, listings[], listsChecked, notes[], checkedAt }`

#### Errors

- 400 — the proxy has never answered, so it has no exit address to score. Check it first.

## [Read a proxy list without saving](#parse-proxies)

POST`/api/v1/proxies/parse`

Reads a pasted list — or the contents of a `.txt` or `.csv` file — and says what every line came out as. Nothing is saved; use it to preview an import.

**Formats are detected per line**, in any mix: `host:port`, `host:port:user:pass`, `user:pass:host:port`, `user:pass@host:port`, `host:port@user:pass`, `scheme://user:pass@host:port` with `http`, `https`, `socks4` or `socks5`, IPv6 in brackets (`[2001:db8::1]:8080`, with or without `:user:pass`), and `host port user pass` separated by spaces or tabs. A trailing `|US` on a line is the country the seller says it is in. **A table with a header row** — separated by commas, tabs or semicolons, what every other anti-detect browser exports — is read by column name: `host`/`ip`/`server`, `port`, `username`/`login`, `password`, `type`/`protocol`, `country`, `name`, `rotation_url`/`change_ip_url`, `tags`, `notes`, or one `proxy` column holding a whole line. Columns it does not use are listed in `ignoredColumns` rather than silently dropped.

Every line comes back with a **status**: `ok`, `duplicate` (the same proxy earlier in this list), `exists` (already saved — compared against what `projectId` can see, or every proxy when it is absent) or `error`. Every one that will not be imported carries a **reason that names the part it could not read** — the port that is not a number, the scheme that is not a proxy type, the IPv6 address without brackets. For a provider whose gateway ports name their protocol — CatProxies — a line with no scheme takes its `type` from the port; an explicit `socks5://` always wins.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `text`required | `string` | The list, up to 5 MB. |
| `type` | `string` | `http`, `https`, `socks4` or `socks5` — assumed when a line does not say. Default http. |
| `projectId` | `string` | Compare against the proxies this project can see when deciding `exists`. |

#### Returns

`{ format: "lines"|"csv"|"tsv"|"semicolon", columns, ignoredColumns, lines: [{ line, text, status, reason, proxy, existingId }], counts: { ok, duplicate, exists, error }, proxies, errors }` — `proxies` and `errors` are the `ok` and `error` lines on their own, as this route always returned.

## [Import a proxy list](#import-proxies)

POST`/api/v1/proxies/import`

The same reading as `POST /proxies/parse`, and every `ok` line is saved, in one write. A proxy that is already saved is **skipped, not added twice**, and named in `skipped` with the row it matches; a line that could not be read is in `errors` with its reason. Nothing is dropped without a line saying so.

`namePrefix` names the new rows `Shop 1`, `Shop 2`, … and continues after the highest number that prefix already has, so a second import does not make a second `Shop 1`; a table’s `name` column wins over it. With `check: true` every new proxy is checked after it is saved, `concurrency` at a time, and `checked` names which answered and which did not — the import itself has already succeeded either way.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `text`required | `string` | The list, in any format `POST /proxies/parse` reads. |
| `type` | `string` | Default type for lines that do not say. |
| `projectId` | `string \| null` | Project to file them under; null or absent for every project. |
| `namePrefix` | `string` | Up to 60 characters. Names the rows `<prefix> <n>`. |
| `tags` | `string[]` | Added to every imported proxy, beside any tags a table row carried. |
| `check` | `boolean` | Check every new proxy after import. Default false. |
| `concurrency` | `number` | Checks in flight at once, 1–16. Default 6. |

#### Returns

`{ added, proxies, skipped: [{ line, text, reason }], errors: [{ line, text, reason }], counts, checked: { checked, ok: [{ id, name, ip, countryCode }], failed: [{ id, name, reason }] } | null }`

```
curl -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"text":"socks5://u:p@1.2.3.4:1080\n5.6.7.8:8080:u:p|DE","namePrefix":"Shop","check":true}' \
  "$BASE/proxies/import"
```

## [Export proxies as text](#export-proxies)

GET`/api/v1/proxies/export`

The saved proxies as a list another tool reads — including this one, so an export imports back unchanged. `url` is `scheme://user:pass@host:port` with the credentials percent-encoded, the one form every importer understands; `colon` is `host:port:user:pass`, what most sellers print, which cannot carry a type; `csv` has a header row with `name,type,host,port,username,password,country,rotation_url,tags,notes`. **Passwords are in the output**, because a list without them imports nothing.

#### Query parameters

| Field | Type | What it is |
| --- | --- | --- |
| `format` | `string` | `url` (default), `colon` or `csv`. |
| `ids` | `string` | Comma-separated proxy ids. An unknown one is a `400` naming it. |
| `projectId` | `string` | Only the proxies this project can see. |

#### Returns

`{ format, count, text }`

## [Check several proxies](#check-proxies)

POST`/api/v1/proxies/check`

Checks a batch of saved proxies, a few in flight at a time — a hundred at once would exhaust the socket table before the seller rate-limits you — and records each result on its proxy, as the single check does. The answer names every proxy that failed and why, because “checked 40” over eight that did not answer is a count rather than an answer. By id: there is no form of this that means all of them.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Saved proxy ids. |
| `concurrency` | `number` | Checks in flight at once, 1–16. Default 6. |

#### Returns

`{ checked, ok: [{ id, name, ip, countryCode }], failed: [{ id, name, reason }] }`

## [Rotate a proxy through its change-IP link](#rotate-proxy)

POST`/api/v1/proxies/:id/rotate`

For a mobile or residential port whose seller gives it a change-IP URL (`rotationUrl`). Mosaic fetches the link — from this machine, never through a browser carrying your cookies — waits a few seconds for the modem to re-dial, and checks the proxy. The answer says what the exit was, what it is now and whether they differ: a link answering 200 is not the same as the IP changing, and only the second is what you wanted. A link that answers anything but 2xx is a `502` naming the status, and nothing was rotated; a proxy with no link is a `400`. The check is recorded on the proxy, as `POST /proxies/:id/check` does.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

`{ id, name, status, before, after, changed, check }` — `changed` is `null` when either exit is unknown.

#### Errors

- 400 — the proxy has no change-IP link.
- 502 — the link answered with an error or could not be reached.

## [Rotate several proxies](#rotate-proxies)

POST`/api/v1/proxies/rotate`

The route above over a list, one after another — several rotations of one seller’s ports at once is a pattern sellers rate-limit. One proxy failing does not stop the rest; every failure is named. By id: there is no form of this that means all of them.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Saved proxy ids. |

#### Returns

`{ rotated: [{ id, name, status, before, after, changed, check }], failures: [{ id, name, reason }] }`

## [Attach proxies to profiles](#assign-proxy)

POST`/api/v1/proxies/assign`

Four modes. **`one`** points every listed profile at `proxyId`, and `null` detaches. **`roundRobin`** deals `proxyIds` out in order, one per profile, wrapping when there are more profiles than proxies. **`unique`** gives each profile a proxy **no other profile holds**, and is refused — naming how many are free — when there are not enough, rather than quietly wrapping: the point of owning forty proxies is forty exits. **`country`** picks proxies that come out in `country` — the measured country where a check has run, the declared one otherwise — unused ones first, then the least shared. For `unique` and `country` the pool is `proxyIds` when given, otherwise every saved proxy the profile’s project can see; a proxy scoped to another project is never handed out. Every choice is made before any profile is written, so a refusal changes nothing. `reused` says whether any proxy is now shared by two of these profiles — sometimes deliberate, sometimes the bug that links two accounts — and when it is, `warnings` carries a `shared-exit` entry naming the most profiles on any one proxy. Sharing is allowed; it is never silent.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `profileIds`required | `string[]` | Profiles to change, in order. |
| `mode` | `string` | `one`, `roundRobin`, `unique` or `country`. Default `roundRobin` when `proxyIds` is given, `one` otherwise. |
| `proxyId` | `string \| null` | Mode `one`: one proxy for all of them, or null to detach. |
| `proxyIds` | `string[]` | The pool. Required for `roundRobin`. |
| `country` | `string` | Mode `country`: a two-letter code, like `US`. |

#### Returns

`{ assigned, mode, reused, perProxy, assignments: [{ profileId, profileName, proxyId, proxyName }], warnings: [{ code: "shared-exit", profiles, message }] }`

## [Move proxies between projects](#proxy-scope)

POST`/api/v1/proxies/scope`

Sets which project a batch of proxies belongs to. `null` makes them available in every project; a project id pins them to that one. Profiles keep working across the change because they reference a proxy by id, not by scope. Proxies are created inside the project that added them precisely so that one customer’s list does not fill every other project’s dropdown.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | The proxies to move. |
| `projectId` | `string \| null` | `null` for every project. Default null. |

#### Returns

`{ changed: n }`

#### Errors

- 400 — `ids` is missing or empty.

## [List known providers](#proxy-providers)

GET`/api/v1/proxies/providers/list`

Providers Mosaic recognises, with their gateway conventions and how each encodes a sticky session in the username. `offered` marks the ones the app lists in its provider picker; the rest are still matched by hostname and still name themselves on a proxy already saved under them, they are simply not offered for a new one.

#### Returns

`{ total, providers: ProviderPreset[] }`

## [Check a proxy without saving it](#check-adhoc-proxy)

POST`/api/v1/proxy/check`

Same check as a saved proxy, against a config you pass inline.

#### Body

`ProxyConfig`. type, host, port, username, password.

#### Returns

A `ProxyCheckResult`.

## [Match the identity to the proxy exit](#match-exit)

POST`/api/v1/profiles/:id/match-exit`

Resolves where this profile actually comes out — through its proxy, or the direct connection when it has none — and brings the fingerprint into line with it. This is the same thing the launcher does on every start when `matchExitOnStart` is true; the endpoint exists so it can be done in bulk without opening a browser, which is what you want after attaching a proxy to profiles that already exist. It is also what the profile editor does by itself when you open a profile whose exit is known — there is no button for it on any surface, because a profile whose timezone contradicts its address should never be waiting on somebody to notice. **Timezone and geolocation are moved whenever they disagree** with the exit: neither is visible to a site that already knows the profile, because a session reads what the browser reports now rather than what it reported when it was created. **The language is moved only for a profile that has never run.** `Accept-Language` rides every single request, so switching a profile that has been speaking `de-AT` to a site for a month over to `en-US` tells that site something true about itself; for a profile with history the mismatch is reported in `skipped` instead of being applied. **`language: "move"` overrides that**, and it exists because the default, left in place for ever, is a dead end rather than a policy: a profile drawn on a European address and later put behind a US exit will send `de-AT` on every request it ever makes, and “report it and do nothing” leaves no way to fix it. Send `move` when the profile is not signed in anywhere; when it is, the change is one those sites see. Refused while the profile is running, because the identity was baked into the process at launch.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id` | `string` | Profile id. |

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `language` | `"auto" \| "move"` | What to do with `Accept-Language` on a profile that has already run. `auto` (the default) leaves it and reports the mismatch in `skipped`; `move` changes it to the exit country’s language. Any other value is a `400` naming the field. |

#### Returns

`{ changed, fields, skipped, from, to, reason, exitIp, profile }` — `fields` names what moved, `skipped` names what deliberately did not and why.

#### Errors

- `400` — the profile is running, or the proxy did not resolve so there is no exit to match against.

```
curl -X POST -H "Authorization: Bearer $KEY" "$BASE/profiles/$ID/match-exit"
```

## [Match many identities to their exits](#bulk-match-exit)

POST`/api/v1/profiles/bulk/match-exit`

The route above, over a list, and it is the one the problem actually has the shape of. A proxy gets edited — a different country flag in the username, a new session token, a swapped gateway — and every profile behind it is now claiming a timezone, a set of coordinates and a keyboard layout that disagree with where it comes out. That mismatch is the first comparison every consistency scanner makes. Calling the singular route forty times means sequencing forty live proxy checks yourself, and the first dead proxy ends the loop with a `400`. Here **no single profile can fail the batch**: the checks run one after another, deliberately rather than in parallel, because forty simultaneous requests to a residential pool is the traffic shape that gets an account rate-limited — which would then come back as forty proxies that "did not resolve". Three outcomes are reported separately and on purpose. `changed` counts the ones that moved. `unresolved` counts the ones whose exit could not be read at all, and **those were not changed** — it is not the same as agreeing, and reporting it as a success would be the 200-that-did-nothing this API refuses. Everything left over already agreed, which is the good case. A running profile counts as `unresolved` with a reason naming it, because its identity was baked into the process at launch.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids` | `string[]` | Profile ids. Required. |
| `language` | `"auto" \| "move"` | As on the singular route. `auto` is the default and leaves `Accept-Language` alone on any profile that has run; `move` applies it to every profile in the list, which is visible to every site each of them is signed in to. |

#### Returns

`{ total, changed, unresolved, results }` — one entry per profile carrying `changed`, `fields`, `skipped`, `exitIp` and, when it applies, `unresolved`.

#### Errors

- `400` — `ids` missing, not an array, or containing something that is not a string.

```
curl -X POST -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
  -d '{"ids":["'$ID'"]}' "$BASE/profiles/bulk/match-exit"
```

## [Move the hardware onto a real machine](#repair-hardware)

POST`/api/v1/profiles/:id/repair-hardware`

Rewrites the hardware half of the fingerprint so it describes a machine somebody can actually buy. The device catalogue makes an incoherent one unconstructible when a profile is *generated*; it does nothing for one that already exists, and profiles created before it drew their GPU, screen and thread count from three tables that knew nothing about each other — which is how an `Apple M4 Max` ends up reporting a `1680x1050` display, a resolution no Apple Silicon Mac has. **Only the hardware moves**: `deviceId`, `gpu`, `screen`, `hardwareConcurrency`, `deviceMemory`, the heap limit and the camera. The seed, the canvas and audio noise, the fonts, the locale, the timezone, the User-Agent, the proxy and the cookie jar are untouched, so this repairs a contradiction rather than issuing a new identity. It is **deterministic from the profile’s own seed** — calling it twice is a no-op, and calling it on three hundred profiles produces three hundred different machines rather than three hundred copies of the most popular row. A profile that already describes a real machine gets only its `deviceId` recorded, which nothing outside can observe. Refused while the profile is running, because the hardware was baked into the process at launch.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id` | `string` | Profile id. |

#### Returns

`{ changed, device: { id, model }, profile }` — `changed` lists the fields that moved and is empty when the profile was already coherent.

#### Errors

- `400` — the profile is running.

```
curl -X POST -H "Authorization: Bearer $KEY" "$BASE/profiles/$ID/repair-hardware"
```

## [Repair hardware across many profiles](#bulk-repair-hardware)

POST`/api/v1/profiles/bulk/repair-hardware`

The route above, over a list. This is the one that matters: the problem it fixes is never one profile, and a repair that has to be invoked a row at a time on a library of three hundred is one nobody invokes. A running profile is **skipped rather than refused** — failing the whole batch over one open browser is the wrong answer for a caller who asked about two hundred — and `skipped` names them so they can be done later.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids` | `string[]` | Profile ids. Required. |

#### Returns

`{ total, repaired: [{ id, device, changed }], alreadyCoherent, skipped: [{ id, reason }] }`. `alreadyCoherent` is counted rather than listed, because it is the answer for most of a healthy selection.

#### Errors

- `400` — `ids` is missing or empty.

```
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
  -d '{"ids":["'$ID'"]}' "$BASE/profiles/bulk/repair-hardware"
```

## [Check a profile’s own proxy](#check-profile-proxy)

POST`/api/v1/profiles/:id/proxy/check`

Checks whichever proxy the profile would actually use, and caches the result on the profile.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

A `ProxyCheckResult`.

## [Run a leak test](#leak-test)

POST`/api/v1/profiles/:id/leak-test`

Probes from inside a real page in the profile: the exit IP against the configured proxy, WebRTC ICE candidates for public address exposure, LAN address exposure, timezone against both the profile and the exit location, language, and geolocation. Starts the profile headlessly if needed. The `exit-ip` finding has three outcomes, not two — no proxy attached is a `pass` naming this machine, a proxy whose exit is known is compared against it, and a proxy that has never been checked is a `warn` asking for one. Read `webrtc` with that in mind: on a profile with no proxy the ICE candidates carry this machine’s own address, which is correct and not a leak, and the report says so rather than calling it a proxy exit.

#### Path parameters

| Field | Type | What it is |
| --- | --- | --- |
| `id`required | `string` | The record’s id. |

#### Returns

`{ profileId, findings: [{ id, label, status, detail }], observedIp, observedCountry, observedTimezone, webrtcCandidates }`

## Run the API on your own profiles

The REST API and the MCP server are part of Pro: $49 a month, with the first 14 days free. Free has every browser feature and no API key.

Mosaic is a desktop app

Email yourself the link and open it on your computer.
