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.
webBotAuthbooleanNoDefault 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.
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.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, 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

This endpoint is dead. It always 404s. Do not build on it.

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

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

Responses

StatusMeaning
200Never 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.
401Missing or unverifiable key.
404The 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.
502The 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 Found

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

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.