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 returns404(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-failedbefore 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 suppliedarchetypeis not present in the current selectable catalog.400 Bad Request— an explicit rawproxyfield was rejected; theproxy_idnames an HTTP proxy (proxy_scheme: "http"); or this deployment cannot take aproxy_idhere (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-failedwithcode: "upstream_proxy_not_public"andfield: "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 a201followed by the session ending a moment later: it readsdestroyed, and this resource carries no reason for it. The422is 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", withRetry-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.untilis when the window ends (ISO 8601, UTC), andretry_after_secondsandRetry-Afterthe seconds until then; send the same request again afteruntil. 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"), orprofile_idis outside the profiles the plan can launch (code: "over_plan_limit").404 NotFound—profile_idrefers to a profile that doesn’t exist OR belongs to a different account, orproxy_idis not one of your saved proxies (resource: "proxy").409 Conflict— noproxy_idwas sent and the profile’s proxy was deleted (code: "proxy_detached"). Choose a proxy.409 profile-in-use— theprofile_idalready has a live session (the body’sactive_session_idnames 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 fromGET /v1/archetypesinstead.
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.
Navigate
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) + optionaldelay_ms(requested inter-key delay, integer 0-500ms) + optionalsensitive(boolean — mark card numbers / OTPs / PINs so the typing simulation makes no visible corrections; password fields get this automatically). On this routesensitivechanges 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, thesensitiveflag anddelay_ms— never yourtextor yourselector. The separate agent-intentsensitiveflag, which omits a marked step’svaluefrom read surfaces, is described under agent-sessions and recipes.scroll— optionalselector+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 forselectorto appear in the DOM.selector_hidden— wait forselectorto disappear from the DOM (or to bedisplay:none/visibility:hidden/ detached).url_matches— wait for the navigation URL to match the regexpattern(anchored at^is recommended).time— sleep formsmilliseconds (max 60,000). The session staysbusywhile 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.comdoes not matchwww.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:/embedmatches/embedand/embed/card, not/embedded.query— parameters the frame’s address must carry, each with exactly this value once its URL encoding is undone (%20and+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.
Search
POST /v1/sessions/:id/search
Not offered on any deployment today. Every deployment answers
503driver-not-integratedand searches nothing (a malformed body gets400first). The OpenAPI document lists it assearchSession, 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
503driver-not-integratedand types nothing (a malformed body gets400first). The OpenAPI document lists it asloginSession, 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) |