# Profiles: Mosaic API reference

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

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

**Profiles** REST API

## Profiles

The unit of work. A profile is an identity, a proxy, a cookie jar and a browser data directory.

- [GETList profiles](#list-profiles)
- [POSTCreate a profile](#create-profile)
- [GETRead one profile](#get-profile)
- [PATCHUpdate a profile](#update-profile)
- [DELETEMove a profile to the trash](#delete-profile)
- [POSTDuplicate a profile](#duplicate-profile)
- [POSTSave a profile’s proxy to the library](#save-profile-proxy)
- [POSTPut a profile on the desktop](#profile-shortcut)
- [POSTMove profiles between projects](#bulk-move)
- [POSTAdd or remove tags in bulk](#bulk-tags)
- [POSTApply one change to many profiles](#bulk-edit)
- [POSTClear a profile’s cache, keep its logins](#clear-cache)
- [POSTClear several profiles’ caches](#bulk-clear-cache)
- [POSTMove many profiles to the trash](#bulk-delete)
- [POSTStart several profiles](#bulk-start)
- [POSTStop several profiles](#bulk-stop)

## [List profiles](#list-profiles)

GET`/api/v1/profiles`

Returns a page of profile summaries for a project, ordered by `sort` — alphabetically by name unless you say otherwise. Each row carries its live status, so a separate status call per profile is never needed. Note that the app's own list defaults to most-recently-used instead, so an unsorted call here and the Profiles screen will not be in the same order; pass `sort=lastRunAt` to match the screen.

#### Query parameters

| Field | Type | What it is |
| --- | --- | --- |
| `os` | `string` | Comma-separated platforms to keep — `windows`, `macos`, `linux`, `android`. The app uses `android` for its Mobile section and the other three for Profiles, because a phone and a desktop are different populations rather than one list with a toggle. Filtering happens before paging, so `total` counts what matched; an unrecognised value is a `400` naming it rather than an empty page that looks like an empty project. |
| `projectId` | `string` | Restrict to one project. Omit to list every project. |
| `search` | `string` | Case-insensitive match against name, note and tags. |
| `tag` | `string` | Exact tag match. |
| `limit` | `number` | How many profiles to return. Omit for all of them; the cap is 1000. |
| `offset` | `number` | How many matching profiles to skip, for paging through a large collection. |
| `sort` | `string` | One of `name` (default), `createdAt`, `lastRunAt`, `os` or `proxy` — the same five columns the app's table sorts by. Anything else is a `400` rather than a silently ignored parameter. |
| `order` | `string` | `asc` or `desc`. Defaults to `asc` for the three text columns and `desc` for the two dates, which is what each is usually wanted in. A profile that has never run sorts last in both directions — `null` is not a date at the beginning of time. Ties break on profile id, so paging over a sorted list can never show a row twice or skip one. |

#### Returns

`{ total, limit, offset, profiles: ProfileSummary[] }` — each with `status` of idle | starting | running | stopping | error. `total` counts every row that matched the filters, not the number returned, so a caller can tell whether another page exists.

```
curl -H "Authorization: Bearer $KEY" \
  "$BASE/profiles?projectId=$PROJECT&tag=ads"
```

## [Create a profile](#create-profile)

POST`/api/v1/profiles`

Creates a profile with a freshly generated, coherence-checked fingerprint. Anything you leave out is generated or defaulted, so an empty body is valid — `POST /profiles` with `{}` is a working call. Passing `count` creates a batch in one call and numbers the names, because five hundred profiles sharing one name is unusable.

**Two things exist so an automation needs no setup calls.** `projectId` is optional and falls back to the system project, so you do not have to look one up. And `proxy` takes a plain proxy **string**, attached to this profile alone and saved nowhere — so a script does not have to `POST /proxies` first, and the Proxies page does not fill up with one row per rotating session. The shortest useful call is `{"proxy":"user:pass@gw.example.com:9000"}`.

**A profile created with a proxy is drawn for that proxy’s exit.** The proxy is measured before the identity is generated, and the country, timezone, languages and geolocation come from the address it really leaves by — so you do not pass `countryCode`, and you should not guess one. Until this existed, the same pasted European gateway produced `America/New_York` on one call and `America/Chicago` on the next: two draws from a share-weighted table, which is what *no* match looks like from outside. The lookup is cached per exact exit for five minutes, so `count: 500` costs one request.

Every response carries **`exitMatch`**, always. `matched: true` means a measured exit decided the locale. `matched: false` names the reason — a gateway that did not answer, `matchExit: false`, or a `countryCode` you passed yourself, which still wins. The profiles are created either way: a dead proxy is not a reason to hand back nothing. It is the one field that separates a batch that is right from a batch that is silently wrong, and they were byte-identical before it.

**A batch on one proxy is allowed, and said.** With `count` > 1, a pasted line, a `ProxyConfig` or a `proxyId` puts every profile in the batch behind one address. That is the operator’s choice and is not refused — but sites can link accounts that share an address, so the response carries a **`warnings`** entry `{ code: "shared-exit", profiles, message }` naming how many now share it. A provider (`{"providerId": …}`) mints one session, and so one exit, per profile, and is the recommended way to put a batch behind proxies. `warnings` is always present, empty when nothing is worth saying.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `projectId` | `string` | Target project. **Optional** — omitting it puts the profile in the system project, the one Mosaic created at first launch and the one that cannot be deleted. That is a fixed destination rather than “wherever the app window happens to be”, so a script that never names a project gets the same answer every run. `GET /projects` lists them; the system one carries `system: true`. |
| `name` | `string` | Display name. With `count` > 1 it is suffixed “ 1”, “ 2”, … **continuing after the highest number that name already has**, so a second batch of “Shop” starts at “Shop 21” rather than making a second “Shop 1”. Put `{n}` in the name to place the number yourself, or `{n:3}` to pad it to three digits — `Shop-{n:3}` gives Shop-001, Shop-002; a token works for a single profile too. A token that is almost one — `{num}`, `{n:x}` — is a `400` naming `name`, never a batch of profiles all called “Shop {num}”. |
| `startAt` | `number` | The first number the names take, 0–1000000. Omit to continue after the highest in use. Create-only, like `count`. |
| `count` | `number` | How many to create, 1–500. Default 1. Refused rather than clamped: `9000` and `"abc"` are both a `400` naming the field, because a batch that silently became 500 — or, on `"abc"`, silently became none — answers 200 either way and cannot be told from the one you asked for. |
| `note` | `string` | Free text shown in the list and searchable. |
| `tags` | `string[]` | Tag names; unknown tags are created. |
| `proxyId` | `string \| null` | Attach a proxy from the saved library. Use this when the exit is one you keep and share across profiles; use `proxy` for everything else. |
| `proxy` | `string \| ProxyConfig \| ProviderPick \| null` | A proxy for **this profile only**, and the field most automations want. Pass a plain **string** in any format the importer reads — `scheme://user:pass@host:port`, `user:pass@host:port`, `host:port:user:pass`, `host:port@user:pass`, or a bare `host:port` (scheme defaults to `http`). Nothing is added to the Proxies page: rotating sessions are not a library, and a run that saved one row per session would leave a list nobody can read of exits that stopped existing hours ago. A `ProxyConfig` object is still accepted. **If the proxy you pass is already saved** — same type, host, port and credentials — the profile links to that entry instead, so one exit is never described twice. Send `null` to remove the proxy entirely, which clears `proxyId` as well. A line that cannot be read is a `400` quoting it. **Or an object naming a provider**: `{"providerId": "…", "country": "US", "state": "California", "city": "Los Angeles", "minutes": 30}`. That mints a proxy from that seller’s template with a session id nothing else holds — which is how two profiles are kept off one exit address — and records where it came from on the profile. Every field but `providerId` is optional, and one the seller’s gateway cannot spell is a `400` naming it. See **Proxy providers**. |
| `fingerprintOptions` | `object` | Passed to the generator — `deviceId`, `os`, `brand`, `countryCode`, `timezone`, `languages`, `seed`. `deviceId` pins which machine from the catalogue the profile claims; see `GET /fingerprints/devices`. Naming `countryCode` or `timezone` here turns the exit lookup off: you have said where the profile is from, and a measurement whose answer would be discarded is not worth a request. |
| `matchExit` | `boolean` | Measure the attached proxy and draw the identity for the address it actually exits from. **On by default**, which is what you want — a profile whose timezone contradicts its exit fails the first comparison every consistency scanner makes. Pass `false` to skip the lookup: faster, and correct only when you do not care where the profile appears to be. It is a create-only instruction and not a field a profile has, so `PATCH` refuses it by name. |
| `startOptions` | `object` | `{ mode: "headful" \| "headless", restoreSession, windowWidth, windowHeight }`. |
| `startPages` | `string[]` | URLs opened as tabs on launch. |
| `extensions` | `string[]` | Absolute paths to unpacked extensions. |
| `templateId` | `string` | Start from a preset. It fills in whatever you did not pass and never overrides what you did — including its bookmarks, its default note and the provider it mints from. The fingerprint is still drawn fresh, from the preset’s own options, and the proxy is still minted per profile, so a batch of forty from one preset is forty exits rather than one. |
| `oneTime` | `boolean` | Make a throwaway: it is marked in the list and moves itself to the trash the moment its browser closes. Default false. |

#### Returns

The created `Profile`, plus `exitMatch` and `warnings`. With `count` > 1, `{ created, profiles: Profile[], exitMatch, warnings }`. `exitMatch` is `{ matched, source: "proxy" | "direct" | "none", countryCode, timezone, ip, reason }`; `warnings` is `[{ code: "shared-exit", profiles, message }]` when the batch shares one address, `[]` otherwise. Both are present on both shapes.

```
# No project, no saved proxy, no setup calls.
curl -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"name":"Scrape","proxy":"socks5://user:pass@gw.provider.com:1080"}' \
  "$BASE/profiles"

# A batch in a named project, with the identity drawn for Germany.
curl -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"name":"Ads","projectId":"'$PROJECT'","count":3,
       "fingerprintOptions":{"countryCode":"DE"}}' \
  "$BASE/profiles"

# Did the identity actually get paired with the exit? Never assume it did.
curl -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
  -d '{"proxy":"user:pass@gw.provider.com:9000"}' "$BASE/profiles" \
  | jq '{matched: .exitMatch.matched, tz: .fingerprint.locale.timezone, why: .exitMatch.reason}'
```

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

GET`/api/v1/profiles/:id`

The complete profile, including the full fingerprint, privacy flags, `companion` settings, start options, cookies and extension list. This is what you would edit and send back to PATCH.

#### Path parameters

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

#### Returns

A `Profile` with `status` attached.

#### Errors

- 404 — no such profile.

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

PATCH`/api/v1/profiles/:id`

Merges the fields you send. Anything touching how the browser launches — fingerprint, proxy, privacy, start options, extensions, extra arguments, cookies — is refused while the profile is running, because those values were baked into the process at launch and changing them would either be ignored or corrupt the session. Stop the profile first. `companion` is the exception: it is read by the built-in extension, which is rebuilt on every launch, so a change is accepted at any time and applies from the next start. Its fields are `autoRetry` (default `true` — reload a page that failed to load), `autoRetrySeconds` (default `10`) and `autoRetryLimit` (default `3`, per address, reset by a successful load).

#### Path parameters

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

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `…` | `Partial<Profile>` | Any subset of the profile’s own fields. |
| `proxy` | `string \| ProxyConfig \| ProviderPick \| null` | The same field as on create, and the quickest way to move a profile onto a new exit: pass the line. It **replaces** whatever the profile was using, saved proxy included, unless you send `proxyId` in the same body. `null` removes the proxy entirely and clears `proxyId`; omitting the field leaves the proxy alone. |
| `pinned` | `boolean` | Keep the profile at the top of `GET /profiles` in **every** order, an explicit `sort` included — a pin is set by hand to keep a row in reach. Not launch state, so it is accepted while the profile runs. |

#### Returns

The updated `Profile`.

#### Errors

- 400 — the profile is running and the patch touches launch state.
- 404 — no such profile.

## [Move a profile to the trash](#delete-profile)

DELETE`/api/v1/profiles/:id`

Stops the profile if it is running, then moves it to the trash with its browser data intact. Nothing is destroyed: it stays restorable for 30 days and is purged on the first start after that. Use the trash endpoints to restore or purge immediately. A browser that will not close is a `400` naming the profile, and the profile stays on the list — a trashed row has no Stop button, so hiding a running window behind one is the worse outcome.

#### Path parameters

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

#### Returns

`{ deleted: true, stopped }` — `stopped` says whether a browser was closed on the way.

#### Errors

- `400` — its browser would not close.

## [Duplicate a profile](#duplicate-profile)

POST`/api/v1/profiles/:id/duplicate`

Copies settings, extensions and start pages. By default the copy gets a brand-new identity, because a duplicate is a different machine — two profiles sharing one fingerprint is exactly the correlation an anti-detect browser exists to prevent. Pass `newFingerprint: false` only when you deliberately want a clone.

#### Path parameters

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

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `name` | `string` | Defaults to “<name> copy”. |
| `newFingerprint` | `boolean` | Default true. False copies the identity verbatim. |

#### Returns

The new `Profile`.

## [Save a profile’s proxy to the library](#save-profile-proxy)

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

Promotes a profile’s pasted, not-yet-saved proxy into the Proxies library and links the profile to it, so the same address can be reused and edited in one place. Refused by name when there is nothing to save: a profile already on a saved proxy, a profile with no proxy, or a provider-minted session — that last one is one launch’s session id, not a reusable address, and saving it would pin many profiles behind one exit.

#### Path parameters

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

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `name` | `string` | Name for the saved proxy. Defaults to “host:port”. |

#### Returns

The new saved `Proxy`.

#### Errors

- `400` — the profile has no custom proxy, already uses a saved one, or its proxy is provider-minted.
- `404` — no profile has that id.

## [Put a profile on the desktop](#profile-shortcut)

POST`/api/v1/profiles/:id/shortcut`

Writes a shortcut that opens this one profile: a small app bundle on macOS, a `.lnk` on Windows, a `.desktop` file on Linux, wearing the project’s colour and glyph. Opening it starts the profile’s browser **without the Mosaic window** — Mosaic runs in the background for as long as the browser is open (the proxy relay, the cookie key and the clean shutdown live there) and quits when it closes. If Mosaic is already running, the shortcut hands the profile to it instead. Making a shortcut again for the same profile in the same place replaces the earlier one, including after a rename. A file of somebody else’s at the same name is left alone and the new one takes a suffix. The shortcut is removed when the profile is purged — not when it is trashed, because a trashed profile can come back.

#### Path parameters

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

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `place` | `"desktop" \| "applications"` | Default `desktop`. `applications` is ~/Applications on macOS, the Start menu on Windows, ~/.local/share/applications on Linux. |

#### Returns

`{ path, place, platform, profileId, profileName }` — `path` is the file or bundle written.

#### Errors

- `400` — `place` is not one of the two, an unknown field was sent, or the profile is in the trash.
- `404` — no profile has that id.

## [Move profiles between projects](#bulk-move)

POST`/api/v1/profiles/bulk/move`

Reassigns profiles to one project. Nothing about a profile changes except which project it belongs to — the fingerprint, proxy, cookies, logins and history are untouched, and a running browser keeps running. **Tag names travel and are registered in the destination project**, because a tag’s colour is a row owned by a project: without that a moved profile keeps its tag words and silently loses their colours. A profile already in the target is not counted as moved, and one whose file cannot be read comes back in `missing` rather than being silently skipped — this used to answer `{ moved: ids.length }` whatever happened.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Profiles to move. An absent or non-array value is a `400`; an empty array moves nothing. |
| `projectId`required | `string` | Destination project. One that does not exist is a `400` naming the id, and nothing is moved. |

#### Returns

`{ count, moved: Array<{ id, name, from }>, missing: string[] }` — `count` is the number and `moved` is the list.

#### Errors

- `400` — `ids` is missing or not an array, `projectId` is missing, or no project has that id.

## [Add or remove tags in bulk](#bulk-tags)

POST`/api/v1/profiles/bulk/tags`

Applies tag changes across many profiles. Adds and removals are applied in that order, so a tag in both lists ends up removed.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Profiles to change. |
| `add` | `string[]` | Tags to add. |
| `remove` | `string[]` | Tags to remove. |

#### Returns

`{ updated: n }`

## [Apply one change to many profiles](#bulk-edit)

POST`/api/v1/profiles/bulk/edit`

The general-purpose bulk operation: one change set, applied to every profile in `ids`. Every field is optional and every list is additive or subtractive against what each profile already has, so this never silently replaces a collection — adding a bookmark that a profile already has is a no-op rather than a duplicate, and omitting a field leaves it untouched. The common mistake is expecting `startPages` to append: it replaces, because start pages are a short deliberate set. Use `addStartPages` to add to what is there. A profile that cannot be written is reported in `failures` and the rest of the batch still runs.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Profiles to change. |
| `addTags` | `string[]` | Tags to add to each profile. |
| `removeTags` | `string[]` | Tags to take off each profile. |
| `addBookmarks` | `{ title, url }[]` | Bookmarks to add, skipping URLs a profile already has. |
| `removeBookmarkUrls` | `string[]` | Bookmarks to remove, matched on exact URL. |
| `addStartPages` | `string[]` | URLs to append to each profile's start pages. |
| `startPages` | `string[]` | Replaces the start pages outright. |
| `proxyId` | `string \| null` | Assign a saved proxy, or `null` to detach. Omit to leave alone. |
| `proxy` | `{ providerId, country?, state?, city?, minutes? } \| string \| ProxyConfig \| null` | **Recommended: mint from a proxy provider.** Each profile mints its own — its own session id, and so its own exit address, which is what makes a proxy source meaningful across a selection. Sets `proxyId` to `null`, since an inline proxy and a library row are two answers to one question. **Also accepted: one address for the whole selection**, as a pasted line in any format the importer reads or a `ProxyConfig`, read once and refused as a `400` quoting it if it cannot be read; a line that matches a saved proxy links to it. That puts every selected profile behind one IP, which sites can use to link the accounts, so it is the operator’s choice and the response carries a `shared-exit` warning. `null` detaches. A value naming no proxy — `{}`, a disabled config — is a `400`, never a quiet detach. |
| `note` | `string` | Replaces each profile's note. |
| `oneTime` | `boolean` | Whether each profile trashes itself when its browser closes. |
| `pinned` | `boolean` | Pin each profile to the top of the list, or unpin it. |

#### Returns

`{ changed: n, skipped: n, failures: [{ id, name, reason }], warnings: [{ code: "shared-exit", profiles, message }] }` — `warnings` names how many profiles this edit put on one address (a saved `proxyId` or a pasted `proxy`), and is `[]` otherwise.

#### Errors

- 400 — the `proxyId` given does not exist.
- 400 — a `proxy` line that cannot be read (quoted), or a `proxy` value that names no proxy, such as `{}`.
- 400 — an unknown field. The fields go at the top level beside `ids`, never inside a `patch` object.
- 400 — the body names no field to change. `{"ids":[…]}` alone used to answer `{"changed":0,"skipped":N}`, which reads like N profiles were passed over.

```
curl -X POST http://127.0.0.1:PORT/api/v1/profiles/bulk/edit \
  -H "Authorization: Bearer $MOSAIC_KEY" -H "Content-Type: application/json" \
  -d '{"ids":["p1","p2"],"addTags":["warm"],"addBookmarks":[{"title":"Docs","url":"https://example.com"}]}'
```

## [Clear a profile’s cache, keep its logins](#clear-cache)

POST`/api/v1/profiles/:id/clear-cache`

Empties the browser caches — the HTTP cache, code cache, GPU and shader caches, the same directories an export leaves out — and nothing else. Cookies, logins, local storage, IndexedDB, history and extensions stay, so the profile is still signed in everywhere it was. For shrinking a profile and for clearing a stale cached script without logging out. Refused while the profile runs: Chromium holds those files open, and the profile is never stopped on your behalf.

#### Path parameters

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

#### Returns

`{ id, name, files, bytes }` — what was removed.

#### Errors

- 400 — the profile is running.
- 404 — no such profile.

## [Clear several profiles’ caches](#bulk-clear-cache)

POST`/api/v1/profiles/bulk/clear-cache`

The route above over a list. A running profile is **skipped**, by name, rather than failing the batch or being stopped; anything that could not be cleared is in `failures` with its reason. By id: there is no form of this that means all of them.

#### Body

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

#### Returns

`{ cleared: [{ id, name, files, bytes }], skipped: [{ id, name, reason }], failures: [{ id, name, reason }], files, bytes }`

## [Move many profiles to the trash](#bulk-delete)

POST`/api/v1/profiles/bulk/delete`

Stops each profile if running, then moves it to the trash. Reversible, like every other delete. A browser that will not close keeps its profile on the list — a trashed row has no Stop button, so hiding a running window behind one is the worse outcome — and it is named in `failures` rather than counted away.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | Profiles to trash. |

#### Returns

`{ deleted: n, trashed, stopped, failures }` — `stopped` lists the ones whose browser was closed, `failures` names the ones that were left alone and why.

## [Start several profiles](#bulk-start)

POST`/api/v1/profiles/bulk/start`

Launches each profile in turn with a pause between them. **Sequential on purpose, and that is a correctness point rather than politeness.** `POST /profiles/:id/start` refuses when `maxConcurrentProfiles` is already reached, and it counts sessions that are still `starting` as well as `running` — so forty parallel requests would every one read the count before any had registered, all pass the check, and quietly exceed the cap the operator set. Awaiting each launch is what makes that setting mean what it says. The pause matters on its own too: every launch spawns a browser with its own renderer, GPU process and network service, and twenty at once is how a laptop stops responding. A profile that is already up is `skipped`, not a failure; one that cannot start costs that row and nothing else.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | The profiles to start. |
| `staggerMs` | `number` | Gap between launches, 0–60000. Default 1000. |

#### Returns

`{ ok, skipped, failures: [{ profileId, reason }] }`

#### Errors

- `400` — `ids` missing or empty, or `staggerMs` out of range.

## [Stop several profiles](#bulk-stop)

POST`/api/v1/profiles/bulk/stop`

Closes each through `Browser.close`, so Chromium flushes its cookie jar rather than being killed mid-write. Concurrent, unlike starting: there is no cap on stopping and nothing to protect — every one of these is already holding the resources it is about to release. A profile that is not running is `skipped`.

#### Body

| Field | Type | What it is |
| --- | --- | --- |
| `ids`required | `string[]` | The profiles to stop. |

#### Returns

`{ ok, skipped, failures: [{ profileId, reason }] }`

#### Errors

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

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