Session lifecycle
A session is a browser session your code drives step by step through the sessions API. On hosted Driftstack only agent sessions load pages, so use agent sessions for real browsing. Every session occupies one of your account’s concurrent slots from creation until destruction; understanding the lifecycle is the difference between using your tier’s capacity well and burning slots on stuck sessions.
On hosted Driftstack
The step-by-step calls on this page (navigate, interact, wait, state, capture) run on a self-hosted deployment. On hosted Driftstack they do not run today:
- A create (or a profile launch) needs
proxy_id, the id of one of your saved proxies. You can leave it out when the profile is bound to a proxy (theprofile_idof a create, or the profile you launch). The session then uses that proxy. With neither, the create is refused with422 proxy-required, and nothing is created. If the profile has no proxy any more, the create is refused with409(code: "proxy_detached") until you send aproxy_id. That happens when its proxy was deleted or never shared with this workspace, or when the profile was markedproxy_choice: "detached". - With a proxy, the session starts, but navigate, interact, wait, extract, state and capture answer
503 driver-not-integratedand run nothing.
To drive pages on hosted, use an agent session: POST /v1/agent-sessions with the same proxy_id, then send your own steps with POST /v1/agent-sessions/{id}/steps, or a task in plain words with POST /v1/agent-sessions/{id}/message. The quickstart runs one end to end. More on this: Step-by-step operations on the hosted service.
States
The session.status field has five values: creating / ready / busy / destroyed / errored.
create
│
▼
┌──────────┐ (transient — the browser ┌───────┐
│ creating │───────────────────────────────────▶ │ ready │
└──────────┘ is being started) └───────┘
│ ▲
navigate / interact │ │ call finishes
/ wait / capture │ │
▼ │
┌──────┐
│ busy │
└──────┘
│
│ destroy
▼
┌───────────┐
│ destroyed │
└───────────┘
(or `errored` if the browser fails)
sessions.create() returns only once the session is ready, although a list or read made at the same time can still show it as creating. Each driving call (navigate, interact, wait, capture) moves the session from ready to busy; while it is creating or busy, any other call returns 409 Conflict. When the call succeeds the session goes back to ready. If the browser fails, the session becomes errored; if you destroy the session mid-call, it becomes destroyed and the interrupted call returns 410 Gone.
Concurrency
Each plan has a hard cap on simultaneously-active sessions. Exceeding the cap returns 429 Too Many Requests on sessions.create(), with current_sessions and limit in the problem body. Unlike rate-limit 429s there is no Retry-After header — capacity frees when one of your sessions ends, so destroy one you created (or wait for your own workflow to finish) and retry.
| Plan | Tier id | Concurrent sessions |
|---|---|---|
| Trial | trial |
1 |
| Starter | starter_v3 |
1 |
| Pro | pro_v3 |
3 |
| Team | team_v3 |
6 |
| Scale | scale_v3 |
12 |
| Enterprise | enterprise |
20+ |
Enterprise caps are set by contract.
The earlier plans, no longer offered, keep their caps for the accounts that hold them:
| Plan | Tier id | Concurrent sessions |
|---|---|---|
| Personal | solo_manual |
1 |
| Team | team_manual |
3 |
| Agency | agency_manual |
8 |
| API Starter | api_starter |
2 |
| API Builder | api_builder |
8 |
| API Scale | api_scale |
24 |
Concurrent sessions are the only thing a paid plan meters: there is no monthly hours allowance and no overage charge. That is about total usage; a single session still ends on its own (see How long a session runs on a paid plan below). The free trial has a session limit and a total: a trial session is closed after 20 minutes, and the trial includes 2 hours of session time in total, counted from the day it starts across every session the account runs. Once the 2 hours are used, creating a session is refused with 429 tier-limit (code: "trial_session_time_used").
Pricing source of truth: driftstack.io/pricing.
Create
const session = await client.sessions.create({
label: 'checkout flow',
// proxy_id: one of your saved proxies (required on hosted Driftstack)
// archetype: optional; without it, a random current iPhone your plan includes
// metadata: optional Record<string, unknown> for your own tracking
});
console.log(session.id, session.created_at);
Returns a Session with id, archetype, status, label, metadata, created_at. The id is the handle for every subsequent call.
Tier check: if you’re at your concurrent cap, the call returns 429 concurrency-limit. If your tier’s profile cap is reached on a profile-binding flow, or the free trial’s 2 hours of session time are used up (code: "trial_session_time_used"), 429 tier-limit. If your account is suspended, 403 forbidden.
Drive: navigate, interact, wait
A session is driven through three primary methods plus state introspection. On hosted Driftstack these answer 503 driver-not-integrated (see above); use an agent session’s steps there.
POST /v1/sessions/:id/navigate — go to a URL.
const result = await client.sessions.navigate(session.id, {
url: 'https://example.com/checkout',
wait_until: 'load', // the default; or 'domcontentloaded', 'networkidle'
timeout_ms: 30_000,
});
console.log(result.final_url, result.status, result.duration_ms);
wait_until controls when the call returns. load returns on the load event; domcontentloaded is faster but earlier; networkidle is accepted and currently waits for the load event, the same as load. For a single-page app that renders its content after the page loads, follow navigate with a wait for the element you need.
POST /v1/sessions/:id/interact — tap, scroll, type, or press keys on the page.
POST /v1/sessions/:id/wait — block until a selector appears, a URL pattern is reached, or a timeout elapses.
GET /v1/sessions/:id/state — live page introspection: current url, title, cookies + local_storage, and a captured_at timestamp. This call runs in the live browser session and marks it busy while it captures, so poll it sparingly and only while the session is ready (a call made while the session is busy returns 409); to check creating / busy without tying up the session, use GET /v1/sessions/:id or the list endpoint instead. When acting as a team owner, state requires the admin role because it returns browser secrets such as cookies and local storage; a member can still read list/detail metadata but gets 403 for state, and the session is left untouched. Self-account read:sessions access is unchanged.
Capture
POST /v1/sessions/:id/capture returns a screenshot, DOM snapshot, or PDF.
const shot = await client.sessions.capture(session.id, { kind: 'screenshot' });
// shot.kind, shot.data, shot.encoding, shot.byte_size, shot.duration_ms
The response carries the capture inline — data is the content itself (base64-encoded for screenshots and PDFs) — and nothing is stored server-side. Persist the bytes yourself if you need them long-term.
Destroy
await client.sessions.destroy(session.id);
destroy is idempotent — calling it twice on the same id is a no-op the second time. It releases the concurrent slot immediately. If the session was bound to a profile, the profile’s storage state is captured and saved on a clean destroy.
Always destroy. A forgotten session holds its concurrent slot until it ends, and that can take hours. A try / finally around your session work is the safe pattern:
const session = await client.sessions.create({
proxy_id: 'a1b2c3d4-...', // your saved proxy's id; required on hosted (422 without it)
});
try {
await client.sessions.navigate(session.id, { url: 'https://example.com' });
// … your logic
} finally {
await client.sessions.destroy(session.id);
}
Python and Go SDK examples follow the same pattern (with block in Python sync; defer in Go).
How long a session runs on a paid plan
A paid plan has no hour limit, but a session does not run for ever. A session that no AI agent drives (one started with POST /v1/sessions through a proxy, a profile launch, or a manual session in the desktop app) runs for up to 4 hours (closed_reason: "max_duration") and is ended sooner after 30 minutes with no input. A session the AI agent drives is not sent the 4-hour limit; it is ended after about 5 minutes with nothing running and no input. Any session still open about 12 hours after it was created is closed, however busy it is (orphaned-lifetime), and a session also ends when the machine running it loses it. Until a session ends, it holds its concurrent slot, which is why the try / finally pattern above matters. Its slot is free as soon as it has ended, with no DELETE needed. The free trial is the exception: a trial session is closed after 20 minutes (closed_reason: "trial_session_limit"), or sooner if the trial’s 2 hours of session time run out during it (closed_reason: "trial_time_used"). Each trial session gets 20 minutes or what is left of the 2 hours, whichever is shorter; the session is told the limit when it starts, and Driftstack also closes a session still running within about two minutes of the mark.
When a free trial ends
Once a free trial has ended, creating a session is refused with 402 until the account chooses a plan. Every other call keeps working. We keep your profiles, proxies and settings for 30 days after your trial ends. The 402 problem body’s type ends in trial-ended; when the trial ends, and whether it has, is shown in the dashboard. A paid plan that has ended is refused the same way, with code: "plan_ended" and the detail “Your plan has ended…”; it keeps its profiles, proxies and settings. After a failed renewal payment, a session start is refused the same way at once (there is no grace period), with code: "payment_failed" and the detail “Your last payment didn’t go through…”; sessions already running are closed within about 2 minutes. While the card is still being retried, everything works again as soon as the payment goes through. Once the last retry fails, the plan has ended: a session start then gets code: "plan_ended", and the account chooses a plan again.
Error shapes
Every error returned by the session endpoints conforms to the problem+json shape with a type URL identifying the error class:
429 Too Many Requests(https://errors.driftstack.dev/rate-limited) — global / per-bucket rate limit exceeded.Retry-Aftercarries the wait time.429 Too Many Requests(https://errors.driftstack.dev/concurrency-limit) — concurrent-session cap reached. Wait for an active session to finish.429 Too Many Requests(https://errors.driftstack.dev/tier-limit) — a tier-derived cap (e.g. profile count) is reached, or the free trial’s 2 hours of session time are used up (code: "trial_session_time_used").404 Not Found— session ID doesn’t exist (or was destroyed and has since been cleaned up).409 Conflict— the session is stillcreating, or another operation is already running (busy); retry once it reportsready.410 Gone(https://errors.driftstack.dev/session-destroyed) — the session isdestroyedorerrored, or this operation lost a race to destroy; create a fresh session.502 Bad Gateway/503 Service Unavailable— the browser session hit an error, or the requested feature is not available (driver-error/driver-not-integrated/feature-unavailable).
The SDKs map these to typed error classes — catch RateLimitError, ConcurrencyLimitError, the tier-limit class (TierLimitError in TypeScript, QuotaExceededError in Python and Go), SessionDestroyedError, DriverError, etc. The full mapping lives at /reference/errors.
Session events on the webhook bus
If you’ve configured a webhook endpoint, terminal session events fire on the bus. session.completed and session.failed come only from the direct sessions this page describes; an agent session sends no session.completed or session.failed (read its status and closed_reason instead), and of the events below it sends only session.challenge_detected and session.profile_save_failed.
session.completed— one per logical destroy of a non-terminal session: a customer-driven destroy, the session time limit of the free trial, a session that ended on its own where it ran, an account suspension reclaiming its live sessions, or a paid plan that lapsed (a renewal payment that did not go through,reason_code: "payment_failed", or a plan that ended,reason_code: "plan_ended"). All but the first also sendauto_destroyed: trueand areason; branch onauto_destroyedif you attribute completions. A close at the plan’s session limit may arrive with either of two reasons (auto-destroyed: free-tier session duration caporthe session ended where it ran), andthe session ended where it ranalso covers every other way a session started withPOST /v1/sessionsor a profile launch ends where it ran: 30 minutes without activity, its 4-hour limit, the trial’s used-up session time, the close about 12 hours after create and the machine stopping it. So do not read the cause from the reason text.session.failed— the session ended because of an unrecoverable error (for example, a timeout or a crash during a page action). Create a new session to continue.session.egress_capability_changed— the session reported its proxy capabilities. It fires on every report, not only when the state changed: there is no change detection, so identical consecutive reports each emit an event with a freshevent_id. Treat it as “here is the current capability state” and compare against what you last stored, rather than as a transition signal. Note also thatwarningscarries video-stream faults (streaming_blank,streaming_failed) alongside proxy ones likedead_proxy, so this event can fire when only the video stream degraded.session.challenge_detected— the session detected a bot-check (DataDome / Arkose / PerimeterX / AWS-WAF / GeeTest / …). The session auto-pauses; resolve the challenge (e.g. in the live view) and it resumes.session.profile_save_failed— a profile-backed session did not replace the stored profile at teardown. Failure reasons are terminal and the next restore will be stale;supersededis harmless and means a newer save of the same profile landed first, andprofile_not_loadedmeans the session started without its saved profile, so its changes were not saved over it and the saved profile is kept as it was.
Intermediate state transitions (e.g. a hypothetical session.created) are not on the bus today. Read the session resource or list endpoint for persisted lifecycle status; do not poll sessions.getState while the session is busy with another operation. See the webhook events catalog for full payload shapes and signature verification.
Notes
- A destroyed session requires a fresh
sessions.create(); sessions are not resumable after destroy. Plan your workflow to recreate cleanly when a long pause is expected. - A session left in
busyafter a server crash is not reset automatically: the operation that was running may already have changed the page, and repeating it could duplicate that work. Destroy the session and create a fresh one. - There are no per-session bandwidth or memory limits for you to manage today. The limits that govern sessions are your tier’s concurrent-session cap and the session time limits above (rate limits and profile caps are separate — see Error shapes above).
Next steps
- Profile management — bind sessions to profiles for storage-state continuity.
- Webhook events — react to session state transitions.
- API versioning — how additive lifecycle fields roll out.