Solari

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.

Create a session, then connect to it

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.

FieldTypeRequiredDescription
profileIdstringNoAttach a stored profile. Must be a non-empty string; anything else is ignored.
recordingbooleanNoDefault false. Record an rrweb replay, retrievable via GET /sessions/:id/replay-url.
stealthbooleanNoDefault 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.
captchabooleanNoDefault false. Managed captcha solving. Requires stealth: true and the plan's captcha feature.
proxystring | objectNoManaged 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.

FieldTypeRequiredDescription
countrystringNoOne of au br ca de es fr gb in it jp kr mx nl sg us. Anything else is a 400.
tierstringNoresidential, static, mobile, or isp, a deprecated alias for static, normalised downstream and reachable only over raw HTTP.
staticbooleanNoDeprecated; use tier: "static". Must be literally true; when both are present, tier wins.
sessionstringNoSticky-session ID (alnum + dash, ≤32 chars). Pins the egress IP for sessionDuration minutes.
sessionDurationnumberNoSticky lifetime in minutes, 1 to 30, default 10. Only meaningful with session. Out-of-range values are rejected with a 400, not clamped.
statestringNoUS-only geo narrowing, e.g. california.
citystringNoUS-only city pin, e.g. los_angeles.
asnstringNoPin 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

StatusMeaning
201Session created. Body carries sessionId, wsEndpoint, cdpEndpoint, expiresAt, and optionally storageStateUrl and proxy.
400Validation 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.
401Missing, non-Bearer, or unverifiable key.
402FeatureRequiresPlan. The plan does not include a requested feature. Body carries feature and plan.
404The requested profileId does not exist for this org.
413Content-Length exceeded the 16 KB cap. limit echoes the cap in bytes.
429ConcurrencyLimitExceeded. The org is at its plan's concurrent-session cap. Not retryable.
502Profile lookup failed: control plane unreachable or 5xx.
503No 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.
otherA 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 degradation is silent

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.

storageState does not exist on the wire

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.getsolari.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, pool kind, and patient queueing

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

Returns the gateway's view of a session: identity, lifecycle status, deadlines, and, while still usable, the connect endpoints. The gateway answers from its own state (the signed id, the owning pool's registration, and the session's concurrency reservation); it does not ask the browser. A well-formed id from your own org always gets a 200.

status is one of:

StatusMeaning
activeDeadline in the future, reservation live, owning pool registered. wsEndpoint and cdpEndpoint are present while they would still be accepted.
releasedEnded by DELETE /sessions/:id or server-side (client disconnected, orphan grace elapsed). Terminal; no endpoints. A GET right after a DELETE returns this immediately, so polling until released is the supported way to confirm a release.
expiredPast createdAt plus the plan's maxSessionMinutes; the pool auto-releases at that deadline.
unknownDeadline in the future but the owning pool is not registered on the replica that answered (scale-in or drain), so liveness cannot be confirmed. Not a failure; retry or treat as best-effort.
A brand-new session reads active for ~10s even if unbound

The concurrency reservation is bound to the session a few milliseconds after the 201. To avoid reporting a not-yet-bound session as released, the gateway treats an unbound reservation younger than about ten seconds as active, unless the replica answering has itself processed the session's DELETE.

Path parameters

NameTypeRequiredDescription
idstringYesThe signed composite session ID. See Session ID format.

Responses

StatusMeaning
200{ id, status, kind, org, createdAt, expiresAt, wsEndpoint?, cdpEndpoint? }. kind is fast or stealth, or null when the pool is unknown to this replica. createdAt and expiresAt are ISO timestamps (null only for legacy ids). The endpoints are present only while active and still within their capability window.
401Missing or unverifiable key.
404{"error":"Not Found","code":"InvalidSessionId"} only for a malformed or forged id, or one belonging to another org; deliberately opaque, so existence cannot be probed. A released or expired session of your own is a 200 with the matching status, never a 404.

Example request

curl -sS -X GET \
  "https://api.getsolari.com/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $SOLARI_API_KEY"

Example response

{
  "id": "pool-fast-1:11111111-2222-3333-4444-555555555555:org_9f2a.YWJjZGVmZ2hpamts",
  "status": "active",
  "kind": "fast",
  "org": "org_9f2a",
  "createdAt": "2026-09-01T12:00:00.000Z",
  "expiresAt": "2026-09-01T13:00:00.000Z",
  "wsEndpoint": "wss://api.getsolari.com/ws/pool-fast-1%3A1111…",
  "cdpEndpoint": "wss://api.getsolari.com/cdp/pool-fast-1%3A1111…"
}

Confirming a release:

curl -sS -X DELETE "https://api.getsolari.com/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $SOLARI_API_KEY"   # 204, forwarded async

curl -sS "https://api.getsolari.com/sessions/$SESSION_ID" \
  -H "Authorization: Bearer $SOLARI_API_KEY" | jq -r .status   # "released"

DELETE/sessions/:id

Releases the session and its concurrency slot. Idempotent: a second DELETE of the same ID also returns 204.

Path parameters

NameTypeRequiredDescription
idstringYesThe 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

StatusMeaning
204Accepted. Returned whether or not the pool was reachable, and also for an already-released session. The handler never consults the pool before acking.
401Missing or unverifiable key.
404Malformed, 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.
204 means accepted, not released

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 Content

GET/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

NameTypeRequiredDescription
idstringYesThe 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

StatusMeaning
200Presigned URL minted. Body carries url, expiresInSeconds, and contentEncoding (defaults to gzip).
401Missing or unverifiable key.
404No 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.
502Control plane unreachable or returned a 5xx.
503The gateway has no control plane configured.
Poll through the 404

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 3

Example response

{
  "url": "https://storage.getsolari.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

StatusMeaning
200A JSON array of profiles, forwarded verbatim from the control plane.
401Missing or unverifiable key.
502Control plane unreachable, 5xx, or returned a non-array body.
503The gateway has no control plane configured.
Only id and name are asserted

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

FieldTypeRequiredDescription
namestringYesTrimmed before use. Must be a non-blank string.

Responses

StatusMeaning
201Profile created. Body forwarded verbatim from the control plane.
400Body was not JSON, name was missing or blank, or the platform rejected it (e.g. name already exists).
401Missing or unverifiable key.
403PlanLimitExceeded. 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.
413Content-Length exceeded the 16 KB cap.
502Control plane unreachable or 5xx.
503The 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

NameTypeRequiredDescription
idstringYesProfile ID, as returned by POST /profiles.

Responses

StatusMeaning
204Deleted.
401Missing or unverifiable key.
404No such profile in this org. The TypeScript SDK swallows this as success; over raw HTTP you see the real 404.
409The dashboard profile editor is open on this profile, so it is locked.
502Control plane unreachable or 5xx.
503The 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 Content

POST/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

NameTypeRequiredDescription
idstringYesProfile ID.

Request body

FieldTypeRequiredDescription
storageStateobjectYesPlaywright-shaped storage state: { cookies: [...], origins: [...] }. The same shape you get by fetching a session's storageStateUrl.

Responses

StatusMeaning
200Saved; version bumped. Body carries profileId, version, storageStateS3Key and sizeBytes. (The TypeScript SDK returns only the latter two; over HTTP you get the full object.)
400Body was not JSON, or storageState was missing or not an object.
401Missing or unverifiable key.
404No such profile in this org.
409Overloaded: either the editor is open, or an optimistic-concurrency version conflict. The status does not distinguish them; only the prose detail differs.
413storageState 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.
502Control plane unreachable or 5xx.
503The 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

StatusMeaning
200Body 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.
401Missing or unverifiable key.
enabled is not a per-tier guarantee

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

StatusMeaning
200Always 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.
200 does not mean a session can be served

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.

The URL is the credential

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

NameTypeRequiredDescription
sessionIdstringYesThe signed composite ID. Subject to the 90-minute expiry: an older URL fails the upgrade with 401 even if the session is alive.

Responses

StatusMeaning
101Switching Protocols. The socket is now a transparent pipe to the pool.
401The composite ID failed signature or age validation. Plain-text socket write, not JSON.
404Malformed path, or the owning pool did not appear in this replica's registry within 15s. Plain text, not JSON.
502The 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: Upgrade

WS/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

NameTypeRequiredDescription
sessionIdstringYesThe signed composite ID. Same 90-minute expiry.

Responses

StatusMeaning
101Switching Protocols.
401The composite ID failed signature or age validation.
404Malformed path, the pool did not appear within 15s, or the slot has no resolvable CDP target.
502The pool was resolved but the upstream upgrade failed.
A 201 does not guarantee this endpoint connects

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: Upgrade

WS/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

NameInRequiredDescription
sessionIdpathYesThe signed composite ID.
tokenqueryNoShort-lived JWT, used by the console instead of a bearer header. Supply this or an Authorization: Bearer header.
This upgrade does need a key

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

StatusMeaning
101Switching Protocols. Observer frames follow.
401No or invalid bearer/token, or the resolved org does not own this session.
404Malformed 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: Upgrade

Session 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.Zm9vYmFyYmF6cXV4MDEyMw

poolId 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 groupAge checked?Why
/ws/:id, /cdp/:id, /ws/observe/:idYes, 90 minutesThe 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/:idNoThese 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.