What the Vision browser API actually exposes
Vision gives you two separate surfaces, and most tutorials blur them. The vision browser api is an HTTP interface for managing profiles, folders and proxies, authorised with an X-Token header. Driving the browser itself happens one layer down, over the DevTools Protocol: you start a profile through the API, read the port the running session returns, then attach Playwright, Puppeteer or Selenium to that endpoint. Below is that whole sequence as one working Python script, with the errors that trip people up at each step.

📌 TL;DR Executive Summary
- Core Takeaway: Vision’s HTTP API handles profiles, folders and proxies with an X-Token header. Automation libraries reach the browser over CDP, using the port returned when a profile is started through the API.
- Key Risk/Challenge: Only API-started profiles expose an attachable session, and calling browser.close() on a CDP connection can terminate the entire browser instance.
- Recommended Solution: Keep endpoint paths in one constants block, poll /json/version for the exact webSocketDebuggerUrl, close pages instead of the browser, and stop the profile explicitly when the job ends.
Prerequisites before the first request
Six things need to be in place before your script can talk to anything.
- Vision desktop app, installed and signed in. It runs on Windows 10+, macOS 12+ (Monterey) and 64-bit Linux. The local API is served by the app process, so it has to be alive on the machine executing your script.
- A profile to drive. Create one in the app or through the API. Use it only with accounts you own or are authorised to manage — the API changes how you operate an account, not what a platform allows.
- Python 3.10+ with httpx and Playwright. Run
pip install httpx playwright. The browser your script drives is Vision’s; Playwright is just the CDP client. - Node 18+ with puppeteer-core if you prefer JavaScript. Install
npm i puppeteer-core, not fullpuppeteer, which downloads a Chromium you will never launch. - Your X-Token from the app’s Settings section, plus the X-Team-Token if you work in teamwork mode.
- The API reference for your build. Path strings live in the app’s own documentation and can shift between releases; the base URLs and header names are the stable part.
None of this turns Vision into a scraping toolkit. The API starts profiles and the CDP connection drives a page — the sites you load still apply their own rules about automated access, so point the script at accounts and properties you are authorised to operate.
Step 1 — Authenticate every request with X-Token
Vision does not use OAuth or a key in the query string. You copy a token from the Settings section of the desktop app and send it as the X-Token header. In teamwork mode you add a second header, X-Team-Token, which authorises requests against the team’s resources.
import os, time, httpx
LOCAL_BASE = "http://127.0.0.1:3030" # desktop app on this machine
CLOUD_BASE = "https://v1.empr.cloud/api/v1" # public cloud API
class VisionClient:
def __init__(self, base_url, token, team_token=None):
headers = {"X-Token": token, "Content-Type": "application/json"}
if team_token:
headers["X-Team-Token"] = team_token
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30.0)
def request(self, method, path, retries=3, **kwargs):
for attempt in range(retries):
resp = self._http.request(method, path, **kwargs)
if resp.status_code < 500 and resp.status_code != 429:
resp.raise_for_status()
return resp.json() if resp.content else None
time.sleep(2 ** attempt)
resp.raise_for_status()
def close(self):
self._http.close()
client = VisionClient(LOCAL_BASE, token=os.environ["VISION_TOKEN"])
The wrapper does three things worth keeping: it pins the base URL so local and cloud stay a one-line switch, it attaches both tokens, and it retries 429 and 5xx responses with exponential backoff so a busy endpoint does not kill a scheduled job. A 401 on the first call almost always means a stale token — recopy it from Settings rather than editing the string.
Step 2 — Find the profile ID you want to start
Every operation is keyed on a profile ID, not a display name, so list the collection and match on what you see in the app.
PROFILES = "/profiles" # collection path as listed in your build's API reference
def find_profile(client, name):
for profile in client.request("GET", PROFILES):
if profile.get("name") == name:
return profile["id"]
raise LookupError(f"no profile named {name}")
profile_id = find_profile(client, "Client A storefront")
print("profile id:", profile_id)
Each record carries the profile’s own settings, including proxy configuration and fingerprint parameters, because those belong to the profile rather than to the browser process. This name lookup is the one manual step in the workflow: from here on the vision browser api addresses everything by ID, so pass that string around rather than searching again. If you plan to build folder and proxy management on top of this, the browser profile management API walkthrough covers that side of the model in more detail.
Step 3 — Start the profile through the API and read its port
This is the step people skip, and it is the reason their connect call fails. Starting a profile through the API returns a running session that includes a port number. Only a session created through the vision browser api advertises a port, and that port is your only route into the browser. A profile you opened by double-clicking in the app has no session registered, so there is nothing to attach to.
def start_profile(client, profile_id):
# POST to the profile's start action; the response is the running session
session = client.request("POST", f"{PROFILES}/{profile_id}/start")
print("session:", session)
return session["port"]
port = start_profile(client, profile_id)
print("DevTools port:", port)
Path strings are versioned, so keep them as constants at the top of the file and copy the current ones from your build’s API reference — the shape is a POST to the profile’s start action, and the JSON that comes back is the session object carrying the port. If your start call reports that the profile is already running, stop it through the API and start once more before you start debugging your client. For background on what each profile actually separates at the browser level, our roundup of browser fingerprinting APIs is the place to start.
Step 4 — Turn that port into the exact DevTools URL
A port number is not an endpoint. Chromium answers a version probe at /json/version with JSON, and the field you need is webSocketDebuggerUrl. The 9222 you see in most Playwright examples is Chrome’s default remote-debugging port; Vision gives each running session its own, so never hard-code it.
def devtools_ws_url(port, timeout=60.0):
url = f"http://127.0.0.1:{port}/json/version"
deadline = time.time() + timeout
while time.time() < deadline:
try:
info = httpx.get(url, timeout=3.0).json()
return info["webSocketDebuggerUrl"]
except (httpx.HTTPError, KeyError, ValueError):
time.sleep(0.5)
raise TimeoutError(f"no DevTools endpoint on port {port}")
ws_url = devtools_ws_url(port)
print(ws_url) # ws://127.0.0.1:PORT/devtools/browser/<id>
The polling loop matters because the browser process is still booting when the start call returns. A short retry loop beats a hard sleep(10), which fails on a slow machine and wastes nine seconds on a fast one. The browser ID inside the WebSocket path identifies the running instance, which is why you copy that string verbatim instead of assembling it from the port yourself.
Step 5 — Attach Playwright with connectOverCDP
chromium.connect_over_cdp(endpointURL) attaches to a browser that is already running, which is what you want here: the profile keeps its cookies, its open tabs and its signed-in state. launch() starts a clean browser with none of that, and launchPersistentContext(userDataDir) only helps when the profile data sits on your own disk under your own control.
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(ws_url)
context = browser.contexts[0] # the profile's existing context
page = context.new_page()
page.goto("https://example.com", wait_until="domcontentloaded")
print(page.title())
page.close() # close pages, not the browser
browser.contexts[0] is the context holding the profile’s session state, so create your page inside it rather than opening a fresh context of your own. And resist calling browser.close(): over CDP it can terminate the whole browser instance, ending the session the API just started for you. Close the pages you created, let the with block drop the connection, and stop the profile with an API call. The connectOverCDP documentation spells out both behaviours. If you also drive hosted sessions elsewhere, the attach pattern for Playwright over a virtual browser API is the same one.
Step 6 — Attach Puppeteer, Selenium, chromedp or Chromiumoxide
The DevTools endpoint is protocol-level, so the library is your choice. Puppeteer takes the same WebSocket URL under a different option name.
const puppeteer = require('puppeteer-core');
async function run(wsUrl) {
const browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, defaultViewport: null });
const page = await browser.newPage();
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
console.log(await page.title());
await page.close();
browser.disconnect(); // leaves the profile's browser running
}
run(process.env.VISION_WS_URL);
browser.disconnect() drops the client connection and leaves the browser alive — the Node equivalent of skipping browser.close(). Vision’s documentation lists Puppeteer, Playwright, Rust’s Chromiumoxide and Go’s chromedp as supported routes, with Python covered through Pyppeteer. Selenium gets there a slightly different way: point Chrome’s debuggerAddress capability at 127.0.0.1:PORT and it speaks to the same endpoint. Pick one client per session; two libraries attached to one browser will fight over page targets and produce confusing errors that look like API failures.
Step 7 — Stop the profile when the job finishes
A started profile stays up until something stops it. That means a browser process left running on the workstation and a profile still holding its proxy configuration after your script has exited.
def stop_profile(client, profile_id):
client.request("POST", f"{PROFILES}/{profile_id}/stop")
print("stopped", profile_id)
stop_profile(client, profile_id)
client.close()
In real code, put the stop call in a finally block. An exception halfway through a Playwright script should still leave the workspace as you found it, and the stop endpoint is cheap to call whether the browser is still open or already gone.
Local API or cloud API? Pick by where the browser runs
The vision browser api ships in two deployments, and the split decides whether you can attach at all. Both endpoints take the same headers and cover much of the same ground, but they are not interchangeable for the attach step.
| What you need | Local app API | Cloud API |
|---|---|---|
| Base URL | http://127.0.0.1:3030 | https://v1.empr.cloud/api/v1 |
| Served by | The desktop app on your machine | The vendor’s servers |
| Auth headers | X-Token, plus X-Team-Token in teamwork mode | X-Token, plus X-Team-Token in teamwork mode |
| Start a profile and get a port | Yes — sessions and ports originate here | Account-level work only |
| Folders, profiles, proxies, fingerprints | Yes | Yes |
| Good fit | Workstation scripts and scheduled local jobs | Setting up folders and profiles from any machine |
The community Python client, vision-browser-async, splits its surface along exactly this line: public_api_methods for CRUD against the cloud, local_api_methods for start and stop. That split tells you the attachable session is a local-app thing. A build agent that cannot run a signed-in desktop app cannot hand you a port, so plan the architecture instead of tweaking the URL. If your pipeline already speaks to hosted browser services, our guide to integrate cloud browser APIs into your workflow covers the trade-offs of moving work off your own hardware.
Common errors and what actually causes them
Almost every failure in this workflow lands in one of six buckets. Match the symptom before you change code.
| Symptom | Cause | Fix |
|---|---|---|
| 401 on every request | Token copied from an old session, or teamwork mode without a team header | Recopy from Settings; add X-Team-Token |
| ECONNREFUSED on ws://127.0.0.1:PORT | Wrong port, or the profile is not actually running | Probe http://127.0.0.1:PORT/json/version first |
| Target.attachToTarget error | Endpoint URL assembled by hand instead of copied | Use webSocketDebuggerUrl verbatim |
| Browser disappears after the script | browser.close() called on a CDP connection | Close pages, then disconnect |
| Start returns no port | Profile was opened manually in the app | Close it, start it through the API |
| Attach hangs, then times out | Polling a port the session has not bound yet | Retry the version probe with backoff |
The endpoint mismatch is the most common one and the most misleading, because the error names a target rather than the URL. Anything other than the exact webSocketDebuggerUrl — a stale browser ID, a different port, a hand-written path — fails the same way. The second most common is a manual profile: if you clicked it open in the app, the API has no session to report. Close that window and let the script own the lifecycle.
The full script
Everything above, assembled into one file you can drop on a workstation, point at a profile name, and run.
import os, time, httpx
from playwright.sync_api import sync_playwright
LOCAL_BASE = "http://127.0.0.1:3030"
PROFILES = "/profiles"
TARGET_PROFILE = "Client A storefront"
TARGET_URL = "https://example.com"
class VisionClient:
def __init__(self, base_url, token, team_token=None):
headers = {"X-Token": token, "Content-Type": "application/json"}
if team_token:
headers["X-Team-Token"] = team_token
self._http = httpx.Client(base_url=base_url, headers=headers, timeout=30.0)
def request(self, method, path, retries=3, **kwargs):
for attempt in range(retries):
resp = self._http.request(method, path, **kwargs)
if resp.status_code < 500 and resp.status_code != 429:
resp.raise_for_status()
return resp.json() if resp.content else None
time.sleep(2 ** attempt)
resp.raise_for_status()
def close(self):
self._http.close()
def find_profile(client, name):
for profile in client.request("GET", PROFILES):
if profile.get("name") == name:
return profile["id"]
raise LookupError(name)
def devtools_ws_url(port, timeout=60.0):
url = f"http://127.0.0.1:{port}/json/version"
deadline = time.time() + timeout
while time.time() < deadline:
try:
return httpx.get(url, timeout=3.0).json()["webSocketDebuggerUrl"]
except (httpx.HTTPError, KeyError, ValueError):
time.sleep(0.5)
raise TimeoutError(f"no DevTools endpoint on port {port}")
def main():
client = VisionClient(
LOCAL_BASE,
token=os.environ["VISION_TOKEN"],
team_token=os.environ.get("VISION_TEAM_TOKEN"),
)
profile_id = find_profile(client, TARGET_PROFILE)
session = client.request("POST", f"{PROFILES}/{profile_id}/start")
try:
ws_url = devtools_ws_url(session["port"])
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(ws_url)
page = browser.contexts[0].new_page()
page.goto(TARGET_URL, wait_until="domcontentloaded")
print(page.title())
page.close()
finally:
client.request("POST", f"{PROFILES}/{profile_id}/stop")
client.close()
if __name__ == "__main__":
main()
Run it with VISION_TOKEN set, and VISION_TEAM_TOKEN too if you are in teamwork mode. Because the stop call sits in finally, a Playwright exception still leaves the workspace clean. Real uses are mundane and legitimate: a QA pass against your own staging site, a check that a client’s dashboard renders after a deploy, or a confirmation that a profile’s proxy exits where you expect. Swap TARGET_URL and the same skeleton works for any single-profile job you would otherwise do by hand.
🏆 Send.win Verdict
Everything in this tutorial belongs to Vision: the requests above go to Vision’s own interface, the port comes from Vision’s own session, and the client you attach is one of the libraries Vision documents. Send.win solves a different problem. It is a browser profile manager for professionals and teams who run client accounts, storefronts and social profiles day to day — each isolated profile keeps its own cookies, storage, sessions, fingerprint settings and proxy, in the Sendwin Browser desktop app on Windows, macOS or Linux, or in the cloud browser from any device with nothing to install. On Pro and Team a profile shared with a paid teammate opens already signed in, residential proxies are included, timezone and locale follow the proxy’s exit IP, and the free cloud demo gives you 10 minutes a day to try the cloud workflow before you pay.
See Send.win plans — Pro from $19/mo with 150 profiles, 5 GB of proxy bandwidth and 6 seats; Team from $49/mo with 500 profiles and 20 GB.
Frequently Asked Questions
How do I get the X-Token for the Vision API?
Open the Vision desktop app, go to Settings and copy the token shown there. Send it as the X-Token header on every request. In teamwork mode the account also needs the separate X-Team-Token header, which authorises requests against the team’s resources.
Can I drive a Vision profile with Playwright or Puppeteer?
Yes, but through the DevTools Protocol rather than a bespoke SDK. Start the profile via the API, read the port from the running session, fetch webSocketDebuggerUrl from /json/version, and pass it to connectOverCDP in Playwright or browserWSEndpoint in Puppeteer. Vision’s docs also list Chromiumoxide and chromedp, with Pyppeteer for Python.
Why can I only connect to profiles started through the API?
A window you opened by hand in the app has no session registered with the API, so there is no port to hand your automation library. The API-started session is what produces that port. Close the manual window, start the profile through the API, and the attach works.
Where is the vision browser api listening by default?
On 127.0.0.1:3030, served by the desktop app installed on that machine. The public cloud API sits at https://v1.empr.cloud/api/v1 and takes the same X-Token header. The community Python client wraps both bases, so you can see the split in its source.
How do I fix ECONNREFUSED from connectOverCDP?
Assume the endpoint URL is wrong before you assume the library is. Request http://127.0.0.1:PORT/json/version and copy webSocketDebuggerUrl exactly — the browser ID in that path has to match the running instance. If the probe itself fails, the profile is not running and the port is stale.
Does browser.close() shut down the connected browser?
Over a CDP connection it can terminate the whole browser instance, which also ends the session you started through the API. Close the pages you created and let the client disconnect instead, then stop the profile with an explicit stop call so the lifecycle stays predictable.
Does Vision provide a Python SDK?
Not officially. vision-browser-async is a community-built async client, MIT licensed, requiring Python 3.13 or newer, with Pydantic models and httpx retries against both the local and cloud endpoints. It is a convenience wrapper over the same HTTP calls, so the raw requests in this tutorial remain the source of truth.
Is there a free trial of Vision?
Vision is sold on paid plans. Third-party reviews disagree about trial length and the starting price, so check the vendor’s own pricing page before you plan a rollout around either. For developing the script itself, any local Chromium started with --remote-debugging-port lets you rehearse the whole attach sequence for free.
How Send.win Helps With Vision Browser Api
Send.win is a browser profile manager for professionals and teams: every account gets its own isolated browser profile.
- Isolated profiles – separate cookies, storage and sessions per profile
- Fingerprint protection – each profile keeps its own consistent browser fingerprint, so profiles never share one browser identity
- Desktop app + cloud browser – native app for Windows, macOS and Linux, or run profiles in the cloud with no install
- Residential proxies included – on Pro and Team, with timezone and locale matched to the proxy
- Team sharing – share a signed-in profile with a teammate without sharing the password
Try the free cloud demo — no install — or download the desktop app. Paid plans start at $19/month, and your first card purchase is covered by a 7-day money-back guarantee (see pricing).