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

# Browser profiles

A profile is a named, versioned, encrypted browser state that belongs to your account. A session names a profile when it is created, starts from that state, and can save its changes back as a new version.

A session loads only the profile it names. A name or id from another account answers 404, and a session carrying one profile cannot load a second. For a login on one site, [saved logins](https://docs.tilion.dev/sessions/saved-logins.md) may be enough; a profile also carries page storage and, on request, the Chrome folder.

Read routes need the `sessions:read` scope. All other profile routes need `sessions:write`. Profile routes answer 501 where profiles are not available.

## POST /v1/profiles

Create an empty profile.

| Field | Type | Meaning |
| - | - | - |
| `name` | string, required | 1 to 64 characters, unique in your account. |
| `domains` | list of strings | Limits what is ever stored or loaded. |
| `probe` | object | A login check, such as `{"require_cookies": ["li_at"], "min_cookies": 1}`. A save that fails it is refused. |
| `webhook` | https URL | Receives profile events. The response carries `webhook_secret` once. |
| `retention_days` | integer, 1 to 3650 | See [Retention](#retention). |

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/profiles \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"name": "shop-login"}'
```

The response is 201 with the profile: `id`, `name`, `domains`, `persona`, `country` and `created_at`, plus the optional settings you set. The first save sets `persona` and `country`.

Errors: 409 `profile_conflict` when the name exists; 422 `invalid_profile` for a bad name or setting, or at 200 profiles.

## GET /v1/profiles

Returns `{"profiles": [...]}`. `GET /v1/profiles/{name}` returns one profile with `versions` (the count), `version` (the newest number, 0 when empty) and `latest` (that version's index).

## DELETE /v1/profiles/\{name}

Delete a profile and everything it holds. Returns `{"deleted": "shop-login", "objects": 3}`, where `objects` counts the stored objects removed.

## Use a profile when creating a session

Send a `profile` block on [`POST /v1/session`](https://docs.tilion.dev/sessions/sessions), or `profile_id` alone to attach read only.

```bash theme={"dark"}
curl -X POST https://back.tilion.dev/v1/session \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"profile": {"name": "shop-login", "save": "replace"}}'
```

| `profile` field | Type | Default | Meaning |
| - | - | - | - |
| `name` | string | | The profile's name. Send `name` or `profile_id`, not both. |
| `profile_id` | string | | The profile's `id`, looked up among your own profiles. |
| `save` | `none`, `replace`, `merge` | `none` | See [Save modes](#save-modes). |
| `domains` | list of strings | all | Load and save only these domains. Refused with `full` or `folder`. |
| `coherence` | `off`, `warn`, `strict` | `warn` | Compares the session's device with the one the profile was saved under. `warn` reports a difference, `strict` refuses it. |
| `folder` | boolean | `false` | Also restore the saved Chrome folder, without caches. |
| `full` | boolean | `false` | Restore the whole saved browser, caches included. |
| `cache` | boolean | follows `full` | Overrides whether caches go in the folder. Needs `full` or `folder`. |

`full` and `folder` restart Chrome once inside the session before it is handed over, so the create takes one Chrome launch longer. They work at create only.

The session response has a `profile` block, or null without a profile: `name`, `version`, `save`, `restored` and `coherence`. `restored` holds counts: `cookies`, `localStorage` and `origins`. A folder restore adds `folder`, `folder_version` and `device`, which is `kept` when the session runs the device the folder was saved under and `new` when it has its own.

Errors on create:

| Status | `error` | When |
| - | - | - |
| 403 | `profile_folder_not_enabled`, `profile_folder_not_on_plan` | `full` or `folder` is not available to this account or plan. |
| 404 | `profile_not_found` | No such profile in your account. No browser is started. |
| 409 | `profile_unavailable` | The profile's storage or key cannot be used. |
| 409 | `profile_incoherent` | `coherence: "strict"` on another device. |
| 422 | `invalid_profile` | `profile_id` and a `profile` block were both sent. |
| 429 | `profile_folder_busy` (`Retry-After: 5`) | Too many folder restores are running. |
| 501 | `profile_full_unsupported` | The session's browser image cannot restore a folder. |
| 502 | `profile_folder_restore_failed` | The restore failed. No session was started. |

## Save modes

| `save` | Behavior |
| - | - |
| `none` | Read only. Nothing is saved when the session ends. This is the default, and `profile_id` on create always uses it. |
| `replace` | The session's state becomes the next version when the session ends (delete, idle or expiry), if the version it holds is still the newest. |
| `merge` | When the session ends, only what it changed since loading is applied to the newest version. |

`replace` with `domains` saves as a merge of those domains, so other domains are not deleted. A save changes only what the session could see: page storage is saved for the origins read back from open tabs and cookie hosts, up to 12, and the storage of any other origin carries forward unchanged.

### Read-only sessions

Any number of sessions can use `save: "none"` on one profile. Each keeps the version it loaded and does not see later saves. A `POST /v1/session/{id}/profile/save` for that profile answers 409 `profile_read_only`. To save from the session, change its mode with `PATCH /v1/session/{id}/profile`.

## GET /v1/session/\{session\_id}/profile

Returns `name`, `version`, `head_version`, `behind`, `mode`, `save`, `folder`, `cache`, `restored` and `coherence`. Here `mode` is the save mode as a string and `save` is a boolean saying whether the session saves. `behind` is true when another writer has committed since this session loaded. A session with no profile answers 404 `no_profile`.

## PATCH /v1/session/\{session\_id}/profile

Turn saving on or off, or change the mode, while the session runs. This works with a CDP client connected. The browser and the version it holds do not change.

```bash theme={"dark"}
curl -X PATCH https://back.tilion.dev/v1/session/sess_3d245e56b86d4bc8ae32095b644117d4/profile \
  -H "Authorization: Bearer ph_live_..." -H "Content-Type: application/json" \
  -d '{"save": "merge"}'
```

The answer has the same fields as `GET`, without `head_version` and `behind`.

* `none` always works.
* `replace` answers 409 `profile_conflict` when a newer version exists, or when a `merge` session's version holds another writer's changes.
* `merge` answers 409 `profile_conflict` when the version the session loaded has been pruned.

After a refusal the mode is unchanged. Passing these checks does not reserve the profile: a `replace` session is checked again when it saves.

## POST /v1/session/\{session\_id}/profile/load

Load a profile's cookies, localStorage and IndexedDB into a running session. The body takes `name` or `profile_id`, and optionally `save`, `domains` and `coherence`.

The response is `{"profile", "version", "save", "restored", "coherence", "persona", "country"}`.

Errors: 409 `session_driven_by_client` with a main client connected; 409 `profile_full_needs_new_session` with `full` or `folder`; 409 `profile_already_attached` when the session carries another profile.

## POST /v1/session/\{session\_id}/profile/save

Save cookies and page storage now. The body is optional: `name` saves into another profile as a one-off snapshot, and `force: true` skips the login check. The response is the new version's index with `profile`. The Chrome folder is saved only when the session ends, because reading it stops Chrome.

Errors: 409 `session_driven_by_client` with a main client connected; 409 `profile_conflict` when another writer committed first; 409 `profile_probe_failed` (`failures`) when the state fails the login check; 409 `profile_read_only`; 422 `profile_required` when no profile is named or loaded.

## What is restored

| State | Default | `folder: true` | `full: true` |
| - | - | - | - |
| Cookies | Yes | Yes | Yes |
| localStorage | Up to 12 origins per session | Every origin | Every origin |
| IndexedDB | Up to 12 origins, without records holding a Blob or Map | Every origin, with Blobs | Every origin, with Blobs |
| Service workers | No | Registrations and scripts | Registrations and scripts |
| CacheStorage | No | No | While it fits, whole per origin |
| HTTP cache | No | No | While it fits, newest first |
| File System API, preferences, history, saved form data | No | Yes | Yes |
| sessionStorage | First tab, up to 12 origins | First tab, up to 12 origins | First tab, up to 12 origins |
| Open tabs | No | No | No |
| Device | The session's own | The one it was saved under | The one it was saved under |

### sessionStorage

A new session's first tab gets the sessionStorage the profile saved, before any page opens, so the page's first script finds its values. The first tab is `context.pages[0]` in Playwright, and it is the same tab for raw CDP and the REST routes. Each value goes back only under the origin it was saved from.

A tab opened later starts empty, as in any browser. A tab the page opens with `window.open` copies its opener's values, and a `noopener` tab starts empty.

> [!WARNING]
>
> A save reads sessionStorage only from the origin each open tab is on at that moment. If the tab leaves a site before the session closes, that site's sessionStorage is not saved, and it keeps the values from its last save, if any. To keep it, end the session on that site, or call `POST /v1/session/{id}/profile/save` while the tab is there. That route answers 409 `session_driven_by_client` while a CDP client such as Playwright is connected, so disconnect the client, save, and reconnect. The tab stays on its page.

A tab closed before the save, and a frame from another site, are not saved either.

A version saved mid-session, or by a session without `full` or `folder`, holds no folder. A later `full` session restores the newest folder and applies the newer cookies and page storage on top. `restored.folder_version` shows which version the folder came from.

## Two sessions on one profile

Both sessions start, because a profile is not locked. What happens next depends on `save`:

| `save` | Result |
| - | - |
| `none` | Each keeps what it loaded, and nothing is saved. |
| `replace` | The first to end saves. The second's changes, folder included, are **not saved**. |
| `merge` | Both save. Cookies, localStorage keys and IndexedDB databases merge, and the last writer wins per item. A folder is not merged: the last writer's whole folder is kept. |

Use `merge` for sessions that write at the same time. While a `replace` session runs, `GET /v1/session/{id}/profile` says `behind: true` once another writer has committed. Switching to `merge` then keeps its changes.

A session that ends with saving on reports the outcome in three places:

* **The close header.** `DELETE /v1/session/{id}` answers 204 with `X-Tilion-Profile-Save`: `saved`, `conflict`, `probe_failed`, `profile_deleted` or `failed`. `profile_deleted` means the profile was deleted while the session ran: nothing is saved and nothing comes back.
* **The status field.** `GET /v1/session/{id}/status` has `profile_save` for an hour, including for sessions that ended by idle or expiry: `profile`, `status`, `held_version`, and when they apply `version`, `head_version`, `error`, `message` and `folder`. `folder` holds `saved`, and when they apply `bytes`, `skipped`, `replaced_other_writers` and `cache`. `folder.replaced_other_writers` is true when this save replaced another writer's folder.
* **Events and webhook.** When changes were not saved, the profile gets a `save_refused` event and a `profile.save_refused` webhook with `session_id`, `reason`, `held_version` and `head_version`.

## Limits

| What | Limit | Over the limit |
| - | - | - |
| Profiles per account | 200 | 422 `invalid_profile` |
| Bytes per account in Tilion's storage | 5 GB | The folder is skipped first, then 422 `invalid_profile` |
| Versions per profile | 20 | Older versions are pruned after a save |
| Chrome folder, compressed | Free 16 MB, Developer 32 MB, others 64 MB | Caches are dropped first, then the folder is skipped; cookies and storage still save |
| Caches in a folder | 32 MB before compression, inside the folder's limit | Dropped; a save never fails for a cache |
| One localStorage value | 1 MB | Dropped and counted |
| One origin's page storage, as restored | 11 MB | IndexedDB is dropped first, then the largest values; counted |
| One version's state | 14 MB | The largest page storage is dropped; cookies are kept |
| Folders in progress | 4 restoring and 4 saving at a time | A create answers 429 `profile_folder_busy` after 20 seconds; a close saves without the folder |

A version index reports a skipped folder in `folder_skipped`.

## Retention

Set `retention_days` (1 to 3650) on `POST /v1/profiles` or `PATCH /v1/profiles/{name}` to delete older versions after a save and in a periodic sweep. `clear_retention: true` removes the setting. `POST /v1/profiles/{name}/prune` with `{"keep": 5}` deletes by count on request and answers `removed_versions`, `kept` and `oldest`.

The newest version is never deleted, and retention never deletes a profile. A `replace` session holding a version that has been deleted is refused when it saves, with 409 `profile_conflict`. A `merge` session saves normally.

## Other profile routes

| Route | Purpose |
| - | - |
| `GET /v1/profiles/{name}/versions` | List version indexes, oldest first. |
| `POST /v1/profiles/{name}/rollback` | Make a new version from an older `version`. |
| `POST /v1/profiles/{name}/fork` | Create a profile `name` from a `version` of this one. |
| `POST /v1/profiles/{name}/import` | Save a Playwright storageState as the next version. Add `?force=true` to skip the login check. |
| `GET /v1/profiles/{name}/export` | Return a version as a storageState with its values in clear text. Needs `sessions:write`. |
| `GET /v1/profiles/{name}/events` | List events, newest first, without values. |
| `GET /v1/profiles/{name}/health` | Report `status`: `ok`, `logged_out` or `unknown`. |

See the [login-once guide](https://docs.tilion.dev/guides/browser-profiles.md) for a complete example.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.