Browser API
HTTP reference for the browser gateway. Base URL https://api.getsolari.com. Every route except GET /health takes Authorization: Bearer slr_live_…. For the TypeScript client over these routes, see the browser SDK reference.
REST creates and releases sessions. POST /sessions returns a wsEndpoint (Playwright wire protocol) and a cdpEndpoint (raw CDP); driving the browser happens over those WebSockets, not over REST.
Contents
Sessions
POST/sessions
Creates a session and returns the endpoints to connect to. Counts against your concurrency limit until released.
Request body. Optional. A missing or non-JSON body is treated as all-defaults: a fast, unproxied, unrecorded session. Only a literal true opts a toggle in.
| Field | Type | Required | Description |
|---|---|---|---|
profileId | string | No | Attach a stored profile. Must be a non-empty string; anything else is ignored. |
recording | boolean | No | Default false. Record an rrweb replay, retrievable via GET /sessions/:id/replay-url. |
stealth | boolean | No | Default false. Routes to the stealth pool (full Chromium under Xvfb) instead of the fast pool (chromium-headless-shell), and injects the runtime stealth shim. Required for proxy and captcha. |
captcha | boolean | No | Default false. Managed captcha solving. Requires stealth: true and the plan's captcha feature. |
webBotAuth | boolean | No | Default false. Sign outbound requests with an Ed25519 key registered to Cloudflare's verified-bot directory. Independent of stealth. Silently inert unless Web Bot Auth is enabled for your account: no error, no signal. |
proxy | string | object | No | Managed proxy egress. "off" (same as omitting), "smart" (the escalation ladder: direct → mobile → residential, swapped mid-session on block detection), a lowercase country code such as "us", or an object (see below). Any value but "off" requires stealth: true. |
proxy object fields. All optional; unknown fields are ignored. The gateway coerces rather than rejects several of these: a non-string session, state, city or asn is silently dropped.
| Field | Type | Required | Description |
|---|---|---|---|
country | string | No | One of au br ca de es fr gb in it jp kr mx nl sg us. Anything else is a 400. |
tier | string | No | residential, static, mobile, or isp, a deprecated alias for static, normalised downstream and reachable only over raw HTTP. |
static | boolean | No | Deprecated; use tier: "static". Must be literally true; when both are present, tier wins. |
session | string | No | Sticky-session ID (alnum + dash, ≤32 chars). Pins the egress IP for sessionDuration minutes. |
sessionDuration | number | No | Sticky lifetime in minutes, 1 to 30, default 10. Only meaningful with session. Out-of-range values are rejected with a 400, not clamped. |
state | string | No | US-only geo narrowing, e.g. california. |
city | string | No | US-only city pin, e.g. los_angeles. |
asn | string | No | Pin egress to a specific ASN. |
Cross-field rules, enforced in this order: proxy (anything but "off") requires stealth: true, else 400; stealth/proxy/captcha each require the plan's matching feature flag, else 402; captcha requires stealth: true, else 400.
Responses
| Status | Meaning |
|---|---|
201 | Session created. Body carries sessionId, wsEndpoint, cdpEndpoint, expiresAt, and optionally storageStateUrl and proxy. |
400 | Validation or cross-field failure: unsupported proxy country; proxy.sessionDuration outside 1 to 30; invalid proxy.tier; proxy without stealth: true; captcha without stealth: true; malformed Content-Length. |
401 | Missing, non-Bearer, or unverifiable key. |
402 | FeatureRequiresPlan. The plan does not include a requested feature. Body carries feature and plan. |
404 | The requested profileId does not exist for this org. |
413 | Content-Length exceeded the 16 KB cap. limit echoes the cap in bytes. |
429 | ConcurrencyLimitExceeded. The org is at its plan's concurrent-session cap. Not retryable. |
502 | Profile lookup failed: control plane unreachable or 5xx. |
503 | No pool of the requested kind became available within the acquire timeout, the concurrency store is wedged (ConcurrencyCheckUnavailable), or the control plane is not configured. Retryable. |
| other | A pool rejection forwarded verbatim, body and all, relabelled application/json even when it is plain text. The known real case is 428 on an SDK/pool wire-version mismatch. The status set is not closed. |
Proxy resolution never errors. If the requested tier is unavailable, the session is created unproxied and the response omits proxy. If you require proxy egress, assert on the presence of the proxy field, not on the 201.
This endpoint never returns an inline storageState. It returns storageStateUrl: {url, expiresInSeconds}, a short-lived presigned S3 GET, because live cookies never traverse the gateway. The SDK's Session.storageState is client-side synthesis: the SDK fetches that URL itself and inlines the JSON. A curl user who wants the cookies must follow storageStateUrl.url in a second request. A null url means the profile exists but was never saved: no seed.
Example request
# Fast session: no body needed.
curl -sS -X POST https://api.getsolari.com/sessions \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
# Stealth + sticky mobile proxy + recording.
curl -sS -X POST https://api.getsolari.com/sessions \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"stealth": true,
"recording": true,
"proxy": {
"country": "us",
"tier": "mobile",
"session": "warm-1",
"sessionDuration": 10
}
}'Example response
{
"sessionId": "pool-7f3a:9d1c4e2a-55b1-4a7e-9a3f-2c8d1e6b0a44:org_4b91ac2f:1752652800000.Zm9vYmFyYmF6cXV4MDEyMw",
"wsEndpoint": "wss://api.getsolari.com/ws/pool-7f3a:9d1c4e2a-…:org_4b91ac2f:1752652800000.Zm9vYmFyYmF6cXV4MDEyMw",
"cdpEndpoint": "wss://api.getsolari.com/cdp/pool-7f3a:9d1c4e2a-…:org_4b91ac2f:1752652800000.Zm9vYmFyYmF6cXV4MDEyMw",
"expiresAt": "2026-07-17T09:00:00.000Z",
"storageStateUrl": {
"url": "https://storage.solaribrowser.com/org_4b91ac2f/prof_01HZY3/v7.json?X-Amz-Signature=…",
"expiresInSeconds": 60
},
"proxy": {
"timezoneId": "America/Los_Angeles",
"country": "us",
"tier": "mobile"
}
}The proxy is already applied to the browser you connect to, so there is nothing to wire up. Pass timezoneId to newContext({ timezoneId }) to match the browser clock to the egress IP. tier echoes which tier actually served the request, so you can confirm a mobile ask did not quietly degrade to residential.
Following the presigned URL for the cookies the SDK would have inlined for you:
SESSION=$(curl -sS -X POST https://api.getsolari.com/sessions \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"stealth":true,"profileId":"prof_01HZY3"}')
echo "$SESSION" | jq -r '.wsEndpoint'
# A null url means "profile exists but was never saved": no seed.
URL=$(echo "$SESSION" | jq -r '.storageStateUrl.url // empty')
[ -n "$URL" ] && curl -sS "$URL" | jq .expiresAt is stamped at now + plan.maxSessionMinutes; the session auto-releases then. Pool kind is decided solely by stealth and is never substituted. If no pool of the requested kind has idle capacity, the request blocks for up to the acquire timeout before returning 503, rather than failing fast.
GET/sessions/:id
The gateway validates the composite ID, resolves the owning pool, and proxies GET {poolUrl}/sessions/{realSessionId}, but the pool registers no such route. A well-formed, correctly-authorized request for a live session reaches the pool and comes back as its default 404, which the gateway forwards verbatim: the plain-text string 404 Not Found served under a JSON content type. It will not JSON.parse().
There is no working replacement. Track the session yourself from the POST /sessions response. It is documented here only because the route really is registered in the gateway, and a reader diffing this reference against the code would otherwise think it was missed.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The signed composite session ID. See Session ID format. |
Responses
| Status | Meaning |
|---|---|
200 | Never returned in practice. No schema is asserted: the gateway pipes the pool's bytes through without inspection, so the shape would be owned by a route the pool does not implement. |
401 | Missing or unverifiable key. |
404 | The universal outcome, with two distinct bodies. Either the gateway's own JSON {"error":"Not Found","code":"InvalidSessionId"} (bad signature, wrong org, or unknown pool; deliberately opaque, so existence cannot be probed), or, for a genuinely valid live session, the pool's plain-text 404 Not Found mislabelled as JSON. |
502 | The pool was resolved but unreachable within 5s. |
Example request
# Expect: HTTP 404 with the literal body "404 Not Found" under a JSON
# content-type. There is no working replacement.
curl -sS -i -X GET \
"https://api.getsolari.com/sessions/$SESSION_ID" \
-H "Authorization: Bearer $SOLARI_API_KEY"Example response
HTTP/1.1 404 Not Found
Content-Type: application/json
404 Not FoundDELETE/sessions/:id
Releases the session and its concurrency slot. Idempotent: a second DELETE of the same ID also returns 204.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The signed composite session ID. This route checks only the signature, not the ID's age, so a session stays releasable for its whole expiresAt lifetime. |
Responses
| Status | Meaning |
|---|---|
204 | Accepted. Returned whether or not the pool was reachable, and also for an already-released session. The handler never consults the pool before acking. |
401 | Missing or unverifiable key. |
404 | Malformed, forged, or another org's session ID, none distinguishable from the others by design. Always carries code: InvalidSessionId, and nothing was released. Do not treat it as success. |
Release is fire-and-forget end to end: the gateway acks 204 immediately and forwards the DELETE to the pool in the background (30s budget, no retry). Downstream failures never surface to you, and no endpoint confirms release. A lost DELETE is a slower release, never a leaked slot; the pool's orphan-grace cleaner (~3.5 min) reaps whatever the background call missed.
Example request
curl -sS -X DELETE \
"https://api.getsolari.com/sessions/$SESSION_ID" \
-H "Authorization: Bearer $SOLARI_API_KEY"
# 204 No Content on success, including for an already-released session.
# A 404 with code: InvalidSessionId means the ID was refused and NOTHING
# was released; do not treat it as success.Example response
HTTP/1.1 204 No ContentGET/sessions/:id/replay-url
Returns a short-lived presigned S3 URL for the session's rrweb replay (<sessionId>.ndjson.gz). Only sessions created with recording: true produce one. Fetch the returned url directly, with no auth header, and decompress per contentEncoding.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | The composite session ID. This route does not HMAC-validate it. It is forwarded to the control plane with your authenticated org, which enforces tenant isolation. It is therefore not subject to the 90-minute ID expiry, so replays of long-lived sessions stay fetchable. |
Responses
| Status | Meaning |
|---|---|
200 | Presigned URL minted. Body carries url, expiresInSeconds, and contentEncoding (defaults to gzip). |
401 | Missing or unverifiable key. |
404 | No replay for this session: recording was off, the session belongs to another org, or the finalize webhook has not landed yet. Expected, not an error to alarm on. |
502 | Control plane unreachable or returned a 5xx. |
503 | The gateway has no control plane configured. |
The recording-finalized webhook lands asynchronously after release, so a 404 for a second or two is normal. Poll with backoff. The URL is typically available 1 to 3s after the session is released.
Example request
# 404 is expected for a second or two after release; poll.
for i in 1 2 3 4 5; do
RESP=$(curl -sS -w '\n%{http_code}' \
"https://api.getsolari.com/sessions/$SESSION_ID/replay-url" \
-H "Authorization: Bearer $SOLARI_API_KEY")
CODE=$(echo "$RESP" | tail -n1)
BODY=$(echo "$RESP" | sed '$d')
[ "$CODE" = "200" ] && break
sleep 1
done
# The presigned URL needs no auth header.
echo "$BODY" | jq -r .url | xargs curl -sS -o replay.ndjson.gz
gunzip -c replay.ndjson.gz | head -n 3Example response
{
"url": "https://storage.solaribrowser.com/org_4b91ac2f/9d1c4e2a.ndjson.gz?X-Amz-Signature=…",
"expiresInSeconds": 900,
"contentEncoding": "gzip"
}Profiles
Persistent cookie/localStorage profiles. The org is taken from your API key, so cross-tenant reads are impossible.
GET/profiles
Lists every profile owned by the caller's org.
Responses
| Status | Meaning |
|---|---|
200 | A JSON array of profiles, forwarded verbatim from the control plane. |
401 | Missing or unverifiable key. |
502 | Control plane unreachable, 5xx, or returned a non-array body. |
503 | The gateway has no control plane configured. |
The gateway types profile rows as opaque and forwards the control plane's array verbatim, deliberately, so the platform can evolve the schema without a gateway redeploy. id and name are the only fields this contract can promise; others may be present. Do not treat the absence of a field as a contract.
Example request
curl -sS https://api.getsolari.com/profiles \
-H "Authorization: Bearer $SOLARI_API_KEY" | jq .Example response
[
{ "id": "prof_01HZY3", "name": "linkedin-login" },
{ "id": "prof_01J0AB", "name": "shopify-admin" }
]POST/profiles
Creates a new, empty profile. The returned id is immediately usable as POST /sessions { "profileId": … }: a fresh profile has no saved storage state, so the session's storageStateUrl.url comes back null and the browser starts from a clean context. Populate it by running the editor from the dashboard, or by driving a session and then calling POST /profiles/:id/save.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Trimmed before use. Must be a non-blank string. |
Responses
| Status | Meaning |
|---|---|
201 | Profile created. Body forwarded verbatim from the control plane. |
400 | Body was not JSON, name was missing or blank, or the platform rejected it (e.g. name already exists). |
401 | Missing or unverifiable key. |
403 | PlanLimitExceeded. The org is at its plan's profile cap. This is the only route in the gateway that can emit this code, and it is generated by the control plane and forwarded verbatim so clients can branch on code rather than pattern-match prose. |
413 | Content-Length exceeded the 16 KB cap. |
502 | Control plane unreachable or 5xx. |
503 | The gateway has no control plane configured. |
Example request
curl -sS -X POST https://api.getsolari.com/profiles \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"linkedin-login"}' | jq .Example response
{ "id": "prof_01HZY3", "name": "linkedin-login" }DELETE/profiles/:id
Deletes the profile row. Orphaned S3 objects (storage-state versions, legacy tarballs) are reaped by a separate lifecycle job, not inline.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile ID, as returned by POST /profiles. |
Responses
| Status | Meaning |
|---|---|
204 | Deleted. |
401 | Missing or unverifiable key. |
404 | No such profile in this org. The TypeScript SDK swallows this as success; over raw HTTP you see the real 404. |
409 | The dashboard profile editor is open on this profile, so it is locked. |
502 | Control plane unreachable or 5xx. |
503 | The gateway has no control plane configured. |
Example request
curl -sS -X DELETE \
"https://api.getsolari.com/profiles/prof_01HZY3" \
-H "Authorization: Bearer $SOLARI_API_KEY"
# 204 on success. 409 means the dashboard editor is open on this profile.Example response
HTTP/1.1 204 No ContentPOST/profiles/:id/save
Persists a Playwright-shaped storageState (cookies + localStorage origins) to the profile, bumping its version. This is a pure control-plane operation on profile metadata. It touches no pool and no session, so save-without-a-session is a valid flow, e.g. migrating profiles out of a local Playwright harness into the platform.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Profile ID. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
storageState | object | Yes | Playwright-shaped storage state: { cookies: [...], origins: [...] }. The same shape you get by fetching a session's storageStateUrl. |
Responses
| Status | Meaning |
|---|---|
200 | Saved; version bumped. Body carries profileId, version, storageStateS3Key and sizeBytes. (The TypeScript SDK returns only the latter two; over HTTP you get the full object.) |
400 | Body was not JSON, or storageState was missing or not an object. |
401 | Missing or unverifiable key. |
404 | No such profile in this org. |
409 | Overloaded: either the editor is open, or an optimistic-concurrency version conflict. The status does not distinguish them; only the prose detail differs. |
413 | storageState exceeded this route's 1 MB cap. 16 KB applies everywhere else; this one is sized for cookie-heavy sites. limit echoes the cap in bytes. |
502 | Control plane unreachable or 5xx. |
503 | The gateway has no control plane configured. |
Example request
# Playwright writes this file via context.storageState({ path: … }).
jq '{storageState: .}' storageState.json | curl -sS -X POST \
"https://api.getsolari.com/profiles/prof_01HZY3/save" \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- | jq .Example response
{
"profileId": "prof_01HZY3",
"version": 7,
"storageStateS3Key": "org_4b91ac2f/prof_01HZY3/v7.json",
"sizeBytes": 4211
}Proxy
GET/proxy/countries
Returns the egress countries available to you, and whether managed proxy is available at all. Intended for region pickers and “should I even send proxy:?” guards. No SDK method exposes this route; it is a curl-only surface.
Responses
| Status | Meaning |
|---|---|
200 | Body carries enabled and a sorted lowercase countries array (ISO-3166-1 alpha-2). Requesting any country outside that list from POST /sessions is a 400. |
401 | Missing or unverifiable key. |
enabled reports whether managed proxy is available at all. It does not report availability per tier, so enabled: true is not a guarantee that tier: "mobile" will resolve. A session requesting a proxy still returns 201 even when it resolves to no proxy, so assert on the proxy field in the response.
Example request
curl -sS https://api.getsolari.com/proxy/countries \
-H "Authorization: Bearer $SOLARI_API_KEY" | jq .Example response
{
"enabled": true,
"countries": ["au","br","ca","de","es","fr","gb","in","it","jp","kr","mx","nl","sg","us"]
}System
GET/health
Liveness and capacity. Unauthenticated: the auth middleware short-circuits before the bearer check, making this the only customer-reachable route with no auth.
Responses
| Status | Meaning |
|---|---|
200 | Always returned while the process is alive, even with zero registered pools and zero idle capacity. Body carries ok, idle, busy, recycling, pools, saturated, and per-kind fast / stealth breakdowns. |
ok is liveness, not readiness. Capacity numbers are per-replica, aggregated from one gateway process's in-memory pool registry. Behind a load balancer, consecutive calls hit different replicas and legitimately disagree. And saturated is derived as exactly idle === 0 across all kinds, so it can read false while the pool kind you actually need has zero idle. Check fast.idle / stealth.idle individually if you care which.
Example request
curl -sS https://api.getsolari.com/health | jq .Example response
{
"ok": true,
"idle": 42,
"busy": 7,
"recycling": 1,
"pools": 4,
"saturated": false,
"fast": { "idle": 30, "busy": 4, "recycling": 0, "pools": 2 },
"stealth": { "idle": 12, "busy": 3, "recycling": 1, "pools": 2 }
}WebSocket upgrades
These are served by a raw upgrade hook that pipes TCP, not by the HTTP router. They are listed with a method and path because that is how they are addressed, but the responses below are handshake outcomes written directly to the socket, not JSON bodies.
WebSocket handshakes cannot reliably carry custom headers, so the HMAC-signed composite ID in the path is the capability: anyone holding the URL can drive the browser. No Authorization header is required or checked on /ws/ and /cdp/; the gateway strips any you send and substitutes an internal tenant bearer before forwarding. Treat these URLs as secrets. They expire 90 minutes after minting.
WS/ws/:sessionId
Playwright wire-protocol upgrade, proxied to the owning pool's /ws/. This is the wsEndpoint returned by POST /sessions; connect with chromium.connect(wsEndpoint).
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | The signed composite ID. Subject to the 90-minute expiry: an older URL fails the upgrade with 401 even if the session is alive. |
Responses
| Status | Meaning |
|---|---|
101 | Switching Protocols. The socket is now a transparent pipe to the pool. |
401 | The composite ID failed signature or age validation. Plain-text socket write, not JSON. |
404 | Malformed path, or the owning pool did not appear in this replica's registry within 15s. Plain text, not JSON. |
502 | The pool was resolved but the upstream upgrade failed. |
Example request
# curl cannot speak the Playwright wire protocol; this only proves the
# handshake succeeds. Real use: chromium.connect(wsEndpoint).
curl -sS -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
"https://api.getsolari.com/ws/$SESSION_ID"Example response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradeWS/cdp/:sessionId
Raw CDP upgrade, proxied to the owning pool's /cdp/. This is the cdpEndpoint returned by POST /sessions; connect with chromium.connectOverCDP(cdpEndpoint), Puppeteer, or any raw CDP client. Identical auth and semantics to /ws/:sessionId.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
sessionId | string | Yes | The signed composite ID. Same 90-minute expiry. |
Responses
| Status | Meaning |
|---|---|
101 | Switching Protocols. |
401 | The composite ID failed signature or age validation. |
404 | Malformed path, the pool did not appear within 15s, or the slot has no resolvable CDP target. |
502 | The pool was resolved but the upstream upgrade failed. |
cdpEndpoint is always emitted on create, but the pool-side CDP proxy 404s if the slot's /json/version lookup failed. Separately: the pool humanizes mouse input by patching Playwright's Mouse inside the slot, which only fires on the /ws/ path. Raw-CDP clients connecting here bypass that humanization unless the gateway-side CDP input humanizer is enabled. Behavioral parity between the two paths is not guaranteed.
Example request
# Expect: HTTP/1.1 101 Switching Protocols.
# Real use: chromium.connectOverCDP(cdpEndpoint), or Puppeteer.
curl -sS -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
"https://api.getsolari.com/cdp/$SESSION_ID"Example response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradeWS/ws/observe/:sessionId
Read-only observer stream for a live session, used by the console's live view. The frame schema is owned by the observer implementation and the pool; it is not asserted by this contract.
Path and query parameters
| Name | In | Required | Description |
|---|---|---|---|
sessionId | path | Yes | The signed composite ID. |
token | query | No | Short-lived JWT, used by the console instead of a bearer header. Supply this or an Authorization: Bearer header. |
Unlike /ws/ and /cdp/, the signed URL alone is not sufficient here: the route additionally authenticates via a bearer header or a ?token= JWT, and the resolved org must match the org embedded in the composite ID. That is what prevents cross-tenant observation.
Responses
| Status | Meaning |
|---|---|
101 | Switching Protocols. Observer frames follow. |
401 | No or invalid bearer/token, or the resolved org does not own this session. |
404 | Malformed path, or the owning pool is unknown to this replica. |
Example request
curl -sS -i -N \
-H "Authorization: Bearer $SOLARI_API_KEY" \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(head -c16 /dev/urandom | base64)" \
"https://api.getsolari.com/ws/observe/$SESSION_ID"Example response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: UpgradeSession ID format
The ID returned by POST /sessions is a signed composite, not an opaque handle:
<poolId>:<realSessionId>:<orgId>:<iatMs>.<sigBase64Url>
pool-7f3a:9d1c4e2a-55b1-4a7e-9a3f-2c8d1e6b0a44:org_4b91ac2f:1752652800000.Zm9vYmFyYmF6cXV4MDEyMwpoolId routes across replicas, orgId lets REST calls authorize without a lookup table, iatMs bounds URL lifetime, and a 16-byte HMAC lets WebSocket upgrades authenticate the URL itself. The signature is verified on every route; the age is not.
| Route group | Age checked? | Why |
|---|---|---|
/ws/:id, /cdp/:id, /ws/observe/:id | Yes, 90 minutes | The ID is the sole credential, so a leaked URL is a real compromise. Past 90 minutes these return 401 even if the session is alive. |
DELETE /sessions/:id, GET /sessions/:id | No | These require a valid API key for the same org and cross-check the signed orgId, so an authentic-but-old ID grants a caller nothing they could not already do with their own key. The ID stays usable for the session's whole expiresAt lifetime; age-capping it silently broke release on long-lived sessions. |
