Connect Playwright or Puppeteer to a fingerprinted Wayfern browser over CDP and keep the interaction, login state and exit control a scraping endpoint cannot hand back.
A scraping endpoint hands you the page it decided to fetch. A browser lets you log in, fill a form, wait for the thing you actually came for, and then keep going. The difference is structural, not a matter of features — so the only real question is who runs the browser.
| Capability | Scraping API | Self-hosted browsers | Wayfern |
|---|---|---|---|
| Interaction | One request in, one response out | Full CDP — once you are running the browsers | Full CDP over one WebSocket: goto, fill, click, wait |
| Login state | Ends with the response | Yours to keep alive | Cookies and storage stay live for as long as the socket is open |
| Isolation | Not exposed to the caller | Yours to build and keep enforcing | A pooled instance is reused only for the same link token, workload and exit |
| Geo granularity | Whatever the vendor chooses to expose | Whatever your proxy vendor exposes, wired by hand | Country → region → city → ISP, plus a sticky session id |
| Who maintains it | The vendor, on their roadmap | You: pool, upgrades, proxy wiring, driver versions | We run the pool; you keep your Playwright code |
Open the CDP WebSocket at wss://browser.wayfern.com/ws with a link token in the query string. There is no SDK to install and nothing to learn: from
the connect call onward it is the Playwright or Puppeteer you already write.
import { chromium } from "playwright";
// The session is created when this socket opens — there is no REST call to
// "start" one, and no queue to poll.
const browser = await chromium.connectOverCDP(
"wss://browser.wayfern.com/ws?token=<LINK_TOKEN>&country=US®ion=CA&session=run42",
);
const page = await browser.contexts()[0].newPage();
// Everything below is ordinary Playwright, running on a browser you hold open.
await page.goto("https://example.com/login");
await page.fill("#email", process.env.ACCOUNT_EMAIL);
await page.fill("#password", process.env.ACCOUNT_PASSWORD);
await page.click("button[type=submit]");
await page.waitForSelector("[data-testid=dashboard]");
console.log(await page.title());
await browser.close();No create call, no session id to poll for. On a successful connection the gateway sends one JSON message, then proxies CDP between your driver and the browser for as long as the socket stays open.
{
"type": "connected",
"connectionId": "0c2c3a1e-...",
"instanceId": "inst-...",
"contextId": "ctx-..."
}The pool only reuses a browser for connections that carry the same link token and the same exit location. A pooled instance is never shared with another token, another workload, or a different geo.
Geo targeting is part of the connection string, not a separate proxy integration. Add country and the session
egresses through a residential exit there; narrow it further with region, city and isp. Leave them off and you
get the default datacenter exit, whose egress is free.
| Param | Type | What it does |
|---|---|---|
| country | string | ISO-3166 country code of the residential exit, e.g. "US". Required to enable residential location targeting; omit for the default exit. See GET /api/proxy/locations. |
| region | string | Region/state code within the country, e.g. "CA". Optional; narrows the exit. |
| city | string | City code within the country/region. Optional; narrows the exit. |
| isp | string | ISP code. Optional; narrows the exit to a carrier. |
| session | string | Sticky-session id ([A-Za-z0-9]+). When set, the same residential exit IP is held for the session TTL so a multi-step flow keeps one IP. |
Every code is checked against the live catalog before the browser is handed over. An unknown country, region, city or ISP is refused at the handshake with close code 4007 rather than silently falling back to the wrong location.
Add session=<id> and the
same residential exit IP is held for the session TTL, so a login on step one and a checkout
on step six are seen coming from one address.
wss://browser.wayfern.com/ws?token=<LINK_TOKEN>&country=US®ion=CA&session=run42The catalog is live, not a static list in a PDF. These endpoints take a dashboard JWT and
return code / name pairs you can feed
straight back into the connection string.
/api/proxy/locations/api/proxy/locations/countries/api/proxy/locations/regions?country=US/api/proxy/locations/cities?country=US®ion=CA/api/proxy/locations/isps?country=US{
"locations": [
{ "code": "US", "name": "United States" },
{ "code": "GB", "name": "United Kingdom" }
]
}The token in the socket URL is not just authentication. It is the unit you cap, isolate and switch off with, so one crawler's connection limit can be lowered — or its token revoked — without touching any of the others.
Link tokens live inside a project and are created by its owners and admins, separately from your wf_ API tokens. Give each crawler its own so you can turn one off without touching the rest.
Every link carries a maxConcurrentConnections value between 1 and 100, defaulting to 10. Connection number cap+1 is refused at the handshake with close code 4003 rather than quietly queued.
Listing a project’s links returns activeConnections alongside each one — the number of sockets currently open on that token, read from the connection tracker.
Revoke a leaked token and every new connection is refused with close code 4002. Regenerate issues a fresh secret on the same link. An optional ISO-8601 expiry retires a token on schedule; absent means it never expires.
When the gateway will not give you a browser it says so in a way you can branch on: a JSON { "type": "error", "code", "message" } message, then a WebSocket close with one of these codes. No connection is left hanging for you
to time out.
| Code | Meaning |
|---|---|
| 4001 | Authentication required: token missing. |
| 4002 | Invalid or revoked token. |
| 4003 | Rate limit exceeded: max N concurrent connections for this link. |
| 4004 | No billing account for this link. |
| 4005 | Session not allowed (billing gate, e.g. no active subscription or no remaining session-hours). |
| 4006 | Residential proxy location control is not available on this deployment. |
| 4007 | Invalid proxy location (unknown country/region/city/isp). |
| 4008 | Failed to provision the residential proxy. |
| 4500 | Internal server error. |
You are charged for pages you hold open, from the same credit balance the Web Data API draws on. Nothing on this page is a separate line item on your invoice.
One page held open for one hour, or $0.10 at the base credit price. Two pages for half an hour costs the same as one page for an hour.
Every page you open is billed for at least 60 seconds, however fast you close it. We would rather say that here than bury it in an invoice.
Only when you ask for a residential exit, metered per byte as it is consumed. Datacenter egress — every session without location params — is free.
Run the balance down and the gateway refuses the next connection with close code 4005 instead of running up a bill. Full rates, plans and credit packs are on the pricing page.
Plenty of work is a single fetch with no clicking involved. For that, a session is the wrong shape and the wrong price: the Web Data API runs the same anti-detect browsers, hands back markdown, HTML, links and metadata, and bills per page instead of per hour.
You need to log in, fill something, click through steps, wait on a selector, or hold one exit IP across a flow — anything where the next request depends on what the last one rendered.
You just want the page. Scrape is 1 credit per page, crawl 1 per successful page, and a screenshot 5 per capture.
Both draw on one credit balance, so discovering URLs with the Web Data API and then driving the handful that need a login over CDP needs no second plan and no second contract.
Create a project, mint a link token, and point your existing Playwright or Puppeteer script at the gateway.