Driftstack DRIFTSTACK docs
Docs

Profiles

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

The profile model is intentionally light at the metadata layer — profile rows hold a name, archetype, optional description, and last-used timestamp. The underlying browser state is managed by the driver layer and isn’t directly exposed through this API.

Tier caps

Each tier limits the maximum number of profiles per account. Crossing the cap on POST /v1/profiles (or POST /v1/profiles/:id/clone) returns 429 Tier limit. Values mirror PROFILES_PER_TIER in @driftstack/api-types:

TierProfiles cap
free1
solo_manual10
team_manual50
agency_manual200
api_starter25
api_builder100
api_scale500
enterprisecustom

The cap on enterprise tier is negotiated; the API returns profile_cap: null on /v1/account/me for enterprise customers.

Resource shape

{
  "id": "prof_<uuid>",
  "name": "production",
  "archetype": "iphone17_ios18_7_safari26_4",
  "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 + OS + Safari version triple. New profiles default to iphone17_ios18_7_safari26_4. You can pin to an older archetype for behavioural-stability reasons (e.g. hold a profile on iOS 17 while you migrate). Once set, the archetype is sticky 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 — touched by SessionsService when a session is created against this profile. null until first use.

Create

POST /v1/profiles

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

archetype and description are optional. Returns the created profile (200, not 201 — the API surface uses 200 for both idempotent and one-shot resource creation).

Errors:

  • 400 ValidationFailed — invalid name shape, missing required field, or description over 2048 chars.
  • 409 Conflictname already exists in this account.
  • 429 TierLimit — account at the profile cap. Body extension: {limit, current, resource: "profile", tier}.

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.

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"
}

Both fields optional; pass description: null to clear. The archetype is intentionally not editable — repin via POST /v1/profiles/:id/clone with a new archetype, then delete the old profile after migration.

Launch

POST /v1/profiles/:id/launch

{ "label": "checkout-run-2026-05-20" }

label is an optional override; everything else flows from the profile (archetype + metadata inherited, last_used_at bumped server-side fire-and-forget). 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 GUI’s Live session view, which mounts on the returned session.id).

Per-session customer-configurable egress is not available on this endpoint (or on POST /v1/sessions) yet — the execution backend behind both has no driver-layer proxy plumbing today, so there is no proxy body field to set here. If you need customer- controlled egress today, use POST /v1/agent-sessions with a proxy_id referencing one of your saved /v1/account/me/proxies configs instead — that resource dispatches to the real device fleet and actually routes traffic through the attached proxy.

Errors:

  • 404 if the profile isn’t owned by the calling account (deliberate anti-enumeration — cross-account profile_id is indistinguishable from a missing one).
  • 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, then launch.
  • Any error the underlying POST /v1/sessions can return — most commonly 429 (concurrent-session cap reached), or 400 on deployments that force the raw-HTTP egress safeguard (SESSION_PROXY_REQUIRED=true) if the request body lacks a proxy key entirely. Not reachable via the official SDKs, which don’t expose a way to send this field (see the egress note above).

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. Underlying browser state is NOT cloned — the new profile starts with a fresh state slot under the same archetype. Use clone primarily for:

  • Forking metadata before pinning the source to a different archetype.
  • 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.

Transfer

POST /v1/profiles/:id/transfer

{ "recipient_account_id": "acc_3f2b1c9d-0e4a-4b6c-8d1e-2f3a4b5c6d7e" }

Moves a profile to another Driftstack account. The recipient finds their acc_<uuid> account id on their Settings page and shares it with you out-of-band (chat / email); you paste it here. The lookup is by account id, not email — there is no address-enumeration path.

The transfer creates a fresh profile under the recipient (inheriting the source’s archetype + description) and deletes the source from your account in the same operation. Underlying browser state is not carried across — the recipient’s profile starts with a fresh state slot under the same archetype. If the recipient already has a profile with the same name, the new one is suffixed ${name} (transferred).

Returns:

{
  "new_profile": { "id": "prof_…", "name": "…", "archetype": "…", "...": "…" },
  "recipient_account_id": "acc_…"
}

Errors:

  • 400recipient_account_id is malformed (must be acc_<uuid>).
  • 400 — recipient is your own account (transfer to yourself is a no-op).
  • 404 — no account exists for that id, or the source profile isn’t found / not owned by you.
  • 429 — the recipient is at their tier’s profile cap, or has hit the per-billing-cycle inbound-transfer cap (twice their profile cap).

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 a file-based alternative to Transfer: 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. Underlying browser state is not in the envelope.

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": "iphone17_ios18_7_safari26_4",
    "description": "primary prod-data scrape profile"
  }
}

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. The envelope is versioned so a future v2 (extending to driver state) stays back-compat.

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. 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 or not a v1 shape.
  • 409 Conflictname (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 archetype, name, description, and underlying browser state mutate as you use it. The snapshot’s recorded metadata (archetype, name, description) is frozen the moment you capture it. Browser state (cookies, logins) is NOT captured at v1 — 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 owned by the calling 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/:id204 No Content.

Snapshots have no automatic lifecycle. Capture as many as you want; they sit until you delete them. Deleting the parent profile sets the snapshot’s parent_profile_id to null but does NOT delete the snapshot — the captured parent_archetype + parent_name + description remain restorable.

Delete (recycle bin)

DELETE /v1/profiles/:id

Soft-deletes the profile — it moves to the recycle bin rather than being destroyed. A trashed 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.

A trashed profile still counts against your tier’s profile cap until it’s purged — soft-delete preserves the underlying encrypted state, so it occupies a slot. Free the slot immediately with Purge (below), or wait for the 30-day auto-purge.

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

Trashed profiles are retained for 30 days, then permanently purged (the underlying state is hard-deleted at that point and is no longer recoverable).

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 a non-null deleted_at timestamp. Read scope suffices (both team roles).

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

Purge (permanent delete)

DELETE /v1/profiles/:id/purge204 No Content

Permanently deletes a trashed profile and frees its cap slot immediately — use it to reclaim a slot without waiting for the 30-day auto-purge. 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 recycle bin, never on a live profile (delete it first).

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). Team RBAC: X-Driftstack-Account is honored for both reads and writes — member roles cannot write on the owner’s account; admin members can.

Trim cached site data

POST /v1/profiles/:id/trim

Reclaims storage by clearing a profile’s re-fetchable caches (HTTP/media cache and per-origin CacheStorage / service-worker registrations) while keeping the identity state — cookies, localStorage, IndexedDB, and open tabs. Use it when an account is near its storage cap and you want space back without losing the profile’s logins. No request body; write scope (write:profiles), admin-only on a team workspace, same as the other mutating routes.

The trim runs out-of-session on the fleet against the profile’s last-saved encrypted state, 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 the cache" }

// "timeout" — the node didn't reply
{ "status": "timeout" }

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

unavailable covers the expected-inert states: the profile is bound to a still-running session (stop it first — 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 the deployment’s profile storage / fleet control plane isn’t enabled. 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 calling account.

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 state mutations are persisted back to the profile row’s underlying storage. Concurrent sessions on the SAME profile are serialised at the driver layer to avoid state-merge conflicts.

See Session lifecycle for the full flow.