Drıftstack docs
Docs

Profiles

A profile is a named, persistent browser identity Driftstack remembers between sessions. Cookies, localStorage, IndexedDB, service workers, and any other state the browser retains are kept under one logical handle so you can resume where you left off.

A profile’s metadata is intentionally light — a name, device profile (archetype), the optional fields you set (description, folder, tags, icon, note, notes, and the proxy and launch settings below), and timestamps such as last used. The browser state itself is managed by Driftstack and isn’t directly exposed through this API.

Tier caps

Each plan limits the number of profiles an account keeps. Only live profiles count: a profile in the trash counts toward no limit, so deleting a profile frees its slot at once (see Delete below). Crossing the cap on POST /v1/profiles (or POST /v1/profiles/:id/clone) returns 429 Tier limit. The caps are below (the public @driftstack/api-types package also exports them as PROFILES_PER_TIER):

Plan Tier id Profiles cap
Trial trial 1
Starter starter_v3 25
Pro pro_v3 100
Team team_v3 300
Scale scale_v3 1,000
Enterprise enterprise Unlimited

Enterprise has no profile cap.

The earlier plans, no longer offered, keep their caps for the accounts that hold them:

Plan Tier id Profiles cap
Personal solo_manual 10
Team team_manual 50
Agency agency_manual 200
API Starter api_starter 25
API Builder api_builder 100
API Scale api_scale 500

Resource shape

{
  "id": "prof_<uuid>",
  "name": "production",
  "archetype": "iphone15_ios18_7_safari26_5",
  "description": "primary prod-data scrape profile",
  "folder": "checkout-flows",
  "tags": ["prod", "us-east"],
  "icon": "🛒",
  "note": "rotate cookies weekly",
  "last_used_at": "2026-05-09T22:00:00.000Z",
  "created_at": "2026-04-15T11:30:00.000Z",
  "updated_at": "2026-05-09T22:00:00.000Z"
}
  • name — unique within the account. Lowercase + hyphen recommended; max 120 chars. Must start and end with an alphanumeric character; allowed inner characters are letters, digits, spaces, underscore, hyphen, and dot. Leading/trailing whitespace is trimmed.
  • archetype — the pinned device profile: the iPhone model, iOS version and Safari version. When omitted, the profile gets a randomly chosen iPhone model from those your plan includes that are open for new profiles, drawn once and kept. Every paid plan includes every device; the free trial includes the iPhone 13 and iPhone 13 mini only. When supplied, the id must be present in the current GET /v1/archetypes response; this includes any older combination the platform still marks available. Once set, the device profile is fixed for that profile’s lifetime.
  • description — free-form, max 2048 chars; nullable.
  • folder — optional organising folder name; null when the profile isn’t filed under a folder.
  • tags — array of free-form organising tags; [] when none are set.
  • icon — optional short emoji shown in the dashboard / GUI; null falls back to a monogram. Synced server-side so it follows the account across machines.
  • note — optional short inline note; null when unset. Synced server-side alongside icon.
  • last_used_at — updated when a session is created against this profile. null until first use.

Create

POST /v1/profiles

{
  "name": "production",
  "archetype": "iphone15_ios18_7_safari26_5",
  "description": "primary prod-data scrape profile"
}

Only name is required; archetype, description, folder, tags, icon, note, default_proxy_id, geolocation and stop_on_exit_ip_change are optional. Returns the created profile with 200 (not 201), the same status an Idempotency-Key replay returns.

Use GET /v1/archetypes to generate the request from the live selectable catalog. Any id absent from the current response is rejected before anything is written. Omitting the field gives the profile a randomly chosen iPhone model from those your plan includes that are open for new profiles; there is no default device.

Send an Idempotency-Key header (one new key per create; see Idempotency keys) to make a retry safe: the same key with the same body returns the profile the first request created (200), not a second profile or a 409 for the name it just took. Keys are per account (the workspace owner’s when you act on a team) and are kept for 30 days.

Errors:

  • 400 ValidationFailed — invalid name shape, missing required field, description over 2048 chars, or an archetype absent from the live selectable catalog.
  • 409 Conflict — name already exists in this account (no code).
  • 409 Conflict with code: "idempotency_key_reused" — the Idempotency-Key was used before with a different body. Nothing was created; use a new key.
  • 409 Conflict with code: "profile_from_key_gone" — the profile this Idempotency-Key created is in the Trash or no longer in the account (profile_id, and profile_state). trashed: it was deleted, or transferred to another account (a transfer leaves it in your Trash); deleted: it has left the Trash. Nothing was created again. If you deleted it, restore the trashed profile from the Trash; otherwise use a new key.
  • 409 Conflict with idempotency_status: "in_progress" — the first request with this key has not finished. Send the same request again with the same key.
  • 400 ValidationFailed — an Idempotency-Key longer than 255 characters or containing whitespace or non-printable characters.
  • 429 TierLimit — account at the profile cap. Body extension: {limit, current, resource: "profile", tier, trash_count}. current counts live profiles only. The detail reads, for example, “Your plan (Trial) keeps 1 saved profile, and this account already has 1. Delete a profile to free a slot, or choose a plan with more profiles.” A plain DELETE /v1/profiles/:id frees the slot at once: profiles in the trash count toward no limit. This refusal carries no code. trash_count is still sent for one release so older clients keep working, but it is always 0 and will then be removed; code: "profile_cap_trash" is no longer sent. The same applies to Clone, Import and restoring a Snapshot. A Launch refused because the account holds more live profiles than its plan (code: "over_plan_limit") also carries trash_count: 0.

List

GET /v1/profiles?limit=50&cursor=<...>

{
  "data": [<profile>, ...],
  "has_more": false,
  "next_cursor": null
}

limit 1-100 (default 50). Cursor is the prior page’s last id; ordering is created_at desc, id desc for stable tie-break.

This lists live profiles only. Profiles you deleted are in the trash for 7 days and count toward no limit: list them with GET /v1/profiles?status=trashed (or GET /v1/profiles/trash, the same rows), and bring one back with Restore. status=active is the default; any other status is a 400. The bracket form (status[]=trashed) is read as the same parameter; sending status more than once, both forms together, or any other bracketed form (status[0]=trashed) is a 400.

status=trashed returns the whole trash in one page, exactly as GET /v1/profiles/trash does: limit and cursor are ignored (not applied and not validated), has_more is always false and next_cursor is null.

Get one

GET /v1/profiles/:id

Returns 404 if the profile doesn’t exist or belongs to a different account (we don’t leak existence cross-account).

Patch (rename + edit description)

PATCH /v1/profiles/:id

{
  "name": "production-eu",
  "description": "EU-region primary"
}

Every field is optional: name, description, folder, tags, icon, note, notes, and the proxy and launch settings below; pass description: null to clear. The device profile is intentionally not editable — to move to a different device, create a new profile with the new archetype, then delete the old profile after migration.

Launch

POST /v1/profiles/:id/launch

{ "label": "checkout-run-2026-05-20", "proxy_id": "6f1c2a9e-4b7d-4c1e-9a3f-2d8e5b6c7a10" }

label is an optional override with the same 120-character maximum as direct session creation. proxy_id is one of your saved proxies, the same field POST /v1/sessions takes; omit it to launch through the proxy the profile is bound to. network_detail: true asks for the session’s network request detail, as on POST /v1/agent-sessions; read it on the agent session the launch runs as (listed by GET /v1/agent-sessions as a manual session whose driftstack_session_id names this session). The body is strict: any other key, including raw proxy, is rejected before profile lookup or session creation. Everything else flows from the profile (the profile’s archetype and metadata are inherited, and last_used_at is updated automatically). One-shot wrapper around POST /v1/sessions — equivalent to:

{
  "profile_id": "prof_<uuid>",
  "archetype": "<profile.archetype>",
  "metadata": { "profile_id": "...", "profile_name": "..." }
}

but in a single round-trip, and the server stamps the linkage on the session’s metadata so the audit + usage trail tie back to the profile.

Returns the freshly-minted session (same shape as POST /v1/sessions). The customer then drives the session via the normal navigate / interact / wait / capture / destroy verbs (or via the desktop app’s Live session view).

The launch runs through a proxy of yours exactly as POST /v1/sessions describes: the proxy_id you send, or the proxy the profile is bound to. The session it returns names that proxy in proxy_id, as every later read of the session does. Where every session runs through a proxy of yours (hosted Driftstack runs them that way), a launch with neither is answered 422 proxy-required, and one from a profile whose proxy was deleted 409 (code: "proxy_detached"). A session launched through a proxy cannot run the step-by-step verbs yet (they answer 503 and leave it ready); to drive the profile’s pages through your proxy, use POST /v1/agent-sessions with its profile_id and a proxy_id.

Errors:

  • 404 if the profile isn’t owned by the effective account — your own, or the owner you are acting as via X-Driftstack-Account (deliberate anti-enumeration — a profile_id outside that account is indistinguishable from a missing one).
  • 403 if you send X-Driftstack-Account for a team you are a member of but not an admin of: launching a profile on a team owner requires the admin role on that team.
  • 409 profile-in-use if the profile already has a live session — a profile can run only one session at a time (the body’s active_session_id names the live one). End it first if you created it, then launch.
  • 409 device-unavailable if the profile’s device is on hold or still in development, so it cannot start a session. Nothing is started; the body’s archetype names the device and held_reason is the reason for the hold in one plain sentence when there is one.
  • 400 for an unknown body key, a label longer than 120 characters, an explicit raw proxy field, or a proxy_id naming an HTTP proxy. Raw proxy data is never applied.
  • 400 (code: "network_detail_not_offered") for network_detail: true where the deployment does not offer request detail, or where the launch does not run through one of your proxies. Nothing is launched.
  • 404 (resource: "proxy") if the proxy_id is not one of your saved proxies, and 403 (feature: "vpnEgress") for a VPN proxy on a plan without VPN egress.
  • 409 with code: "proxy_detached" if no proxy_id was sent and the profile’s proxy was deleted. Choose a proxy.
  • 422 proxy-required if a proxy of yours is required and there is none (no proxy_id, and the profile is bound to no proxy); 422 proxy-validation-failed if the proxy could not be used.
  • 429 tier-limit with code: "over_plan_limit" if the account holds more saved profiles than its plan keeps and this one is not among the most recently used, up to the plan’s number (on the trial, only the most recently used one launches). Nothing is deleted.
  • 429 tier-limit with code: "trial_session_time_used" once the free trial’s 2 hours of session time are used up.
  • Any other error the underlying POST /v1/sessions can return, most commonly 429 when the concurrent-session cap is reached.

Clone

POST /v1/profiles/:id/clone

{ "name": "production-staging" }

Body fully optional. When name is omitted the server auto-derives a non-conflicting ${source} (copy) / (copy 2) / (copy 3) … naming (caps at 99 to avoid runaway loops; rejects with 409 if it gets there).

The clone inherits source’s archetype + description, and its free-text notes come along, as they do on a transfer (a snapshot never carries them). Underlying browser state is NOT cloned — the new profile starts with a fresh state slot under the same device profile. Use clone primarily for:

  • Keeping a copy of a profile’s settings before you replace it with a new profile on a different device.
  • Splitting a busy production profile into per-environment copies before they diverge.
  • Pre-creating staging profiles ahead of a load test.

Returns the cloned profile (same shape as create). The audit_log entry for profile.created carries payload.cloned_from: profile_<source-id> so the audit shows provenance.

Errors mirror create: 429 if the cap would be exceeded by the clone, 409 on explicit-name collision, 404 if the source isn’t found / not owned by the caller.

Export / Import

Export a profile as a versioned, metadata-only JSON envelope, then import it (in the same account or another) to mint a fresh profile from it. This is how you give a profile to another Driftstack account: the source profile is left intact, and you move the JSON yourself (commit it, attach it, hand it to a teammate). Only the metadata travels — name, archetype, description and the free-text notes. Underlying browser state is not in the envelope.

Import is a new-profile write, so envelope.profile.archetype must be present in the current selectable catalog. An export of an older retained profile still succeeds, but importing that envelope returns 400 ValidationFailed if its pinned id is no longer selectable, or if that device is closed for new profiles (code: "device_closed_for_new_profiles"; see GET /v1/archetypes). A clone and a snapshot restore are refused the same way.

Export

GET /v1/profiles/:id/export

{
  "version": 1,
  "exported_at": "2026-05-20T12:00:00.000Z",
  "source_profile_id": "prof_<uuid>",
  "source_account_id": "<account-uuid>",
  "profile": {
    "name": "production",
    "archetype": "iphone15_ios18_7_safari26_5",
    "description": "primary prod-data scrape profile",
    "notes": "Signed in as the EU test shopper. Cart has two items."
  }
}

source_profile_id and source_account_id are informational only — they record where the file came from. Importing always mints a fresh id, into whatever account holds the calling key. Consumers must honor the envelope version and reject versions they do not understand.

Import

POST /v1/profiles/import

{
  "envelope": { "version": 1, "...": "..." },
  "name_override": "production-copy"
}

envelope is a v1 export envelope (paste the file). name_override is optional — rename on import without editing the file; when omitted, envelope.profile.name is used. envelope.profile.notes is restored onto the new profile; an envelope exported before notes existed has no notes key and imports with notes: null. Returns the created profile (same shape as create). Tier-cap + name-conflict semantics match Create: 429 if the import would exceed your profile cap, 409 if the name already exists. Importing an envelope from a different account is permitted (file-based transfer between teammates).

Errors:

  • 400 ValidationFailed — the envelope is malformed, is not a v1 shape, or carries an archetype absent from the live selectable catalog.
  • 409 Conflict — name (or name_override) already exists.
  • 429 TierLimit — importing would exceed your tier’s profile cap.

SDK usage:

const envelope = await client.profiles.export('prof_<uuid>');
const restored = await client.profiles.import({ envelope });

Export is a read — any valid bearer with read scope. Import requires the write:profiles scope on the calling key (a broad write key also satisfies it). Team RBAC: X-Driftstack-Account is honored — admin members can import on the owner’s account.

Snapshots

Snapshots are immutable point-in-time metadata records of a profile. The parent profile keeps evolving — its device profile, name, description, and underlying browser state mutate as you use it. The snapshot’s recorded metadata (device profile, name, description) is frozen the moment you capture it. Browser state (cookies, logins) is NOT captured today — see /api/profile-snapshots for the full contract.

Capture

POST /v1/profiles/:id/snapshots

{ "label": "before-iOS-26-rollout", "description": "optional, max 2048 chars" }

The response carries the snapshot’s id (prefix psnap_), parent_profile_id, parent_archetype, parent_name (frozen at capture time), and captured_at.

List

GET /v1/profiles/:id/snapshots — newest-first, paginated. GET /v1/profile-snapshots — every snapshot the effective account owns — your own, or the owner you are acting as via X-Driftstack-Account, across all profiles. Same pagination shape.

Get one

GET /v1/profile-snapshots/:id

Restore

POST /v1/profile-snapshots/:id/restore

{ "name": "restored-from-baseline" }

Creates a NEW profile carrying the snapshot’s parent_archetype + description. The original parent profile is NOT modified — even if it has been renamed, edited, or deleted in the meantime. The new profile counts against your tier cap (429 if it would exceed) and 409s on name collision. The audit_log entry for profile.created carries payload.restored_from_snapshot: psnap_<id>.

Delete

DELETE /v1/profile-snapshots/:id → 204 No Content.

Snapshots have no automatic lifecycle. Capture as many as you want; they sit until you delete them. Deleting the parent profile does NOT delete the snapshot — the captured parent_archetype + parent_name + description remain restorable. Note that DELETE /v1/profiles/:id is a soft delete into the 7-day recycle bin (see below), so the snapshot’s parent_profile_id stays populated while the parent sits in the bin — it only becomes null once the profile is purged, restored, or the bin expires it. Until then the id is present but a GET on that profile 404s, so do not treat a non-null parent_profile_id as proof the parent is reachable.

Profiles pinned to a retired device

Catalog restrictions apply to new direct session/profile creates and profile imports. Existing profiles keep their stored device profile even after it leaves the selectable catalog. They remain listable, readable, clonable, snapshot-restorable, and launchable; these compatibility operations do not silently rewrite the pinned browser identity.

Delete (recycle bin)

DELETE /v1/profiles/:id moves the profile to the trash.

DELETE /v1/profiles/:id?permanent=true deletes it permanently.

A profile in the trash counts toward no limit: the delete frees its slot at once, for create, clone, import, transfer, snapshot restore and launch alike. It stays in the trash for at least 7 days and is then removed for good. Deleting it permanently (?permanent=true on this call, or Purge below for a profile already in the trash) only removes it sooner; it is not needed to free a slot.

Without permanent, the delete is a soft delete: the profile is hidden from GET /v1/profiles and can’t be launched or looked up by id, but it’s recoverable (see Restore below). Deleting also frees the name immediately, so you can create a new profile with the same name right away.

With permanent=true, a live profile is moved to the trash and purged in the same call, and a profile already in the trash is purged. Its stored browser state is deleted, and it cannot be restored. Same scope and team-admin rule as a plain delete. A profile with a live session is refused with 409 Conflict and nothing is deleted: stop the session if you started it, then retry.

permanent=false, an empty permanent= or no parameter is the soft delete. Any other value (permanent=1, permanent=yes) is refused with 400 ValidationFailed and nothing is deleted.

Returns 204 No Content, and is idempotent — re-deleting an already-trashed profile, permanently deleting one that is already gone, or an id that was never yours also returns 204.

Trashed profiles are retained for at least 7 days, then permanently purged (the underlying state is hard-deleted at that point and is no longer recoverable). Each trashed profile carries purges_at: the earliest time the profile is permanently deleted (at least 7 days after the delete; removal follows at the next daily clean-up). The clean-up runs once a day, at 04:00 UTC, so a profile is removed after its purges_at, normally within a day, and never before it; until it is removed it can still be restored. Profiles that were already in the trash when the window changed from 30 days to 7 keep the date they already had, and never less than 7 days from the change.

List trash

GET /v1/profiles/trash → { "data": [ ...profile ] }

Lists your trashed profiles, newest-deleted first. Each row has the same shape as a live profile plus two non-null timestamps: deleted_at (when it was deleted) and purges_at (the earliest time the profile is permanently deleted: at least 7 days after the delete; removal follows at the next daily clean-up). Both are null on a live profile. Read scope suffices (both team roles).

{
  "id": "prof_<uuid>",
  "name": "production",
  "deleted_at": "2026-10-08T09:15:00.000Z",
  "purges_at": "2026-10-15T09:15:00.000Z"
}

(Other profile fields omitted.) Profiles in the trash count toward no limit.

Restore

POST /v1/profiles/:id/restore → the restored profile

Un-trashes a soft-deleted profile (write scope; admin-only on a team workspace, same as delete). The restored profile is live again and its deleted_at and purges_at are null.

A restore needs two things:

  • A free slot. The restored profile counts toward your plan’s profile limit again, so the account must be under its cap.
  • A restore left. Each account may restore a set number of profiles in any rolling 30 days. The number follows the plan: 10% of the plan’s profiles, and never fewer than 3; Enterprise is a flat 100 (@driftstack/api-types exports it as PROFILE_RESTORES_PER_TIER).
Plan Restores per rolling 30 days
Trial 3
Starter 3
Pro 10
Team 30
Scale 100
Enterprise 100

The earlier plans, no longer offered, follow the same rule: Personal 3, Team (team_manual) 5, Agency 20, API Starter 3, API Builder 10 and API Scale 50.

Only a successful restore from the trash uses one. A refused restore, a snapshot restore, a purge, a permanent delete and the automatic purge use none. The desktop app’s Trash view shows how many restores are left.

Returns:

  • 404 if the id isn’t one of your trashed profiles.
  • 409 Conflict if a live profile has since taken the trashed profile’s name — rename the live one (or the restored profile) first, then retry.
  • 429 TierLimit with code: "restore_cap" if the account has used its restores for the rolling 30 days. Body extension: {limit, current, resource: "profile_restore", tier, code, window_days, next_restore_at}. limit is the plan’s number, current is how many the account has used, window_days is 30 and next_restore_at is the ISO time the next restore becomes available. The detail reads, for example, “Your plan (Pro) allows 10 restores from Trash in 30 days, and this account has used 10. The next one is available on 2026-10-21. Profiles in Trash are kept until the date shown beside each one.”
  • 429 TierLimit with code: "restore_needs_slot" if the account’s live profiles are at the plan’s number. Body extension: {limit, current, resource: "profile", tier, code}. current counts live profiles. The detail reads, for example, “Your plan (Pro) keeps 100 profiles and this account has 100. Delete one or choose a bigger plan, then restore.”

Both 429s leave the profile in the trash and use no restore. restore_cap is checked first, so an account that is out of restores and also at its cap gets restore_cap. On a team workspace both are judged on the plan of the account that owns the profile. Waiting for next_restore_at does not extend a profile’s time in the trash: it is still removed at the first daily clean-up after its purges_at.

Purge (permanent delete)

DELETE /v1/profiles/:id/purge → 204 No Content

Permanently deletes a trashed profile now, without waiting for the 7-day auto-purge. Its slot was already freed by the delete that put it in the trash, so purge is not needed to free a slot. The underlying encrypted state is hard-deleted; this is irreversible (no restore afterward). Write scope (admin-only on a team workspace, same as delete). Returns 404 if the id isn’t one of your trashed profiles — purge only acts on the trash, never on a live profile (delete it first, or use DELETE /v1/profiles/:id?permanent=true, which does both). The 404’s detail reads: “No profile with this id is in the trash for this account. List the trash with GET /v1/profiles/trash; for a team owner’s profiles, send the X-Driftstack-Account header with the owner’s account id (team admins only).” Returns 409 Conflict if the profile has a live session; nothing is purged.

Read endpoints (GET) accept any valid bearer with read scope; write endpoints (POST, PATCH, DELETE) require the write:profiles scope on the calling key (a broad write key also satisfies it).

One exception, documented here because the endpoint is: POST /v1/profiles/{id}/launch requires write:sessions, not write:profiles — it creates a session, so it is gated as a session write. A key scoped to write:profiles alone can manage profiles but cannot launch one. Team RBAC: X-Driftstack-Account is honored for both reads and writes — member roles cannot write on the owner’s account; admin members can.

Recent activity

GET /v1/profiles/:id/activity

Returns the profile’s recent navigation — every page an agent session planned to open while using this profile — most recent first, with the session it belonged to. Each url is the site of the page (scheme://host[:port]/): the path, query and fragment are not kept.

This is account activity, not browsing history. The profile itself stores no history; these rows come from the account’s agent session transcripts, which are operational records of what the account did. That is why clearing a profile’s history (POST /v1/profiles/:id/trim with scope: "history", or Clear history in the desktop app) does not remove them: that action clears the profile’s saved open tabs and nothing else. Session records are removed only when the account is deleted.

{
  "data": [
    {
      "at": "2026-09-05T08:12:44.120Z",
      "url": "https://example.com/",
      "agent_session_id": "agt_3f9a2c1d-7b4e-4c8a-9d2f-1e6b5a7c3d9f"
    }
  ],
  "sessions_scanned": 12,
  "truncated": false,
  "pages_withheld": false
}

The response is bounded: the server reads the profile’s most recent sessions and returns the most recent navigations from them. When truncated is true, older activity exists but is not returned. Requires read:profiles. Returns 404 for a profile the account does not own and 503 on deployments where agent sessions are not enabled.

Each session is read from the history its agent works from: in a long session whose oldest entries were dropped from that history to make room (see history under agent sessions), the pages in the dropped entries are not returned.

The pages and session ids come from agent session records, so they are shown only to a caller who could read those sessions: the key needs read:sessions as well, and in a teammate’s workspace (X-Driftstack-Account) the caller must be an admin there, as for /v1/agent-sessions. Anyone else still gets the rest of the response — sessions_scanned and truncated — with data empty and "pages_withheld": true. Every response carries pages_withheld; it is false when the pages are included.

Trim cached site data

POST /v1/profiles/:id/trim

Clears a profile’s stored data. What it clears depends on scope, and three of the four values are destructive of identity state.

With no body it clears only the re-fetchable caches (HTTP/media cache and per-origin CacheStorage / service-worker registrations) and keeps cookies, localStorage, IndexedDB and open tabs. That is the behaviour this endpoint had before scope existed, so an existing caller that sends nothing is unaffected.

scope Clears Keeps
(omitted) / cache Re-fetchable caches Cookies, site data, tabs
cookies Cookies and per-origin site data — signs the profile out everywhere Cached files, tabs
history The profile’s remembered open tabs Logins, cached files
all Caches, site data and tabs together — signs the profile out The profile and its fingerprint

No scope alters the profile’s fingerprint: it stays the same device to every site. Clearing a profile’s tabs does not clear the server-side record of session activity, which is governed by the retention policy rather than by this endpoint.

{ "scope": "cookies" }

The body is optional and strict: an unknown key, or a scope outside the four values, is refused with 400 rather than falling back to the default — this body selects which data a destructive operation destroys. Write scope (write:profiles), admin-only on a team workspace, same as the other mutating routes.

The trim runs against the profile’s last saved state while no session is using it, so the response is a discriminated 200 body in every case:

// "ok" — trimmed; the smaller size is persisted immediately
{ "status": "ok", "size_bytes": 18874368, "bytes_reclaimed": 41943040 }

// "unavailable" — nothing ran; reason says why
{ "status": "unavailable", "reason": "profile is currently in use — stop its running session before clearing its data" }

// "timeout" — no reply in time
{ "status": "timeout" }

// "error" — the trim reported a failure; the stored state is untouched
{ "status": "error", "reason": "<failure reason>" }

unavailable covers the expected-inert states: the profile is bound to a still-running session (stop it first if you started it — a live session would save its full un-trimmed state back over the trimmed result), the profile has no saved state yet (a fresh profile has nothing to trim), or profile storage isn’t enabled on this deployment. On "ok", size_bytes (the new stored size) updates the storage meter and launch-quota checks immediately. Returns 404 if the profile isn’t found or isn’t owned by the effective account — your own, or the owner you are acting as via X-Driftstack-Account.

Proxy, launch settings and revision (2026-09-28)

A profile remembers which proxy it launches through, its fixed location and its stop-on-exit-IP switch — on the account, so the same profile leaves through the same exit on every computer. Three fields on every profile, all settable on create and PATCH:

  • default_proxy_id — one of the account’s saved proxies (404 with resource: "proxy" for any other; 400 with proxy_scheme: "http" for an HTTP proxy, which cannot carry a session), or null to unset.
  • proxy_choice — set by the server, three states: "unset" (never chose: a launch without a proxy_id is refused as it always was), "bound" (a launch with this profile_id and no proxy_id uses default_proxy_id), "detached" (its proxy was deleted: a launch naming it without a proxy_id is 409 proxy_detached until you choose one — never another proxy, never the first in the list). Send "proxy_choice": "detached" on PATCH to mark a profile detached on purpose (2026-09-29); it is the only value a request may send, and a non-null default_proxy_id beside it is a 400.
  • geolocation ({latitude, longitude, accuracy?} or null) and stop_on_exit_ip_change default onto a session created with the profile when the create omits them; an explicit value wins.

revision moves on every edit (a launch’s last_used_at and a save-back do not move it). Send it back as expected_revision on PATCH and a profile another computer changed first is refused with 409 (code: "stale_revision", current_revision) and nothing changes; omit it to write unconditionally. Every edit is recorded as profile.updated in the audit log with the names of the changed fields.

Clone copies the three fields (same account). Import, transfer and snapshot restore reset them: the new profile starts "unset" with no location. The account summary says profile_trash_count beside profile_count: profile_count and the profile cap both count live profiles only, and profiles in the trash count toward no limit. It also carries profile_restores_used and profile_restores_limit, the restores from the trash the account has made in the rolling 30 days and the number its plan allows (see Restore).

Lifecycle interaction

A session is bound to a profile at creation time (POST /v1/sessions { profile_id }). The session carries the profile’s state forward; on destroy, any changes are saved back to the profile. A profile can only be used by one live session at a time: creating a second session with the same profile_id is refused with 409 profile-in-use until the first session has ended, so saved cookies and logins are never overwritten by a competing session.

See Session lifecycle for the full flow.