Profile management
A profile is a persistent identity Driftstack maintains across sessions. Cookies, local storage, IndexedDB, and the browser fingerprint are kept between sessions when a session uses a profile.
If a session doesn’t bind a profile, it starts ephemeral — fresh cookies, fresh storage, no continuity. That’s the right choice for one-shot fetches. For workflows that need login state, multi-step flows, or returning-visitor signals, bind a profile — pass profile_id on sessions.create(), or call profiles.launch() (see “Bind a session to a profile” below).
Tier limits
Each plan has a profile cap, enforced at POST /v1/profiles creation time. Exceeding the cap returns 429 with an RFC 9457 https://errors.driftstack.dev/tier-limit problem body — the detail string names the plan and the cap, 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.” Only live profiles count: a profile in the trash counts toward no limit, so a delete frees its slot at once. An account that already holds more profiles than its plan keeps (after a downgrade, or on the trial) loses none of them, but only the most recently used, up to the cap, can launch — on the trial, only the most recently used one. That holds on every launch path (POST /v1/agent-sessions, POST /v1/sessions with a profile_id, POST /v1/profiles/:id/launch); any other profile is refused with 429 tier-limit, code: "over_plan_limit".
| Plan | Tier id | Profile 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 | Profile 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 |
Plans and prices: driftstack.io/pricing.
Self-hosted licences set a profile limit (Solo 25, Pro 100, Enterprise none); how many sessions run at once depends on your Macs.
Create a profile
POST /v1/profiles with at minimum a name. The archetype field is optional: when omitted, the profile gets a randomly chosen iPhone model from those your plan includes that are open for new profiles (GET /v1/archetypes lists the models), drawn once and kept. Every paid plan includes every device; the free trial includes the iPhone 13 and iPhone 13 mini only. Choose an older device profile only if your workflow specifically depends on that device’s behaviour.
TypeScript:
const profile = await client.profiles.create({
name: 'shopper-account-1',
description: 'Returning-visitor profile for the shopping flow',
});
console.log(profile.id, profile.archetype);
Python:
profile = client.profiles.create({
"name": "shopper-account-1",
"description": "Returning-visitor profile for the shopping flow",
})
Go:
profile, err := client.Profiles.Create(ctx, &driftstack.CreateProfileRequest{
Name: "shopper-account-1",
Description: "Returning-visitor profile for the shopping flow",
})
The response is the full Profile:
{
"id": "prof_01HV...",
"name": "shopper-account-1",
"archetype": "iphone15_ios18_7_safari26_5",
"description": "Returning-visitor profile for the shopping flow",
"last_used_at": null,
"created_at": "2026-05-07T11:00:00.000Z",
"updated_at": "2026-05-07T11:00:00.000Z"
}
name is unique within an account. Re-using a name returns 409 Conflict.
List profiles
GET /v1/profiles returns paginated results with cursor-based pagination.
const { data, has_more, next_cursor } = await client.profiles.list({ limit: 50 });
for (const p of data) console.log(p.name, p.last_used_at);
The last_used_at field updates every time a session binds to the profile — both on POST /v1/sessions with a profile_id and on POST /v1/profiles/:id/launch. Sort by it client-side to find recently active profiles.
Get one profile
GET /v1/profiles/:id:
const profile = await client.profiles.get('prof_01HV...');
Bind a session to a profile
To drive a profile’s pages through one of your proxies, launch it with an agent session. The two direct endpoints below —
sessions.create()andprofiles.launch()— take a savedproxy_idtoo (or use the proxy the profile is bound to), and where every session runs through a proxy of yours — hosted Driftstack runs them that way — they answer422 proxy-requiredwithout one; but a session they start through a proxy cannot run the step-by-step operations yet. Useclient.agentSessions.create({ profile_id: 'prof_01HV...', proxy_id: '<proxy id>' })to drive it: the session restores the profile, goes out through that saved proxy, and saves the profile back when it ends.
POST /v1/sessions accepts an optional profile_id field. When supplied, the session’s archetype defaults to the profile’s (so you can leave it out), {profile_id, profile_name} is recorded in the session’s metadata, and the profile’s last_used_at is updated:
const session = await client.sessions.create({
profile_id: 'prof_01HV...',
// archetype optional — inherited from profile when absent
label: 'checkout-run-2026-05-20',
});
Cross-account profile_id returns 404 (anti-enumeration — indistinguishable from a missing one).
Launch a profile (one-shot)
For the launch-a-saved-profile flow (where the typical action is “give me a session on this profile, right now”), the SDK exposes profiles.launch(id, body?) as a one-round-trip alternative to sessions.create({ profile_id }):
const session = await client.profiles.launch('prof_01HV...', {
// `label` names the session in the dashboard. The only other field
// this endpoint accepts is a saved `proxy_id`.
label: 'checkout-run-2026-05-20',
});
Returns the freshly-minted session (same shape as sessions.create). The dashboard /profiles page exposes a per-row Launch button that calls this endpoint and surfaces the returned session.id; from there you drive the session via the desktop app’s Live session view. The navigate/interact/wait/capture calls on /v1/sessions run only on a self-hosted deployment: on hosted Driftstack a launch needs a proxy (see below), and those calls answer 503 driver-not-integrated (why).
profiles.launch() and sessions.create() take a saved proxy_id, or use the proxy the profile is bound to; without one, a deployment that requires a customer proxy refuses them with 422 proxy-required, and hosted Driftstack runs every session through a proxy of yours. On such a deployment, if the profile has no proxy any more, they answer 409 (code: "proxy_detached") until you send a proxy_id. That happens when its proxy was deleted or never shared with this workspace, or when the profile was marked proxy_choice: "detached". They never pick another proxy for you. A session they start through a proxy cannot run the step-by-step operations yet, so to drive pages through one of your saved proxies or VPNs, use client.agentSessions.create({ profile_id, proxy_id }); the session uses that saved proxy, and you drive it with your own steps (agentSessions.runSteps) or a task in plain words (agentSessions.message).
Profile-bound sessions inherit the profile’s storage state on launch and write new state back on clean destroy. (On a paid plan a session started with profiles.launch() or sessions.create() runs for up to 4 hours, and it ends on its own sooner after 30 minutes without activity or when the machine running it loses it; its slot frees then. A session started with agentSessions.create() is an AI or pair session unless you pass mode: "manual": it is not sent the 4-hour limit, closes after about 5 minutes with no step running and no input, and is closed about 12 hours after it was created at the latest. On the free trial a session is closed after 20 minutes, and the trial includes 2 hours of session time in total.) Without a profile_id, sessions start ephemeral.
Delete a profile
DELETE /v1/profiles/:id. Soft-delete — the profile moves to a recycle bin: hidden from your list, its name freed for reuse, but restorable for at least 7 days (after which it’s permanently purged at the next daily clean-up). A profile in the recycle bin counts toward no limit, so the delete frees its slot at once.
await client.profiles.delete('prof_01HV...');
Idempotent — there’s no force flag, and re-deleting an already-trashed profile still returns 204. To recover, list GET /v1/profiles/trash (each row’s purges_at is the earliest time the profile is permanently deleted: at least 7 days after the delete, with removal at the next daily clean-up) then POST /v1/profiles/:id/restore (see the API reference).
A restore needs a free slot under your plan’s profile cap, and each account may restore a set number of profiles in any rolling 30 days: Trial 3, Starter 3, Pro 10, Team 30, Scale 100, Enterprise 100. Past either one the restore is refused with 429 tier-limit (code: "restore_needs_slot" or code: "restore_cap"), the profile stays in the recycle bin and no restore is used. See Restore.
Clone a profile
POST /v1/profiles/:id/clone. Creates a new profile with the same archetype and description as the source. Stored browser state is NOT copied — the new profile starts empty.
// Auto-derived "(copy)" / "(copy 2)" / ... naming.
const copy = await client.profiles.clone('prof_01HV...');
// Or pass an explicit name.
const named = await client.profiles.clone('prof_01HV...', { name: 'staging-mirror' });
Tier-cap + name-conflict are checked the same way as create: 429 if your tier limit would be exceeded, 409 on explicit-name collision, 404 if the source profile isn’t yours or doesn’t exist. The audit-log entry for the new profile, in the dashboard’s audit log, carries payload.cloned_from: "profile_<uuid>" (the internal profile_ prefix).
Snapshots — immutable point-in-time copies
A snapshot is a frozen copy of a profile. The parent profile keeps evolving — its name, description, and storage state mutate as you use it. The snapshot does not. What it keeps is the profile’s name and archetype at capture time (parent_name, parent_archetype) and the label and description you give it. It does not keep the profile’s browser storage; see the note below.
Capture. POST /v1/profiles/:id/snapshots.
const snap = await client.profileSnapshots.capture('prof_01HV...', {
label: 'before-iOS-26',
description: 'pre-rollout reference', // optional
});
// snap.id — "psnap_<uuid>"
// snap.parent_archetype, snap.parent_name — frozen at capture time
List. Per-profile or cross-account.
const perProfile = await client.profileSnapshots.listForProfile('prof_01HV...');
const everySnapshot = await client.profileSnapshots.list();
// Iterate every snapshot in your account, walking cursor pages.
for await (const s of client.profileSnapshots.iterate()) {
console.log(s.label, s.captured_at);
}
Restore. Creates a NEW profile (the original is never modified).
const restored = await client.profileSnapshots.restore(snap.id, {
name: 'restored-from-baseline',
});
Tier-cap + name-conflict apply the same way as create. The audit-log entry on the new profile carries payload.restored_from_snapshot: "psnap_<uuid>" (the public psnap_ prefix).
Delete. DELETE /v1/profile-snapshots/:id.
Snapshots have no automatic lifecycle. Capture as many as you want; they sit until you delete them. Deleting the parent profile keeps the snapshot — the captured parent_archetype, parent_name, and description remain restorable. Because customer DELETE is a soft delete into the 7-day recycle bin, parent_profile_id stays populated while the parent sits in the bin and only becomes null at hard purge, so a non-null value is not proof the parent is still reachable.
A snapshot does NOT preserve browser state. Snapshots do not store browser state today, so a restored profile starts with no cookies, no localStorage, no IndexedDB and a fresh encryption key. Do not treat a snapshot as a backup of a logged-in session, and do not delete a parent profile expecting its storage to survive in a snapshot — that data is not recoverable from one. What a snapshot restores is the profile’s identity and metadata, not its contents. (Customer DELETE on a profile is a soft-delete into a 7-day recycle bin; parent_profile_id is nulled at hard purge.)
The same surface is available in the Python and Go SDKs as client.profile_snapshots.* and client.ProfileSnapshots.* respectively.
Profile-name conventions
Profile names are free-form strings up to 120 characters. Conventions that work well:
<flow>-<account-or-persona>-<index>— e.g.signup-test-acct-3,shopper-personaA-1.<environment>-<purpose>— e.g.staging-smoke,prod-canary.
Names ARE visible in the dashboard and any team-member access logs. Don’t put PII or secrets in profile names; use description for human notes if you need them.
Device profiles
A device profile (archetype in the API) is the device, OS and browser version a session presents. There is no default device: a new profile that omits archetype gets a randomly chosen iPhone model from those your plan includes that are open for new profiles. To choose one, read GET /v1/archetypes at runtime instead of predicting or constructing a slug.
Profiles pin to one device profile at creation time. The pin is stable: a profile created against iphone16pro_ios18_7_safari26_4 keeps that fingerprint forever, even after newer iPhones are added or that model closes for new profiles. This stability is intentional — a returning profile shouldn’t suddenly show up on a different iOS version.
To move a profile to a newer device, create a fresh profile pinned to the new device profile and walk through any session-state migration manually.
What gets persisted
When a session binds to a profile, on session destroy the profile’s storage state captures:
- HTTP cookies (incl.
Secure,HttpOnly,SameSiteattributes; partition keys preserved). - WebStorage:
localStorageandsessionStorage(per-origin partitions). - IndexedDB databases (per-origin partitions).
- Service Worker registrations + Cache Storage entries (per-origin partitions).
- The browser’s fingerprint, reused so the profile looks the same each time.
What does NOT persist:
- The DOM tree of the last page (sessions always start with a fresh page).
- Active WebSocket / EventSource connections (open a fresh one on the next session).
- WebRTC peer connections.
Next steps
- Session lifecycle — full lifecycle reference including profile-binding semantics.
- API versioning — how additive fields roll out without breaking existing SDK calls.
- Webhook events — the events you can subscribe to. Profile changes are not webhook events; they are recorded in the account’s audit log (
profile.created,profile.deleted).