Cloud browser
Saved logins
A profile stores the sign-in half of a browser session, its cookies and per-site localStorage, encrypted and separate from any live browser. Sessions come and go in seconds. The login stays, so an agent can act for a user days after they signed in.
How a saved login works
- Create an empty profile.
- Open a session and have the person sign in, usually in the live viewer.
- Capture that session into the profile.
- Create later sessions with
profileId. They start already signed in, and the login is written back when they end, so cookies the site rotated during the run are kept.
All profile endpoints live under /api/browser/profiles and use the browser:read and browser:write scopes. Stored cookies are never returned by any endpoint.
Create a profile and capture a login
A profile needs a name, unique within your account. clientRef is your own id for the end user it belongs to.
curl -X POST https://scrappy.gg/api/browser/profiles \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{ "name": "acme-crm-user-42", "clientRef": "user_42" }'
The profile holds nothing until you capture a live session into it. Call capture once the person has finished signing in:
curl -X POST https://scrappy.gg/api/browser/profiles/$PROFILE/capture \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{ "sessionId": "b1f2c3d4-..." }'# -> { captured: true, cookieCount: 18, originCount: 2 }
A capture that comes back empty is rejected, not written, so a failed read cannot wipe a working profile. An explicit capture saves whatever the session holds: you are vouching that the page is signed in.
Start a session from a profile
curl -X POST https://scrappy.gg/api/browser/sessions \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{ "profileId": "prf_123", "clientRef": "user_42" }'# -> { session: {...}, viewerUrl: "...", pinned: true }
Cookies and localStorage are in place before the first page loads. Creating a session from a profile that has no captured login yet answers 404.
If one API key drives browsers for many of your own users, send X-Client-Ref: <your user id> on every browser request. A request carrying it can only see and use the sessions and profiles recorded for that same user.
Each profile keeps its own exit IP
A session created with a profileId leaves through the same residential IP address every time. Scrappy records that exit when the login is captured. You do not supply it or manage it.
This matters because many sites tie a login to where it was made. A session cookie that shows up from a different IP on every run is a strong sign of automation, and it is the most common reason a saved login gets challenged or signed out. Replaying from one stable address looks like the same person coming back. Sessions with no profile and no clientRef rotate through shared exits, so do not expect a stable IP from those.
| Field | Where | What it does |
|---|---|---|
pinned | Create-session response | Whether this session went out through the profile's pinned exit. false means re-check: capture again if the profile has no pin, or retry shortly if the exit is temporarily out of use. |
rotateEgress: true | Create-session body | Keeps the login but takes a fresh exit for this run. Use it to retry after a navigation fails with UPSTREAM_UNAVAILABLE. Do not set it by default. |
{ "proxyUrl": null } | PATCH /api/browser/profiles/{id} | Clears the pin for good. The next session picks a new exit, and the next capture pins the profile to it. |
PATCH also accepts name and userAgent. It changes how a profile is routed and never touches the stored login. Supply your own proxyUrl only if you are bringing your own proxy.
Automatic capture
A session created from a profile is saved back to it without you asking: periodically while the session is live, and when it ends through release, sleep, the idle timeout or running out of credits. This keeps rotated cookies current even if your code never calls capture again.
The signed-out guard
A replay the site rejected ends up on a sign-in page, and that page has cookies of its own. Saving it would replace a working login with a signed-out one. Automatic saves therefore refuse to write when the profile already holds a login and there is positive evidence that the session has lost it:
- a tab on one of the login's own sites is showing a sign-in page or a visible password field, or
- the cookie jar has shrunk and a real share of the cookies the stored login had are missing.
The guard applies to automatic saves only. Explicit capture is unchanged. For a single release you can also skip the write with DELETE /api/browser/sessions/{id}?capture=false.
Opting out with autoCapture: false
The guard is a structural check. If your code can judge "signed in" better, for example by looking for an element only a signed-in user sees, create the session with autoCapture: false and save the profile yourself. Scrappy then never writes that session back: no periodic refresh, and no capture on release, sleep, idle timeout or out of credits. Only a literal false opts out.
# Create the session with automatic saves offcurl -X POST https://scrappy.gg/api/browser/sessions \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{ "profileId": "prf_123", "autoCapture": false }'# ... run your flow, confirm the page is signed in, then save it yourselfcurl -X POST https://scrappy.gg/api/browser/profiles/prf_123/capture \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{ "sessionId": "b1f2c3d4-..." }'
Webhooks for lost logins and ended sessions
Two webhook events tell you what happened to a session without polling it. Subscribe to them like any other event:
curl -X POST https://scrappy.gg/api/user/webhooks \-H "Authorization: Bearer sk_..." -H "Content-Type: application/json" \-d '{"name": "Browser logins","url": "https://example.com/hooks/scrappy","secret": "a-long-random-string","events": ["BROWSER_LOGIN_LOST", "BROWSER_SESSION_ENDED"]}'
| Event | Fires | data.reason |
|---|---|---|
BROWSER_LOGIN_LOST | Once per breakage, when the site ends the login of a live session that was created from a profile. Checked each minute. It can fire again after a capture from the same session saves a login. | sign_in_path, password_form, auth_cookie_removed |
BROWSER_SESSION_ENDED | Once, when a session ends for any reason. | released, idle_timeout, max_age, orphaned, out_of_credits, browser_gone, not_resumed |
{"id": "0b9c...","event": "BROWSER_LOGIN_LOST","timestamp": "2026-10-01T12:00:00.000Z","data": {"sessionId": "b1f2c3d4-...","apiKeyId": "key_123","clientRef": "user_42","profileId": "prf_123","origin": "https://example.com","path": "/login","reason": "sign_in_path","detectedAt": "2026-10-01T12:00:00.000Z"}}
Both events carry sessionId, apiKeyId, clientRef and profileId. BROWSER_SESSION_ENDED adds endedAt. BROWSER_LOGIN_LOST adds detectedAt, plus origin (scheme and host) and path (no query string or fragment). Both are null when the reason is auth_cookie_removed. Neither event ever includes page text, cookie names or values.
When BROWSER_LOGIN_LOST arrives, the usual response is to ask the user to sign in again in the viewer and capture the session once they have.
Verify the signature
Each delivery has an X-Scrappy-Signature header: the hex HMAC-SHA256 of the raw JSON body, keyed with your webhook secret. A failed delivery is retried up to three times with increasing delays, and GET /api/user/webhooks/{id} shows the delivery history. See Webhooks and integrations for the rest.
import crypto from "node:crypto";// rawBody is the request body exactly as received, before JSON.parseexport function isFromScrappy(rawBody, signatureHeader, secret) {const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");const a = Buffer.from(expected);const b = Buffer.from(signatureHeader ?? "");return a.length === b.length && crypto.timingSafeEqual(a, b);}