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, captcha, and allowedDomains.
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.
allowedDomainsstring[]NoRestrict egress to a fixed list of hosts (and their subdomains); see Network Access. Each entry must be a bare hostname — no scheme, port, or wildcard. Requires stealth: true and the plan's allowedDomains feature (enterprise only — a non-enterprise key gets 402 FeatureRequiresPlan). Omitted or null means unrestricted; [] is rejected with 400 rather than treated as unrestricted — see Network Access for why. Echoed back (normalised) in the create response when supplied. See Egress allowlist below for the full enforcement model and the runtime-update endpoint.

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; allowedDomains 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 GCS 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": "AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...Nzp2w9.RtY7dQxLmNbVcKsHj4FwZu",
  "wsEndpoint": "wss://api.getsolari.com/ws/AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...Nzp2w9.RtY7dQxLmNbVcKsHj4FwZu",
  "cdpEndpoint": "wss://api.getsolari.com/cdp/AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...Nzp2w9.RtY7dQxLmNbVcKsHj4FwZu",
  "expiresAt": "2026-07-17T09:00:00.000Z",
  "storageStateUrl": {
    "url": "https://storage.googleapis.com/cmowemxix0001mt01fxlwjc7m/cmusb5bzs01heny01tnvz9ddm/v7.json?X-Goog-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":"cmusb5bzs01heny01tnvz9ddm"}')

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 opaque, signed 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": "AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...Nzp2w9.RtY7dQxLmNbVcKsHj4FwZu",
  "status": "active",
  "kind": "fast",
  "org": "cmowemxix0001mt01fxlwjc7m",
  "createdAt": "2026-09-01T12:00:00.000Z",
  "expiresAt": "2026-09-01T13:00:00.000Z",
  "wsEndpoint": "wss://api.getsolari.com/ws/AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...",
  "cdpEndpoint": "wss://api.getsolari.com/cdp/AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo..."
}

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 opaque, signed 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 URL for the session's rrweb replay. Only sessions created with recording: true produce one. Fetch the returned url directly, with no auth header. contentEncoding tells you what the bytes you actually receive will be — not what was uploaded. The stored object is always gzip, but on GCP the storage layer decompresses it transparently on an ordinary download, so contentEncoding is "identity" there and "gzip" on AWS. Check this field before decompressing — do not assume gzip.

Path parameters

NameTypeRequiredDescription
idstringYesThe opaque 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 — the actual encoding of the bytes the URL will serve ("identity" on GCP, "gzip" on AWS).
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. Carries code: "ReplayPending" when the upload is still in flight (the session is still running, or just ended — retry) or code: "ReplayUnavailable" when recording was never enabled for this session (terminal). The code is omitted only against a platform build that predates the distinction.
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.
ENCODING=$(echo "$BODY" | jq -r .contentEncoding)
echo "$BODY" | jq -r .url | xargs curl -sS -o replay.ndjson
if [ "$ENCODING" = "gzip" ]; then
  gunzip -c replay.ndjson | head -n 3
else
  head -n 3 replay.ndjson
fi

Example response

{
  "url": "https://storage.googleapis.com/cmowemxix0001mt01fxlwjc7m/9d1c4e2a.ndjson.gz?X-Goog-Signature=…",
  "expiresInSeconds": 900,
  "contentEncoding": "identity"
}

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": "cmusb5bzs01heny01tnvz9ddm", "name": "linkedin-login" },
  { "id": "cmusc2x4z01hfny01qmdw3abc", "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": "cmusb5bzs01heny01tnvz9ddm", "name": "linkedin-login" }

DELETE/profiles/:id

Deletes the profile row. Orphaned GCS 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/cmusb5bzs01heny01tnvz9ddm" \
  -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/cmusb5bzs01heny01tnvz9ddm/save" \
  -H "Authorization: Bearer $SOLARI_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @- | jq .

Example response

{
  "profileId": "cmusb5bzs01heny01tnvz9ddm",
  "version": 7,
  "storageStateS3Key": "cmowemxix0001mt01fxlwjc7m/cmusb5bzs01heny01tnvz9ddm/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, a sorted lowercase countries array (ISO-3166-1 alpha-2, the union across tiers), and tiers — the same countries broken out per tier (residential, mobile, static). Requesting any country outside countries from POST /sessions is a 400.
401Missing or unverifiable key.
A resolvable tier for a country is still not a guarantee

tiers tells you which (country, tier) pairs the pool can serve — it is not a live availability feed. A country listed under tier: "mobile" can still fail to resolve under load, and proxy: "smart" resolving to no proxy at all is a separate, known failure mode. A session requesting a proxy still returns 201 even when it resolves to no proxy, so assert on the proxy field in the response — never on this endpoint alone.

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"],
  "tiers": {
    "residential": ["au","br","ca","de","es","fr","gb","in","it","jp","kr","mx","nl","sg","us"],
    "mobile": ["au","br","ca","de","es","fr","gb","in","it","jp","kr","mx","nl","sg","us"],
    "static": ["au","br","ca","de","fr","gb","in","it","jp","mx","nl","us"]
  }
}

Egress allowlist

allowedDomains on POST /sessions restricts a stealth session to a set of destination hostnames, subdomain-suffix matched — an entry of example.com permits example.com and any *.example.com, nothing else. Omitting the field (the default for every session) is unrestricted egress, unchanged.

Enforcement is network-level, below Chrome, not a navigation filter: every outbound connection a page makes — navigations, but also every image, script, stylesheet, and XHR/fetch it triggers — is checked, independently of how the connection is made: a direct DNS lookup for a non-allowlisted hostname resolves to NXDOMAIN; a request proxied through the slot's local CONNECT forwarder (used by every proxy-configured session) is checked against the same allowlist at the forwarder and refused with a 403 proxy response if the target host doesn't match; and a connection made entirely outside either of those paths — including a raw IP-literal destination, and including a raw CDP client over /cdp/ bypassing the shim this proxy and resolver instrument — is blocked at the packet level before it leaves the host.

Setting it: at create time (allowedDomains on POST /sessions, requires stealth: true, else 400) or any time after, below.

POST/sessions/:id/allowed-domains

Replaces the session's current allowlist in full — this is not an add, despite the create-time field's name; the gateway holds no partial list to merge against, so send your complete desired set every time. There is no endpoint to read the list back; track what you last sent.

Request body

FieldTypeRequiredNotes
domainsstring[]YesEach entry must be a non-empty hostname (letters, digits, ., -), else 400. An empty array reverts the session to unrestricted egress.

Responses

StatusMeaning
200Applied synchronously — by the time you get this response, the new list is live. Body echoes { ok: true, domains }.
400Three distinct causes, same status: malformed domains; the session isn't a stealth session (the fast pool has no proxy shim to enforce an allowlist, so this call would silently protect nothing); or the session was created with no allowedDomains field at all (not even an empty array). That last case isn't a validation failure of this call — it's a consequence of how Chrome gets launched: a session created without an initial allowlist never goes through the relaunch that seeds the enforcement boundary, so some hosts may already route around it regardless of what you set here. If you want the option to update the allowlist later, pass at least one real host at create time — an empty array is rejected with 400 (see above), so there is no way to start unrestricted and keep this option open.
404Session not found. Covers both "never existed" and "already released" — this endpoint can't distinguish them, so either way it's a true no-op: nothing was mutated, on this session or any other.
503{ code: "PoolUnreachable", retryable: true }. The gateway replica handling your request can't currently reach the host your session lives on. Transient — retry.

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, opaque 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 opaque, signed 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 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 opaque, signed ID. Same 90-minute expiry.

Responses

StatusMeaning
101Switching Protocols.
401The 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

Bidirectional live-view stream for a session, used by the console's live view. This channel is not read-only. It carries screencast frames one way and input events the other: the viewer drives the real browser through it, and the pool dispatches what arrives as trusted CDP input.

So an observer is not a passive viewer. Opening the socket is well guarded — the signed URL alone is not enough, and this route additionally requires either an org-matching Authorization: Bearer key or a short-lived token minted for that exact session. But once open, granting observe access is granting control: whoever holds those credentials can type and click in the live session. That is the part the “read-only” wording denied.

Frame type is the discriminator, not direction. Binary frames are screencast imagery, forwarded from CDP Page.screencastFrame, and travel server to client only. Text frames carry structured JSON and travel in both directions: client to server they are input, dispatched by the pool as CDP Input.dispatchMouseEvent and Input.dispatchKeyEvent; server to client they are out-of-band metadata about the session, such as the guest's real viewport size, which a viewer needs in order to map a click correctly.

Do not treat either list as closed. A text message is identified by its type field, and new ones can appear in either direction without the transport changing — so branch on type, and ignore anything you do not recognise rather than failing on it.

The frame schema is owned jointly by the pool and the console, and neither end negotiates or versions it, so both must change together. A protocol mismatch is silent in both directions — it looks like nothing happening rather than an error. The pool dispatches on a message type through a chain with no fallback branch and drops anything it cannot parse; the console renders binary frames and discards text ones. Neither side reports that the other is speaking a different protocol.

Path and query parameters

NameInRequiredDescription
sessionIdpathYesThe opaque, signed session 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 bound into it server-side. 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.
503The session has no page yet — a brand-new session's browser starts with zero tabs open, so there is nothing to stream. It resolves itself the moment any page exists: open one (navigate, or let the SDK's default page load) and the identical observe call upgrades cleanly. Transient — retry, or wait until after your first navigation.

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 an opaque, signed token — not a composite you can parse apart:

AgODjZhJuvsUOhwIXtabrWP8D_P6XOUAyXJBc4feYRziSSNo...Nzp2w9.RtY7dQxLmNbVcKsHj4FwZu

It is base64url (already URL-safe, no manual encoding needed) and carries no inspectable fields — routing, org scoping and URL lifetime are all enforced server-side against the signature, not read out of the string. The signature is verified on every route; the age is not, except where the table below says so.

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.