Drıftstack docs
Docs

Sessions

A session is a browser session your code drives step by step, occupying one of your account’s concurrent slots from creation to destruction. Use it to navigate URLs, interact with the page, capture screenshots / DOM state, and tear it down cleanly. On hosted Driftstack only agent sessions load pages, so use agent sessions for real browsing. Every hosted session needs a proxy of yours (proxy_id), and the step-by-step operations on this page answer 503 on a session that runs through a proxy, so none of them can drive a hosted session yet (see Step-by-step operations on the hosted service). To send your own steps, use POST /v1/agent-sessions/{id}/steps.

For the higher-level lifecycle + state diagram, see Session lifecycle. This page is the endpoint reference.

Step-by-step operations on the hosted service

On hosted Driftstack the step-by-step operations on this page — navigate, interact, wait, get-state, capture, extract, search and login — are not available today. That is how the hosted service works at the moment, not a setting on your account: there is no flag or account setting that turns them on.

Why: hosted Driftstack runs every session through a proxy of yours, so a create here needs a proxy_id, or a profile_id whose profile is bound to a proxy. Without either it is refused with 422 proxy-required. A session with a proxy runs through it on the same machines as agent sessions, and the step-by-step operations have no way to reach a session there yet. Each one answers 503 driver-not-integrated before anything runs; the session is unchanged and stays ready.

What to use instead: an agent session (POST /v1/agent-sessions with the same proxy_id). It runs through your proxy, and drives it two ways:

  • POST /v1/agent-sessions/{id}/message — an AI turn: you write the task in plain words and the agent plans and runs the steps.
  • POST /v1/agent-sessions/{id}/steps — steps you write (navigate, tap, type, wait, capture, extract and the rest), run as written with no AI planning. This is the step-by-step way to drive a hosted session.

Both return structured per-step results (results, with diagnosis and warning).

The create still answers 201, because the session it starts is real: it runs through your proxy, reports that proxy in proxy_id and what the proxy delivered in egress_capabilities, and is listed by GET /v1/agent-sessions as a manual session (see Running a session through your proxy). It also holds one of your concurrent-session slots until you destroy it (DELETE /v1/sessions/{id}) or it ends on its own, so destroy it when you are done with it.

Concurrency

Each plan caps how many sessions are active at once (the public @driftstack/api-types package also exports the caps as the TIER_CONCURRENT_SESSION_LIMITS constant):

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

Hitting the cap on POST /v1/sessions returns 429 Too Many Requests with current_sessions and limit in the problem body (no Retry-After header — that header is only sent on rate-limit 429s; retry once one of your sessions ends).

A plan change that lowers the cap closes nothing. Sessions already running when the plan drops below their number are left to finish: each runs until you close it or it ends on its own, as above. The new cap applies to the next create at once, so creates answer 429 concurrency-limit until fewer sessions are open than the new plan allows. A plan that ends is not a downgrade: the account is back on the ended Trial, which starts nothing.

An open session counts against the cap until it is closed, whether or not anything is running on it: until you destroy it, or it ends on its own (after a time without activity, at its time limit, or when the machine running it loses it). A session that has ended frees its slot at once, with no DELETE needed, even before its status here reads destroyed. So on a plan whose cap is 1, a session an earlier run left open makes the next create answer 429 — the problem type ends in concurrency-limit, limit is the account’s session cap, current_sessions repeats that cap (it is not a separate count), and nothing was created. It is not a fault: close a leftover you created and create again. To find leftovers, list your sessions with GET /v1/sessions (a status of creating, ready or busy is open) and end each that you created with DELETE /v1/sessions/{id}; for agent sessions, list with GET /v1/agent-sessions (any status but closed is open) and close the ones you created with DELETE /v1/agent-sessions/{id}. A session started here through a proxy also takes one of your agent-session slots (it is listed there as a manual session), so it counts on both, and an open agent session can refuse a create here the same way. Close sessions in a finally block so a run that fails part-way does not leave one open. On a paid plan, a session runs until you destroy it or it ends on its own: after 30 minutes without activity, at its time limit (4 hours for a session started here through a proxy or by a profile launch), or when the machine running it loses it. On the free trial a session is closed for you 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. A trial session runs for 20 minutes or for what is left of the 2 hours, whichever is shorter; one still running when the 2 hours run out is closed. Once they are used, creating a session is refused with 429 tier-limit (code: "trial_session_time_used"). Once a free trial has ended, creating a session is refused with 402 until the account chooses a plan; the problem body’s type ends in trial-ended, and the dashboard shows when the trial ended. A paid plan that has ended is refused the same way, with code: "plan_ended" and the detail “Your plan has ended…”. After a failed renewal payment it is refused the same way at once, with no grace period, with code: "payment_failed" and the detail “Your last payment didn’t go through…”, while the card is still being retried. Once the last retry fails, the plan has ended and the refusal says so (code: "plan_ended"). On the trial, a session started from a profile set up on a device the trial does not include (it includes the iPhone 13 and iPhone 13 mini) is refused with 403 (code: "device_not_on_plan").

Resource shape

{
  "id": "ses_<uuid>",
  "account_id": "acc_<uuid>",
  "api_key_id": "key_<uuid>",
  "status": "ready",
  "archetype": "iphone15_ios18_7_safari26_5",
  "archetype_source": "explicit",
  "purpose": "production_customer",
  "label": "login flow",
  "metadata": null,
  "proxy_id": "6f1c2a9e-4b7d-4c1e-9a3f-2d8e5b6c7a10",
  "egress_capabilities": null,
  "egress_capability_report": null,
  "created_at": "2026-05-09T22:00:00.000Z",
  "updated_at": "2026-05-09T22:00:30.000Z",
  "last_state_at": "2026-05-09T22:00:30.000Z",
  "destroyed_at": null
}

status is one of creating, ready, busy, destroyed, errored. The SDK’s sessions.create() call returns only after the new session reaches ready, but concurrent resource reads and lists can show it as creating while the browser starts.

Each operation moves the session from ready to busy; a session that is already creating or busy returns 409 Conflict. When the operation succeeds the session returns to ready. If the operation fails the session becomes errored; if you destroy the session while an operation is running it becomes destroyed, and the interrupted operation returns 410 Gone and sends no success or failure event. Both errored and destroyed are final — the session cannot be reused. A session left in busy after a server fault is not reset automatically: destroy it and create a fresh one instead of retrying the operation.

purpose is production_customer for every customer session (the default); other values are reserved for Driftstack internal use.

label is a free-form short string (max 120 chars) for the customer’s own identification — surfaced in dashboards + the audit log. metadata is an arbitrary JSON object for the customer’s own bookkeeping.

last_state_at is the most recent successful getState capture timestamp. Recording it never changes status. updated_at reflects any change to the record (status changes, metadata writes).

proxy_id is the saved proxy the session’s traffic goes out through: the proxy_id the create named, or the proxy the launched profile is bound to. That is usually one of your proxies. On a team, a session an admin started with a proxy saved on the admin’s own account reports the admin’s proxy, which your own proxy list does not show. It is there from the create’s response onwards, on every read of the session, and is kept after the session ends. It is null for a session started without a saved proxy. The key is missing when the proxy is not reported: on a read by a team member without admin role (who cannot read agent sessions either), on a read that cannot look the proxy up at that moment (it still answers, without the key; read the session again), and from an older server that does not send it. Read a missing key as “not reported”, not as null.

egress_capabilities and egress_capability_report are both null until a session routes through a SOCKS5 proxy and reports its proxy capabilities (and stay null for non-proxied sessions). A session started through your proxy reports them once its browser has started and checked the proxy, which is usually a little after the create returns: a read made straight after the create shows null there while proxy_id already names the proxy. Read the session again shortly after. When populated, egress_capabilities is the typed view — { udp_associate, quic_route, warnings[] } — and egress_capability_report carries the additional observations the device made about your session: the proxy’s declared capabilities and geography, the transport that was requested and the one that was active, whether a real HTTP/3 connection was observed, the exit IP / country / timezone the session presents, the IPs a WebRTC handshake would surface, and the live streaming and egress state. Prefer egress_capabilities for typed access; treat both as nullable on every read.

egress_capability_report is an opaque object: it is documented as additionalProperties and its contents may gain or lose keys without notice, so read individual keys defensively and do not depend on any one of them being present. Fields that describe Driftstack’s own infrastructure rather than your session — build identifiers, internal endpoints, internal diagnostics — are not part of it and never will be.

Its egressState, when present, is live (traffic is getting out), dead_proxy (your proxy stopped carrying traffic while the session ran), or default_connection_down (only on an agent session created before proxy_id became required, with no proxy of its own: the connection Driftstack provided for it then stopped carrying traffic — this was on our side, and the matching warning below says the same).

egress_capabilities.safeguards

safeguards is whether every egress safeguard held for this session: passed, failed, or unverified.

The key is absent entirely (not present as null) on a session reported before this field existed. Treat an absent value as unknown — never read it as unverified, and never as passed.

Value What it means
passed Every safeguard check reported for this session passed, and completeness was confirmed.
failed At least one safeguard check did not pass. Stop relying on the session and contact support with the session id.
unverified We could not confirm that every safeguard held — treat the session the same way you would failed for anything that matters.

failed takes precedence whenever it and unverified could both apply: a known failure is the strongest actionable fact even when completeness is also unverifiable.

The same list applies wherever egress_capabilities appears: GET /v1/sessions/{id}, GET /v1/sessions, POST /v1/sessions, POST /v1/profiles/{id}/launch, and the session.egress_capability_changed webhook.

egress_capabilities.warnings

warnings is a list of short codes naming anything that did not work as asked for this session. Read them as opaque strings: a code you do not recognise should be ignored rather than treated as fatal, and a new code can appear without an SDK upgrade. The field’s type never changes — it stays string[].

These are the codes the API publishes, and what you can do about each:

Code What it means What to do
udp_unsupported_by_proxy Your proxy refused the SOCKS5 UDP ASSOCIATE command, so QUIC cannot travel through it. Use a proxy that carries UDP if you need HTTP/3.
quic_unavailable QUIC was asked for but could not be used for this session, and traffic fell back to HTTP/2 over TCP. Retry on a new session if HTTP/3 matters to you.
dead_proxy Your proxy stopped answering while the session was running. Check that it is reachable before starting another session.
default_connection_down An older session with no proxy of its own lost the connection Driftstack provided for it. Nothing to fix at your end. New sessions run through one of your own proxies.
streaming_blank The live view of the session produced no picture. The session itself kept running. Reopen the live view, or read the session’s results without it.
streaming_failed The live view of the session stopped. Start a new session if you need to watch it.
safeguards_unverified We could not confirm that every egress safeguard ran for this session. Treat the session’s egress as unverified; start a new session if that matters to you.
safeguard_failed An egress safeguard did not pass. Stop relying on the session’s egress and contact support with the session id.
safeguard_failed:direct_internet_block The check that nothing leaves the session outside your proxy did not pass. Stop relying on the session’s egress and contact support with the session id.
safeguard_failed:browser_integrity The check that the session ran the expected browser build did not pass. Contact support with the session id.
safeguard_failed:proxy_egress_verification The check that the session’s traffic actually left through your proxy did not pass. Confirm your proxy is working, and contact support with the session id.
safeguard_failed:live_view_capture The live view of the session could not be captured. Nothing — the session’s own browsing is unaffected.

An agent session created before proxy_id became required, without a proxy of its own, ran on a connection Driftstack provided, so the codes that name your proxy — udp_unsupported_by_proxy, dead_proxy and safeguard_failed:proxy_egress_verification — never appear on it. For that session, a connection that carried no UDP reads quic_unavailable, a connection that stopped reads default_connection_down, and a failed check that its traffic left the right way reads safeguard_failed. Every agent session created since runs through one of your saved proxies.

Every safeguard code starts with safeguard_failed, so matching on that prefix catches all of them in one branch, including any added later.

The same list applies wherever egress_capabilities appears: GET /v1/sessions/{id}, GET /v1/sessions, POST /v1/sessions, POST /v1/profiles/{id}/launch, and the session.egress_capability_changed webhook.

Create

POST /v1/sessions

{
  "archetype": "iphone15_ios18_7_safari26_5",
  "purpose": "production_customer",
  "label": "login flow",
  "metadata": { "ticket": "SUP-42" },
  "profile_id": "prof_01HV...",
  "behavioral_profile": "regular",
  "proxy_id": "6f1c2a9e-4b7d-4c1e-9a3f-2d8e5b6c7a10"
}

Every field is optional, except that hosted Driftstack runs every session through a proxy of yours: there a create needs proxy_id (or a profile_id whose profile is bound to a proxy). See Running a session through your proxy. New profiles and sessions started without a device get a randomly chosen iPhone model from those your plan includes that are open for new profiles; you can also pick a model yourself. A session created without a profile or archetype runs as a fresh visitor on a random current iPhone; pass archetype to pin one, or profile_id to keep an identity between runs. The random device is drawn from among the devices your plan includes that are open for new profiles (see GET /v1/archetypes), weighted toward the newest iOS the way real traffic skews; it is chosen once, when the session is created, and never changes while the session runs. Sessions created without a device used to all get the same default, which made many fresh sessions from one account look alike; now each is its own visitor. The response says what you got: archetype names the device and archetype_source is random, explicit (you passed archetype) or profile (the profile’s device). Every paid plan includes every device; the free trial includes the iPhone 13 and iPhone 13 mini only. purpose defaults to production_customer.

When supplied directly, archetype must be an id returned by the current GET /v1/archetypes catalog. Any id absent from that response returns 400 ValidationFailed on the archetype field before a session is created. Fetch the catalog at runtime and pick an id your plan includes whose new_profiles is open (a closed one returns 400, code: "device_closed_for_new_profiles"), or omit archetype for a random current iPhone your plan includes; do not synthesize an id from device/version strings.

When profile_id is supplied the session uses the profile’s device by default, records {profile_id, profile_name} in the session’s metadata, and updates the profile’s last_used_at. Cross-account profile_id returns 404 (anti-enumeration — indistinguishable from a missing one). See also POST /v1/profiles/:id/launch for the one-round-trip launch helper.

Profile-backed launches inherit the device profile already stored on the profile. This is a compatibility path: an existing profile remains launchable if its pinned id is no longer offered for new direct creates. The exception cannot be requested by putting a retired id directly in a create-session body. It does not cover a device that is on hold or still in development — the device picker shows it but does not offer it, and it cannot start a session — so a profile on one is refused with 409 device-unavailable before any session exists (the body’s archetype names the device and held_reason is the reason for the hold in one plain sentence when there is one).

A profile can have only one live session at a time. If the profile_id already has a non-terminal session, the create is refused with 409 profile-in-use (the body’s active_session_id names the live session) — this prevents two sessions on the same profile from overwriting each other’s saved cookies and logins. End the named session if you created it (or wait for it to finish), then launch again. Sessions without a profile_id are never affected.

An account that holds more saved profiles than its plan keeps (after a downgrade, or a trial that keeps one) loses nothing, but only its most recently used profiles, up to the plan’s number, can launch — on the trial, only the most recently used one. Any other profile_id is refused with 429 tier-limit (code: "over_plan_limit", resource: "profile"), here and on POST /v1/profiles/:id/launch alike.

behavioral_profile selects how the session taps, scrolls and types — casual, regular, or power_user. Defaults to regular when omitted; set once for the session’s lifetime.

Returns the created session (201).

Running a session through your proxy

proxy_id is the id of one of your saved proxies (GET /v1/account/me/proxies), the same proxy_id an agent session takes, and it is checked the same way:

  • It must be a proxy saved on the account the session belongs to. A team admin starting a session for the owner (X-Driftstack-Account) may also name a proxy saved on their own account. Any other id returns 404 (resource: "proxy"), whether or not it exists.
  • An HTTP proxy cannot carry a session: 400 (proxy_scheme: "http").
  • An OpenVPN or WireGuard proxy needs a plan that includes VPN egress: otherwise 403 (feature: "vpnEgress").
  • A proxy whose saved configuration cannot be used — unreadable, or pointing at a private network address — is refused with 422 proxy-validation-failed before anything is created. Where the deployment runs the connection check before launch, a proxy that fails it is refused the same way.

Hosted Driftstack runs every session through a proxy of yours. There a create without proxy_id is refused with 422 proxy-required (code: "proxy_required") and nothing is created; a create with a profile_id whose profile is bound to a proxy uses that proxy. If the profile has no proxy any more, the create is refused with 409 (code: "proxy_detached") until you choose one. That happens when its proxy was deleted or never shared with this workspace, or when the profile was marked proxy_choice: "detached". The same rules apply to POST /v1/profiles/:id/launch, which takes proxy_id too.

A session started with a proxy_id runs through that proxy from its first request, and its egress_capabilities report what the proxy delivered. The step-by-step operations below (navigate, interact, wait, get-state, capture, extract, search, login) cannot run on such a session yet: each answers 503 driver-not-integrated without changing the session, which stays ready (see Step-by-step operations on the hosted service). Destroy it when you are done. To drive a page through your proxy today, use an agent session: POST /v1/agent-sessions/{id}/message for an AI turn, or POST /v1/agent-sessions/{id}/steps for steps you write.

Such a session runs on the same machines as agent sessions, and is tracked there as one: while it runs it is listed by GET /v1/agent-sessions as a manual session whose driftstack_session_id names this session, and it counts toward your open agent sessions as well as toward the cap above. Destroying this session ends it, and it ends the other way too: closing that agent session (DELETE /v1/agent-sessions/:id) ends this one, and so does the session stopping on its own — after 30 minutes without activity (nothing you send to the step-by-step operations counts, since they do not reach it yet), at its time limit (4 hours, or the plan’s own limit where that is shorter), or when the machine running it stops it. Whichever way it ends, this session is destroyed with it, within about two minutes at most: GET /v1/sessions/:id reads destroyed, and its slot and its profile are free again. The proxy rules are the agent session’s too: an account holding more saved proxies than its plan allows launches only through its most recently used ones (429 tier-limit, code: "over_plan_limit", resource: "proxy").

If none of the machines that run sessions can take a new one just then, the create is refused with 503 (code: "dispatch_no_live_node", retryable: true, retry_after_seconds, and a Retry-After header) and nothing is created; send it again after the Retry-After wait. POST /v1/agent-sessions answers the same way. When the machines are only being updated, the create waits up to 25 seconds for one to come back first; if none does, the 503 carries node_updating: true and a Retry-After of 60 seconds.

The create never accepts a raw proxy object. Supplying one returns 400 before a profile is looked up or a session is created; it is never silently ignored. On a deployment that cannot start a session through a saved proxy on this endpoint, a proxy_id is refused with 400 (resource: "proxy") rather than ignored, a deployment that requires a proxy refuses every create here with 422 proxy-required, and POST /v1/agent-sessions with proxy_id is the way to run a session through your proxy.

Errors:

  • 400 ValidationFailed — a directly supplied archetype is not present in the current selectable catalog.
  • 400 Bad Request — an explicit raw proxy field was rejected; the proxy_id names an HTTP proxy (proxy_scheme: "http"); or this deployment cannot take a proxy_id here (resource: "proxy").
  • 403 Forbidden — the proxy is an OpenVPN or WireGuard proxy and the plan does not include VPN egress (feature: "vpnEgress").
  • 422 proxy-required — a proxy of yours is required and none was named.
  • 422 proxy-validation-failed — the named proxy could not be used.
  • 422 validation-failed with code: "upstream_proxy_not_public" and field: "proxy.host" — the proxy’s address turned out not to be a public address when the session was started. Change the proxy and start again. The machine starting the session makes this check, so the usual outcome is a 201 followed by the session ending a moment later: it reads destroyed, and this resource carries no reason for it. The 422 is returned only when the refusal arrives before the create answers.
  • 502 driver-error — the session could not be started; nothing is running.
  • 503 feature-unavailable — no machine could take the session just then (code: "dispatch_no_live_node", with Retry-After); try again after the wait.
  • 503 maintenance — every machine that could run the session is in a booked maintenance window (code: "maintenance", retryable: true); nothing was created. until is when the window ends (ISO 8601, UTC), and retry_after_seconds and Retry-After the seconds until then; send the same request again after until. Running sessions are not affected.
  • 429 ConcurrencyLimit — concurrent-session cap hit. An open session, perhaps one an earlier run left, holds the slot: close it if you created it, and retry (see Concurrency).
  • 429 tier-limit — the free trial’s 2 hours of session time are used up (code: "trial_session_time_used"), or profile_id is outside the profiles the plan can launch (code: "over_plan_limit").
  • 404 NotFound — profile_id refers to a profile that doesn’t exist OR belongs to a different account, or proxy_id is not one of your saved proxies (resource: "proxy").
  • 409 Conflict — no proxy_id was sent and the profile’s proxy was deleted (code: "proxy_detached"). Choose a proxy.
  • 409 profile-in-use — the profile_id already has a live session (the body’s active_session_id names it). End it first if you created it.
  • 409 device-unavailable — the profile’s device is on hold or still in development, so it cannot start a session; nothing was started. Launch a profile on a device from GET /v1/archetypes instead.

List

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

Cursor-paginated, newest-first.

Get one

GET /v1/sessions/:id — fetch a single session resource. Returns the full session record in the Resource shape above (id, status, archetype, archetype_source, purpose, label, metadata, proxy_id, egress_capabilities, timestamps, etc.). Backs the SDK’s sessions.get(id).

Returns 404 if the session id is unknown or belongs to a different account (anti-enumeration — cross-account ids are indistinguishable from missing ones).

For the live page state (url, page_state, cookies, screenshot payload) rather than the resource record, use GET /v1/sessions/:id/state below.

POST /v1/sessions/:id/navigate

{
  "url": "https://example.com/page",
  "wait_until": "load"
}

wait_until: 'load' (default), 'domcontentloaded', or 'networkidle'. 'networkidle' is accepted and currently waits for the load event, the same as 'load'. If the page adds content after it loads, follow the call with a wait for that content, for example a selector condition on the element you need.

Returns { url, status, final_url, duration_ms } on success — url is the originally requested URL, final_url reflects any HTTP redirects.

502 DriverError for navigation-time failures (DNS, TLS, network). The session becomes errored and its browser is shut down; subsequent operations return 410 Gone, so create a fresh session instead of retrying this one.

Interact

POST /v1/sessions/:id/interact

The body wraps the typed action under action plus an optional top-level timeout_ms:

{
  "action": {
    "kind": "tap",
    "selector": "button#submit"
  },
  "timeout_ms": 5000
}

Supported discriminator values on action.kind:

  • tap — selector (required).
  • type — selector + text (max 10,000 chars) + optional delay_ms (requested inter-key delay, integer 0-500ms) + optional sensitive (boolean — mark card numbers / OTPs / PINs so the typing simulation makes no visible corrections; password fields get this automatically). On this route sensitive changes typing behaviour only. It is not a redaction control, and it does not need to be: this route keeps no transcript, and the session event it records carries the action kind, the sensitive flag and delay_ms — never your text or your selector. The separate agent-intent sensitive flag, which omits a marked step’s value from read surfaces, is described under agent-sessions and recipes.
  • scroll — optional selector + delta_x (default 0) + delta_y (default 0). Both integers; positive scrolls down/right.
  • press — key (1-20 chars; e.g. Enter, Tab, a).

This route acts on the page itself, never inside an embedded frame. A body that names a frame — beside action or inside it — is refused with 400, field naming where it was (frame or action.frame), and nothing is sent; it is never ignored. To tap or type inside a frame, send the step to POST /v1/agent-sessions/{id}/steps with its frame path: one level per nested frame, outermost first.

Wait

POST /v1/sessions/:id/wait

The body wraps the typed condition under condition plus an optional top-level timeout_ms (100ms – 120s):

{
  "condition": {
    "kind": "selector",
    "selector": "div.results"
  },
  "timeout_ms": 5000
}

Supported condition.kind values:

  • selector — wait for selector to appear in the DOM.
  • selector_hidden — wait for selector to disappear from the DOM (or to be display:none / visibility:hidden / detached).
  • url_matches — wait for the navigation URL to match the regex pattern (anchored at ^ is recommended).
  • time — sleep for ms milliseconds (max 60,000). The session stays busy while it sleeps, and the sleep still counts as session time.

This route waits on the page itself, never inside an embedded frame. A body that names a frame — beside condition or inside it — is refused with 400, field naming where it was (frame or condition.frame), and nothing is sent; it is never ignored. To wait for an element inside a frame, send a wait step to POST /v1/agent-sessions/{id}/steps with its frame path: one level per nested frame, outermost first.

Get state

GET /v1/sessions/:id/state — returns the session’s current url, title, cookies, and local_storage, plus a captured_at timestamp (subject to a payload-size cap). Useful for checkpoint-like reads without a full screenshot.

Despite its GET method, this call runs against the live browser (the session is busy while it captures) and returns browser secrets such as cookies. With X-Driftstack-Account, only a team admin may call it; a team member receives 403. Members may still use the sessions list and GET /v1/sessions/:id for persisted metadata. A self-account caller with read:sessions is unchanged.

Also includes page_state — the page lifecycle as the browser sees it: { state: 'loading' | 'loaded' | 'errored' }, with an error object (kind: http / tls / dns / net / timeout, plus http_status and message) when a navigation failed. null when the session hasn’t reported a lifecycle event yet (e.g. before the first navigation).

A typical page_state after a failed navigation:

{
  "url": "https://unreachable.example",
  "title": null,
  "page_state": {
    "state": "errored",
    "error": { "kind": "dns", "message": "Could not resolve host" }
  },
  "captured_at": "2026-06-11T18:00:00Z"
}

Use it to confirm a navigation succeeded before acting on the page — poll GET /v1/sessions/:id/state after navigate until page_state is loaded (proceed) or errored (branch on error.kind), rather than guessing from a screenshot:

await client.sessions.navigate(id, { url });
let state = await client.sessions.getState(id);
while (state.page_state?.state === 'loading') {
  state = await client.sessions.getState(id);
}

if (state.page_state?.state === 'errored') {
  // e.g. 'dns' → bad host, 'http' → check error.http_status, 'tls' → cert
  throw new Error(`Navigation failed: ${state.page_state.error.kind}`);
}

Capture

POST /v1/sessions/:id/capture

{
  "kind": "screenshot",
  "full_page": false
}

kind is one of: 'screenshot' (PNG, base64-encoded in response), 'dom_snapshot' (the serialised DOM as raw text), or 'pdf'. Screenshots cap at 4 MiB; PDFs at 8 MiB. The response carries encoding: 'base64' for screenshot+pdf, 'utf8' for dom_snapshot.

Reading one embedded frame

A dom_snapshot can read one embedded document (an <iframe>) instead of the page. Name the frame by its address with frame_match:

{
  "kind": "dom_snapshot",
  "frame_match": {
    "host": "pay.example.com",
    "path_prefix": "/embed/card",
    "query": { "field": "expiry" }
  }
}

When the request arrives, the session lists the page’s frames and reads the one whose current address matches every field you give:

  • host (required) — the frame’s host name, exactly: pay.example.com does not match www.pay.example.com. Letter case does not matter and the port is not compared. A host name only, with no scheme, port or path.
  • path_prefix — the start of the frame’s path, in whole segments: /embed matches /embed and /embed/card, not /embedded.
  • query — parameters the frame’s address must carry, each with exactly this value once its URL encoding is undone (%20 and + read as a space). A parameter that appears more than once matches only if every occurrence has the value. At most 10.

frame_match refuses a field it does not know rather than ignoring it, so a misspelt path_prefix cannot widen the match.

Exactly one frame must match. When it does not, nothing is read, the session stays ready, and the answer is a 409 whose code says why:

code Why
frame_not_found No frame matches.
frame_match_ambiguous More than one frame matches; matched_frames says how many. Add path_prefix or query until only one does.
frame_match_unconfirmed The match could not be shown to be the only one (the session could list only part of the page’s frames, the page’s own address could not be read, or the matching frame reports the page’s own address), or the session did not confirm it read that frame.

No refusal names a frame or its address.

The session checks a read by listing the frames again once it is done. If the page added or removed a frame meanwhile, so that the frame it read is no longer the one match, nothing is returned and the answer is frame_match_unconfirmed. A page that swaps the frame for another and back again while it is being read is not detected.

Some query parameters cannot be matched on, because the browser removes their values from a frame’s address before Driftstack sees it: they carry credentials. Naming one in query, in any letter case, is refused with 400 and parameter set to the name you sent. They are password, passwd, pwd, pass, secret, token, access_token, api_key, apikey, auth, otp, id_token, refresh_token, code_verifier, client_secret, assertion, jwt, bearer, sig, signature, session_token, auth_token, passphrase, passcode, totp, mfa_code, private_key, privatekey, credential, credentials and ds_token. Names are compared whole: code_verifier is refused, while code, user, username and email are kept and can be matched.

frame_match works only with kind: "dom_snapshot"; with a screenshot or a PDF it is refused with 400. Like the other step-by-step operations, it cannot run on a session started with a proxy_id yet.

Extract

POST /v1/sessions/:id/extract

{
  "extractions": [
    { "name": "title", "selector": "h1", "type": "text" },
    { "name": "price", "selector": ".price", "type": "text", "transform": "number" },
    {
      "name": "products",
      "selector": "li.product",
      "type": "list",
      "extract": {
        "name": { "type": "text", "selector": ".name" },
        "href": { "type": "attribute", "attribute": "href", "selector": "a" }
      }
    }
  ]
}

Reads structured data from the page in one round trip: a batch of up to 100 named extractions, each a selector plus how to read it. type is 'text' (trimmed text content; add "transform": "number" to parse the numeric part), 'attribute' (reads the named attribute), or 'list' (every matched element — with an optional per-field extract map to pull sub-fields from each one). Selectors are passed safely (never interpolated into a script), so they can’t inject. The response is { "value": { … } } keyed by each extraction’s name:

{ "value": { "title": "Example", "price": 19.99, "products": [{ "name": "A", "href": "/a" }] } }

This route reads the page itself, never inside an embedded frame. A body that names a frame — beside extractions or in one of them — is refused with 400, field naming where it was (frame, or extractions[N].frame for the first extraction that has one), and nothing is read; it is never ignored. To read inside a frame, send an extract step to POST /v1/agent-sessions/{id}/steps with its frame path: one level per nested frame, outermost first.

POST /v1/sessions/:id/search

Not offered on any deployment today. Every deployment answers 503 driver-not-integrated and searches nothing (a malformed body gets 400 first). The OpenAPI document lists it as searchSession, marked not available yet. The request and response shapes below describe the endpoint as designed.

{
  "query": "wireless headphones",
  "wait_for_results_selector": ".results"
}

Finds the search field, types query with realistic timing, and submits. search_selector is optional — omit it and the field is detected heuristically. submit defaults to true (set it false to type without submitting). When wait_for_results_selector is given, the call waits for that selector after submit and reports whether it appeared (a timeout is results_visible: false, not an error). timeout_seconds (1–120, default 10) caps that wait. query is required and capped at 10,000 characters before any session operation is claimed.

The response is a strict two-branch result. A complete query preserves the caller-requested submit behavior and may include the results assessment:

{
  "submitted": true,
  "query_truncated": false,
  "results_visible": true,
  "duration_ms": 8420
}

If typing the query hits the time cap, search stops safely before pressing Return or waiting for results. The refusal cannot carry results_visible:

{
  "submitted": false,
  "query_truncated": true,
  "duration_ms": 600000
}

duration_ms is capped at 600,000ms of browser work. Allow up to 15 seconds more than that for the response to arrive; this extra time is not available for search work itself. This endpoint is not available on any deployment today and returns 503 driver-not-integrated without looking up the session or searching anything (a malformed body gets 400 first). There is no public fill_form session route or SDK method.

Login

POST /v1/sessions/:id/login

Not offered on any deployment today. Every deployment answers 503 driver-not-integrated and types nothing (a malformed body gets 400 first). The OpenAPI document lists it as loginSession, marked not available yet. The request and response shapes below describe the endpoint as designed.

{
  "username": "[email protected]",
  "password": "••••••••",
  "success_selector": ".dashboard"
}

Heuristic credential login: types username then password realistically and submits. The password is never logged. username_selector / password_selector / submit_selector are optional — omit them and the fields are detected heuristically (submit falls back to Return on the password field). Give success_selector for a robust signal on known or multi-step logins (its post-submit presence means success); omit it and the password-field-gone heuristic is used. Without an explicit success selector, a challenge page that removes the password field can be assessed as logged in, so treat logged_in as an assessment rather than an authentication proof. timeout_seconds (1–120, default 10) caps the post-submit success wait. post_login_url lets you drive a challenge/pause flow when login didn’t complete. Recipes are capture-only today — /v1/recipes supports create, list, read and delete, and the API exposes no recipe-execution surface — and this endpoint is not offered either, so the direct-session API has no way to drive a login today.

The response is a strict two-branch result. Complete credentials are submitted and assessed only in the submitted: true branch:

{
  "submitted": true,
  "credentials_truncated": false,
  "logged_in": true,
  "post_login_url": "https://example.com/account",
  "duration_ms": 12450
}

If typing hits the time cap, login stops safely before submission. It does not type the password after a truncated username and does not expose a URL on this branch:

{
  "submitted": false,
  "credentials_truncated": true,
  "logged_in": false,
  "duration_ms": 600000
}

duration_ms is capped at 600,000ms across both fields, submission, and assessment. Allow up to 15 seconds more than that for the response to arrive; this extra time is not available for login work itself. This endpoint is not available on any deployment today and returns 503 without looking up the session or typing any credential (a malformed body gets 400 first). Do not send credentials until it is.

Destroy

DELETE /v1/sessions/:id

Cleanly tears down the session. Returns 204 No Content. Idempotent on already-destroyed sessions. Frees the concurrent slot. Any session.completed webhook subscriptions fire after the session’s status becomes destroyed.

Auth + scoping

GET endpoints require a bearer that satisfies the read scope; the team-role restrictions below also apply. Write endpoints (POST navigate / interact / wait / capture / extract / search / login; DELETE) require the write:sessions scope (a broad write key also satisfies it). Team RBAC: X-Driftstack-Account is honored — a member can list and read the owner’s persisted session metadata, while live state and writes require the admin role. A member’s state or write request returns 403.

Errors common to every endpoint

Status Type When
401 unauthorized Missing / invalid bearer
403 forbidden Scope missing or team role is insufficient
404 not-found Session not found / not owned
409 conflict Session is creating or another operation owns busy
410 session-destroyed Session is destroyed/errored, or a destroy interrupted it; recreate
504 session-timeout An operation exceeded its time budget mid-call
502 driver-error The browser failed (network error, crash)
503 driver-not-integrated The operation is unavailable on this deployment: on the hosted service, every step-by-step operation (why)