Browser
Section titled “Browser”Hoody Browser gives you HTTP control over Chromium and Firefox instances: start browsers, navigate pages, evaluate JavaScript, manage tabs, and capture DevTools URLs. Typical uses are web scraping, automated testing, screenshot services, and AI-driven web interaction.
Capabilities
Section titled “Capabilities”- Instance management - Start, stop, and restart browser processes, each with its own tabs, cookies, and state
- Page navigation - Browse a URL and read back its navigation metadata, HTML, or text
- JavaScript execution - Evaluate a script against a tab over GET or POST, in the page’s own world or an isolated one depending on the engine
- Tab management - List the open tabs and close them individually
- DevTools access - Get the WebSocket URL for the Chrome DevTools Protocol
- Fingerprinting - Set user agent, viewport, geolocation, locale, and Chromium version
- Health monitoring - Track server metrics and instance health
- Agent automation - Snapshot a page’s accessibility tree, act on an element, and wait for the result, all over HTTP
- Multiple instances - Run concurrent browsers, one per
browser-Nhostname
API Endpoints Summary
Section titled “API Endpoints Summary”All endpoints are relative to your Browser service URL:
https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.comThe hostname selects the instance: browser-1 is one browser, browser-2 another. The SDK takes a browser_id argument that routing ignores; only /history reads it, as a filter. The CLI’s --browser-id N picks the browser-N hostname, so --browser-id 1 reaches the same instance as the HTTP examples; 0 is a separate instance, browser-0.
Instance management:
GET /start- Create a browser instance, or return the metadata of the one already running (new options are ignored)GET /stop- Kill the instance; returns once it is goneGET /restart- Rebuild the instance from the options it was created with; parameters on the request override them, and cookies, tabs, and log buffers reset
Most endpoints below create the instance with default options when none is running. Pass start=false to get a 404 instead, or start=true to force creation on a server whose operator disabled auto-start. Seven never create one and return 404 whatever start says: both viewport routes, GET /viewport and POST /viewport, plus GET /devtools-url, GET /shutdown and the three agent endpoints GET /snapshot, POST /action and POST /wait. The three agent endpoints return 409 INSTANCE_CHANGED instead when the request carries an instanceGeneration and no instance exists. /history is server-wide and has no instance to create.
Browser interaction:
GET /browse?url=...- Navigate to a URL; returnstabId, the finalurl, therequestedUrlbefore redirects,title,httpStatus,created,reusedandelapsedMs; both URLs come back with credentials and the value of any query or fragment parameter whose name looks secret (such astoken,code,state) redacted; a secret under an unrelated name is notPOST /browse- The same navigation with a JSON bodyGET /snapshot- Accessibility tree of a tab as YAML, with[ref=...]element handles and asnapshotIdPOST /action- Click, fill, type, press, select, check or hover an elementPOST /wait- Wait for an element state, page text, URL or load stateGET /eval?script=...- Evaluate JavaScript scriptPOST /eval- Evaluate JavaScript with POST body
/browse takes waitUntil (commit, domcontentloaded or load; load is the default, and networkidle is deliberately not offered) and timeoutMs (up to 30000). Omitting timeoutMs and passing one are two different behaviours, not a default value: without it the navigation may take up to 30 seconds, and with it the whole budget is yours. Either way, a navigation that runs out of time is 504 TIMEOUT with details.phase navigation. The same server timeout bounds every proxied control route that has no deadline of its own, and an instance that does not answer in time returns 504 TIMEOUT. A navigation that fails outright, on DNS or a refused connection, is 502 NAVIGATION_ERROR. A site’s own 404 is not an error here: it arrives as httpStatus: 404 in a 200 response. A response that is a file download is 422 NAVIGATION_IS_DOWNLOAD, on /screenshot?url= and /pdf?url= too. Downloads are not supported: a download started by a click in the page is dropped without an error, and no file is saved. url must be an absolute http, https or data URL, about:blank, or (on Chromium engines only) a chrome: URL; any other scheme (file: in any spelling, view-source:, javascript:, blob: and the rest) is 400 VALIDATION_ERROR (url must be an http, https or data URL, about:blank, or a chrome URL on Chromium; file and other local schemes are refused), and a value that is not an absolute URL, such as /etc/hostname, is 400 VALIDATION_ERROR (url must be an absolute URL, like https://example.com/). The same rules apply to the url of /screenshot and /pdf, and the check runs before an instance is started or a tab is created, reused or navigated. See Local files.
Page content and export:
GET /html- Get page HTML contentGET /text- Get page text contentGET /screenshot- Capture the page as PNG; passurlto navigate first,fullPage=truefor the whole document, orformat=jpegwithquality(0-100); any open tab can be captured, including one in the backgroundGET /pdf- Export page as PDF;formatis Letter, Legal, Tabloid, Ledger or A0-A6 (headless Chromium only; a headful or Firefox instance returns501 NOT_SUPPORTED)
/browse, /eval, /html, /text, /pdf, and /screenshot accept a tabId. Without one, /eval, /html, /text, /pdf and /screenshot act on the active tab, and /browse opens a new tab, unless onlyIfNotExists=true finds an open tab already on that URL. Spellings the URL parser treats as the same match: https://example.org and https://example.org/, an upper-case scheme or host, a default port. With one, the request targets that tab or fails: an id that is not open returns 404 TAB_NOT_FOUND with the list of open tabs, and a malformed id returns 400. There is no fallback to the active tab.
Which JavaScript world /eval runs in depends on the engine. The script is handed to Playwright’s page.evaluate, and the two engines put that call in different places. On a stealth=false instance it runs in the page’s main world, so a global the page defined (window.myApp, a framework’s store) is visible. On the default stealth=true instance, which runs Patchright, it runs in an isolated world. The two worlds share the underlying document, so document.title and document.querySelector(...) reach the same DOM either way, but nothing else crosses: page-defined JavaScript state is absent, and so is anything the page did to the JavaScript layer on top of the DOM. A page that shadows document.title with its own property, hangs an expando off a node, or replaces a DOM method is visible to a main-world script and invisible to an isolated one. Reading what is missing returns undefined inside a 200, with nothing to distinguish that from a value the page really did leave undefined. Start the instance with stealth=false when the script has to see the page’s own variables, or reach the same state through the DOM. /html and /text are not affected; both read the DOM.
/eval answers. A script’s value comes back as {"result": <value>}, and a script that returns nothing gives {}. A script that throws, or returns a promise that rejects, is 422 EVAL_ERROR: details.name and details.message carry the error (the message cut at 1000 characters; Firefox reports the message only), and error reads Script error: <name>: <message>. A result that JSON cannot hold, such as a BigInt or a circular object, is 422 RESULT_NOT_SERIALIZABLE. timeoutMs (1 to 30000, default 30000; query or JSON body) limits the script. One still running at the limit is stopped and the answer is 504 TIMEOUT with details.phase evaluate. On Chromium engines the script is terminated and the tab stays open (details.terminated: true). On Firefox the tab is closed, because that is the only way to end the script there (details.tabClosed: true). The script is also stopped when the caller disconnects. A missing script is 400 VALIDATION_ERROR (Missing required parameter: script), the same way a missing url is on /browse; there is no separate code for either.
When a tab or its page fails. If a tab’s page crashes, the rest of the instance keeps working. /tabs marks that tab crashed: true, every request to it (/eval, /html, /text, /screenshot, /pdf, /browse, /snapshot, /action, /wait) answers 502 PAGE_CRASHED, and POST /viewport skips it. Close it with /tab/close and open a new tab. A request that is still running when its tab or the browser closes, for example during /stop or /restart, answers 502 BROWSER_DISCONNECTED. An /eval, /html or /text that the page navigates away from answers 409 PAGE_NAVIGATED; run it again on the new page. /screenshot and /pdf with url wait at most 30 seconds for the page: 504 TIMEOUT when it is slower, 502 NAVIGATION_ERROR when it cannot be reached.
Parameter values are checked before anything runs. An invalid format, quality or fullPage on /screenshot, format, margin, landscape or printBackground on /pdf, scriptBase64 on /eval, since or clear on /console and /network, or a url on GET /cookies that is not absolute is 400 VALIDATION_ERROR naming the parameter, and so is repeating one of them in the query string.
Cookies:
GET /cookies- Get browser cookiesPOST /cookies- Set browser cookies; each cookie needs aname, avalueand either an absoluteurlor adomainwith apath, and a cookie that breaks a rule is400 VALIDATION_ERRORnaming the field (likecookies[1].sameSite) with none of the request’s cookies setDELETE /cookies- Clear browser cookies
Introspection and control:
GET /metadata- Get instance metadata and DevTools URLGET /tabs- List browser tabs, including popups,target=_blanktabs the page opened and pages a CDP client opened; a tab whose page crashed carriescrashed: truePOST /tab/close- Close a browser tab; closing the last open tab or passing a malformedtabIdreturns400 VALIDATION_ERROR, and atabIdthat names no open tab returns404 TAB_NOT_FOUNDGET /devtools-url- Get DevTools WebSocket URL (200withnullfields on Firefox)GET /viewport- Read the live viewport policy; never creates an instancePOST /viewport- Change the viewport of a running instanceGET /shutdown- Graceful shutdown; returns200 {"message":"Shutting down"}before teardown finishes, so poll/metadata?start=falsefor a404if you need to know the instance is gone
Logs and history:
GET /console- Get browser console logs (full capture needsstealth=false, see below); an optionaltabIdfilters to one open tab (malformed is400 VALIDATION_ERROR, not open is404 TAB_NOT_FOUND)GET /network- Get network request/response logs;tabIdworks the same wayGET /history- Query browsing history across every instance on the server; filter withsince,domain, orbrowser_id, page withlimitandoffsetDELETE /history- Delete browsing history, optionallybeforea timestamp or for onebrowser_id; answers{"deleted": N}
/console records the console lines and uncaught page errors of every tab, including the first load of a tab the page opened, and logged objects are recorded with their content. The default stealth instance records only part of that, so there the answer says "capture": "partial" with a reason. It still records what the browser reports itself, such as failed resource loads and Content Security Policy violations. It does not record the page’s own console.log, console.info, console.warn and console.error calls, uncaught errors or unhandled promise rejections, so an empty logs does not mean the page logged nothing. Both buffers hold 500 entries. dropped in the answer counts the entries discarded since the instance started or the last clear=true; above zero, older entries are missing. Start the instance with stealth=false for full capture; every other instance answers "capture": "available". since on /console and /network is an ISO 8601 date or time such as 2026-10-05T12:00:00Z; anything else is 400 VALIDATION_ERROR, never the whole buffer. /network and /history include the first request and page of a tab the page or a CDP client opened.
Health:
GET /api/v1/browser/health- Service health checkGET /metrics- Server and instance metrics, including opentabsacross instances andmaxTabsPerInstance
Quick start
Section titled “Quick start”# Start a browser instancehoody browser start -c <container-id> --browser-id "1"
# Navigate to a URLhoody browser navigate -c <container-id> --browser-id "1" --url "https://example.com"
# Execute JavaScripthoody browser evaluate -c <container-id> --browser-id "1" --script "document.title"
# Take a screenshothoody browser screenshots capture -c <container-id> --browser-id "1"
# List open tabshoody browser tabs list -c <container-id> --browser-id "1"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
const BROWSER_ID = '1';
// Start browser instanceconst { data: instance } = await containerClient.browser.instances.start({ browser_id: BROWSER_ID });
// Navigate to URLconst { data: page } = await containerClient.browser.page.navigate({ url: 'https://example.com' }, { browser_id: BROWSER_ID });
// Execute JavaScriptconst { data: result } = await containerClient.browser.page.evaluate({ script: 'document.title' }, { browser_id: BROWSER_ID });
// Take screenshotconst { data: screenshot } = await containerClient.browser.page.captureScreenshot({ browser_id: BROWSER_ID });
// List tabsconst { data: tabs } = await containerClient.browser.tabs.list({ browser_id: BROWSER_ID });# Start browser instancecurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/start"
# Navigate to URLcurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.com"
# Execute JavaScriptcurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/eval?script=document.title"
# List tabscurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/tabs"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Runs the same four calls as the HTTP tab (start, navigate, evaluate a script, and list tabs), all against the browser-1 instance.
# Start a browser instance
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/start&method=GET&response=transparent
# Navigate to a URL
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.com&method=GET&response=transparent
# Execute JavaScript
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/eval?script=document.title&method=GET&response=transparent
# List open tabs
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/tabs&method=GET&response=transparent Starting an instance returns its metadata. A Chromium instance with remote debugging enabled, which is the default, reports a webSocketDebuggerUrl for Chrome DevTools Protocol access; on Firefox, or with useRemoteDebuggingPort=false, that field is null.
Browser configuration
Section titled “Browser configuration”Tuning the engine
Section titled “Tuning the engine”Everything about the instance except the viewport is set at /start, before the browser launches. A fingerprint profile supplies the baseline and any parameter you pass on top of it wins, so you can start from a known identity and change one thing. Calling /start on an instance that is already running returns its metadata and ignores the new options; to change one, call /restart with the new value.
For each field a fingerprint profile can set, the most specific value wins: a top-level option (locale, timezoneId, userAgent, geolocation, viewport), then the same value inside userProfile, then the fingerprint profile, then the browser’s own default. /metadata reports the locale and timezone in effect; when none was set it reports the browser’s own (the host’s), not a fixed en-US or UTC.
The options of one instance are capped at 96 KiB (400 VALIDATION_ERROR above that). Options in a query string have a lower ceiling: the whole request head, meaning the request line with its query string plus every header, may be at most 16 KiB (16,384 bytes), and headers the proxy adds count too. A larger request is refused with a bare 431 Request Header Fields Too Large that has no JSON body and no CORS headers, so a browser page sees only a network error, and the 96 KiB check is never reached. /start and /restart take only query parameters. To start an instance with an option set that does not fit, send the options in the JSON body of a POST /browse, POST /eval or POST /cookies that creates the instance; the 96 KiB cap then applies to the body.
# Start from the "default" fingerprint, then override identity and networkhoody browser start -c <container-id> --browser-id "1" \ --fingerprint-id default \ --chromium-version 136 \ --locale en-GB --timezone-id Europe/London \ --viewport '{"width":1920,"height":1080}' \ --proxy-server socks5://127.0.0.1:9050 \ --use-remote-debugging-portimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Start from the "default" fingerprint, then override identity and networkconst { data: instance } = await containerClient.browser.instances.start({ browser_id: '1', fingerprintId: 'default', chromiumVersion: '136', locale: 'en-GB', timezoneId: 'Europe/London', viewport: JSON.stringify({ width: 1920, height: 1080 }), proxyServer: 'socks5://127.0.0.1:9050', useRemoteDebuggingPort: true,});# Start from the "default" fingerprint, then override identity and networkcurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/start?fingerprintId=default&chromiumVersion=136&locale=en-GB&timezoneId=Europe/London&viewport=%7B%22width%22%3A1920%2C%22height%22%3A1080%7D&proxyServer=socks5://127.0.0.1:9050&useRemoteDebuggingPort=true"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Starts the browser-1 instance from the default fingerprint with the locale, timezone, viewport, and proxy overrides shown above.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/start?fingerprintId=default%26chromiumVersion=136%26locale=en-GB%26timezoneId=Europe/London%26viewport={"width":1920,"height":1080}%26proxyServer=socks5://127.0.0.1:9050%26useRemoteDebuggingPort=true&method=GET&response=transparent Engine and profile
| Parameter | What it does |
|---|---|
browser | chromium (default) or firefox. Firefox runs through Playwright, ignores stealth, and rejects useRemoteDebuggingPort=true, since the DevTools Protocol is Chromium-only. |
chromiumVersion | Omitted, the server uses the Chrome major of a userAgent you pass, and otherwise resolves the current stable Chrome for Testing build when the instance is created. A full version (136.0.7103.113) or a channel (stable, beta, dev, canary) is used as given. Any other value is 400 VALIDATION_ERROR, and so is a full version that no download source has. A bare major version (136) is looked up in the server’s version map, then in Chrome for Testing. A major neither resolves is treated as if omitted, without an error, so check browserFullVersion in the returned metadata. A launch that does not finish within 180 seconds is 500 BROWSER_LAUNCH_ERROR. The call blocks while the build downloads. If another process is installing the same build and the start is still waiting after about 5 minutes, it fails with 503 INSTALL_IN_PROGRESS and Retry-After: 30; retry after that delay. |
fingerprintId | Selects a configured fingerprint profile for its context and launch defaults. Request parameters override it. An unknown ID starts with an empty profile, without an error. |
stealth | true (default) asks for Patchright, a Playwright fork that patches common bot-detection signals such as navigator.webdriver; false uses plain Playwright. It is a request, not a guarantee: if the Patchright module cannot be loaded the instance falls back to plain Playwright with a warning in the server log, and /metadata reports engine: "playwright", the engine actually loaded. The backend is fixed for the life of the instance: /start with a different stealth value on a running instance returns 409 INSTANCE_BACKEND_MISMATCH, so /stop it first. |
launchArguments | Extra browser command-line flags. Repeat the key (launchArguments=--a&launchArguments=--b) or pass one JSON array. |
Identity presented to the page
| Parameter | What it does |
|---|---|
userAgent | Replaces the user-agent string. |
locale | BCP 47 language tag, for example en-GB. Anything else is 400 VALIDATION_ERROR naming locale, before the browser launches. |
timezoneId | IANA timezone, for example Europe/London. Anything else is 400 VALIDATION_ERROR naming timezoneId. |
geolocation | Coordinates reported to the geolocation API, as a JSON object. A non-object such as the query geolocation=null returns 400; a null in a JSON body is treated as omitted. |
viewport / noViewport | A fixed emulated size as JSON ({"width":1280,"height":800}, optional deviceScaleFactor); the default profile gives 1920x1080. noViewport=true, or viewport=null, lets the page follow the real window size. Combining noViewport=true with a fixed viewport returns 400. |
Network
| Parameter | Default | What it does |
|---|---|---|
proxyServer | none | http://, https://, socks5:// or socks5h://. Paired with proxyUsername, proxyPassword, proxyBypass. |
enableQuic | false | QUIC and HTTP/3 are blocked unless you turn them on. |
enableDnsOverHttps | true | DNS-over-HTTPS, resolver set by dnsOverHttpsUrl. |
dnsOverHttpsUrl | Cloudflare | Any HTTPS resolver. |
Debugging and display
| Parameter | Default | What it does |
|---|---|---|
useRemoteDebuggingPort | true | Launches with a remote debugging port and fills in webSocketDebuggerUrl. The kit picks the port itself; a remoteDebuggingPort value in the request is ignored. |
remoteDebuggingAddress | 0.0.0.0 | Bind address for the debugging port. Clients never reach the port directly; they connect through the paired cdp-N URL. |
showBrowser | true | Headful, on the X display below. false runs headless, which /pdf requires. Extensions require headful. A headful start that finds no display answers 503 DISPLAY_UNAVAILABLE with Retry-After and is not retried; start with showBrowser=false instead. |
display | 500 + N | X display for headful mode. On a browser-N hostname the proxy sets it to 500 + N (browser-1 shows on display-501) and ignores a caller’s query value. With showBrowser=true and no display from the route or the DISPLAY variable, the request fails with a validation error. |
extensions, extensionsDir, extensionsStoreIds | none | Load unpacked extensions, a directory of them, or Chrome Web Store IDs. Chromium only, and all need showBrowser=true; a start that cannot load them (headless, or a directory that does not exist) is 400 VALIDATION_ERROR naming extensions or extensionsDir, and is not retried. Store downloads of the same extension are serialized across instances; a start that is still waiting on another’s download after about 5 minutes fails with 503 INSTALL_IN_PROGRESS and Retry-After: 30; retry after that delay. |
Viewport
Section titled “Viewport”An instance starts with a fixed emulated viewport, so resizing the browser window does not change the page size and the fingerprint stays stable across instances. GET /viewport returns the live policy (viewport, source, tabs, converged). POST /viewport with {"viewport":{"width":1280,"height":800}} resizes every open tab, and later tabs inherit the size. Neither route creates an instance: with none running, both answer 404 with details.reason: "no_instance" rather than starting one. {"viewport":null} switches to responsive mode, which only works on a Chromium instance that was created responsive; a fixed-created instance returns 409 REQUIRES_RESTART, and Firefox returns 501 NOT_SUPPORTED. /metadata reports "viewport": null for a responsive instance, plus viewportSource.
Multiple instances
Section titled “Multiple instances”Each browser-N hostname is a separate instance. The proxy derives everything else from N: by default the instance listens on internal port 30000 + N and shows on display-(500 + N), and it always pairs with cdp-N. A deployment can move those two starting ports in its configuration. To run two browsers, use two hostnames:
# Start the first instancecurl "https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.com/api/v1/browser/start"
# Start the second instancecurl "https://PROJECT_ID-CONTAINER_ID-browser-2.SERVER.containers.hoody.com/api/v1/browser/start"
# Each instance is addressed by its hostnamecurl "https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.com"curl "https://PROJECT_ID-CONTAINER_ID-browser-2.SERVER.containers.hoody.com/api/v1/browser/browse?url=https://example.org"Each instance is isolated with its own tabs, cookies, and state. A ?browser_id= query parameter does not change which instance a request reaches.
Instance lifecycle
Section titled “Instance lifecycle”You do not have to call /start first. Navigation, content, cookie and log endpoints create the instance when none is running, using the options on that request plus the defaults, unless the operator started the server with auto-start disabled. GET /viewport, POST /viewport, GET /devtools-url, GET /shutdown, GET /snapshot, POST /action and POST /wait never create one. The start parameter has three states: omitted means auto-create, start=false returns 404 instead of creating, and start=true creates the instance even when auto-start is disabled. /restart follows the same rule for an address with no instance: omitted creates one (unless auto-start is disabled), start=false answers 404 NOT_FOUND and creates nothing, and start=true creates one.
/start on a running instance returns its metadata without applying new options. It still rejects options in an invalid basic format with 400, though not launch-time checks such as full Chromium-version validation, and a different stealth is refused, since that cannot change without a /stop. /restart rebuilds the same instance from the options it was created with, so an instance started with chromiumVersion=136 restarts on 136 unless the restart request says otherwise. Parameters on the restart request override the inherited values, with one exception: the engine. A restart cannot switch between the default stealth engine and plain Playwright, so one that would answers 409 INSTANCE_BACKEND_MISMATCH and leaves the instance running as it was. browser=firefox counts as that switch for a default instance, even with stealth=false added: Firefox restarts only from an instance created with stealth=false. To move a default instance to Firefox, /stop it and call /start?browser=firefox. Browser state resets: cookies, tabs, and the console and network buffers start empty. Browsing history is not part of that state: it is kept on disk, 30 days by default, and survives restarts. Any request that has to start an instance, including /start, /restart, auto-starting routes and the CDP endpoints, answers 503 INSTALL_IN_PROGRESS with Retry-After: 30 when it is still waiting on another process’s Chrome, Firefox or extension install after about 5 minutes; the landing page / keeps its HTML body.
Starting an instance counts against --request-timeout like any request. A /start, /restart or auto-start whose launch takes longer answers 504 TIMEOUT with details.phase launch and details.outcome unknown. The launch carries on, so the instance may still come up: check /metadata?start=false before retrying. An auto-starting request that timed out this way is not run. 408 means only that a request body was still arriving. If another process already listens on browser_port, a request that has to start the instance answers 409 PORT_IN_USE naming the host and port, without retrying; pick another port.
/stop kills the instance and returns once it is removed. /shutdown closes it gracefully and returns 200 before teardown finishes; if the next step depends on the instance being gone, poll /metadata?start=false until it returns 404. Neither starts an instance: on an empty address both answer 404 NOT_FOUND.
An instance that nobody uses is stopped automatically. It becomes eligible once it has been idle for the server’s idle timeout (--instance-max-age, one hour by default) and is stopped at the next cleanup check, which runs every --cleanup-interval (five minutes by default); when several instances go idle together, the later ones can stop a little after that. Use means API requests addressed to the instance, from when they arrive until they finish, CDP traffic, an open CDP WebSocket connection, and page navigations, including a person browsing on the display. An instance that is only open and running is not in use, and neither the server-wide endpoints (the health check, /metrics and /history) nor the status page’s automatic refresh counts. Stopping it discards its browser state (tabs, cookies, storage), and the next auto-starting request gets a fresh instance.
Local files
Section titled “Local files”/browse, /screenshot?url= and /pdf?url= refuse file: and every other scheme except http, https and data URLs, about:blank, and, on Chromium engines only, chrome: URLs, with 400 VALIDATION_ERROR on url. Every other about: page is refused (Firefox’s about:reader?url=file:… loads a local file), chrome: is refused on Firefox because there it is the browser’s own privileged UI, and blob: is refused. A value that is not an absolute URL, or is not a string, is refused too, and so is a supplied but empty url= on /screenshot and /pdf (omit url to capture the current tab). The url is checked before anything else: before an instance is started (no browser is launched for a refused url), before the tab lookup (so /screenshot?tabId=99999&url=file:///etc/hostname is 400, not 404) and before /pdf’s 501 NOT_SUPPORTED on Firefox or a headful instance. In POST /cookies, only the cookie entries must be http or https URLs; GET /cookies?url= only needs an absolute URL, because it filters cookies and does not navigate.
This is far from enough. It is a check on those HTTP routes only, and it does not stop the browser from opening local files. A tab can still reach file:// through /eval (for example location.href = 'file:///…'), through any CDP client, or through the live view, and /text, /html, /snapshot and /screenshot then show the file. A real fix is a block inside the browser itself, which the kit does not have yet. Treat local files as readable by anything that can drive the browser.
Agent loop
Section titled “Agent loop”/snapshot, /action and /wait give an HTTP-only caller a native observe, act, wait loop with Playwright’s actionability checks behind it, instead of hand-written /eval JavaScript. They work on Firefox, which has no DevTools Protocol.
These three never create an instance. Start one with /api/v1/browser/start or /api/v1/browser/browse first, or they return 404 (409 INSTANCE_CHANGED if the request carries an instanceGeneration).
GET /api/v1/browser/snapshot returns the tab’s accessibility tree as YAML with [ref=...] handles, plus a snapshotId, url, title, truncated and chars. maxChars caps the text at 30000 by default, up to 200000, cutting on a line boundary.
POST /api/v1/browser/action takes {"action": "...", "target": {...}}. The action is one of click, fill, type, press, select, check or hover. fill and type carry value, press carries key, select carries values, check carries checked.
POST /api/v1/browser/wait takes {"condition": {"kind": "..."}}, where kind is target, text, url or loadState. A condition that never becomes true is 504 TIMEOUT, not a matched: false body.
B="https://PROJECT_ID-CONTAINER_ID-browser-1.SERVER_NAME.containers.hoody.com/api/v1/browser"
# Observecurl "$B/snapshot"
# Act by ref from that snapshot (replace the ref and snapshotId below with values from your own# /snapshot response), or by a semantic target that needs no snapshotcurl -X POST "$B/action" -H 'content-type: application/json' -d '{ "action": "fill", "target": { "ref": "e5", "snapshotId": "gen_4a1c9f2b7d3e5081:7" }, "value": "user@example.com" }'
curl -X POST "$B/action" -H 'content-type: application/json' -d '{ "action": "click", "target": { "role": "button", "name": "Sign in" } }'
# Wait for the resultcurl -X POST "$B/wait" -H 'content-type: application/json' -d '{ "condition": { "kind": "text", "target": { "selector": "#status" }, "text": "Signed in" } }'Targets. Set exactly one of ref (with its snapshotId), selector (a single CSS selector), role (with name), label, text, placeholder or testId. exact defaults to true. Every target is strict: two matches return 409 AMBIGUOUS_TARGET with the candidates rather than acting on the first one. A candidate’s name never includes text typed into editable elements inside it. Semantic and CSS targets address the top document; use a ref to reach inside an iframe.
Refs go stale. A ref is valid only while its snapshot is the tab’s latest and the top document has not navigated. Reusing one after that returns 409 STALE_SNAPSHOT with details.reason, so the caller re-observes instead of acting on whatever now carries that ref.
A 409 STALE_SNAPSHOT does not always mean nothing happened; details.outcome says which. Reusing a ref after a newer snapshot or a top-document navigation answers 409 STALE_SNAPSHOT with details.reason, and nothing was sent to the page: re-observe and act again. A navigation that lands while an action is still waiting to act gets the same answer. details.outcome not-started means the input never reached the page, so retrying is safe. details.outcome unknown means the input had been dispatched when the tab navigated and the browser call then failed, so the action may have taken effect: take a new /snapshot and check the page before retrying, because repeating the action can run it twice. A navigation the action itself causes, once its input has been dispatched and the call completes, is its result and not a conflict: following a link, submitting a form, pressing Enter, or a script that changes the URL answers 200, and url is the address after the action. The ref that issued such an action is stale afterward, so take a new /snapshot before using it again.
Snapshots hide what you typed. A textbox, searchbox, spinbutton or slider holding entered text renders as <value hidden>, because the accessibility tree otherwise serialises the value and would hand every password or token back on the next iteration. An empty control renders with no value at all, so the two are still distinguishable. The content of editable regions, such as a contenteditable editor, is hidden the same way: the region keeps its role and ref, and its contents become <value hidden>. Pass includeValues=true when the contents are known not to be sensitive. A plain native <select> keeps its option names, since those are page content. Any other combobox, and a <select> with rich or editable content, is masked like a textbox.
Budgets. timeoutMs bounds the whole operation: 10000 by default, 30000 at most. Expiry is 504 TIMEOUT carrying details.phase; action timeouts also carry details.outcome, but a /wait condition timeout reports phase: "wait" without one. details.outcome means the same thing on every response that carries it: not-started means the action was never dispatched and is safe to retry, unknown means it may have landed, and completed means it finished before whatever else went wrong.
One controller per tab. A second /action, /browse or /snapshot on a tab that is already running one gets 409 TAB_BUSY instead of racing. /wait is never gated, so it can watch a tab while an action runs. The gate does not cover /eval, DevTools Protocol clients, or a person clicking on the display.
Dialogs. An optional dialog policy authorises handling the first dialog the action raises, if it matches; it never requires one. Any other dialog is dismissed and the call returns 409 UNEXPECTED_DIALOG. A page that opens an unrelated dialog first therefore spends the policy on that one, and the dialog you meant to accept is dismissed.
Instance identity. Pass the instanceGeneration from /metadata and a replaced browser answers 409 INSTANCE_CHANGED rather than silently acting on a different instance. A request that carries instanceGeneration never auto-starts an instance: if none is running, the answer is 409 INSTANCE_CHANGED, not 404.
A 200 means the browser operation completed. It does not mean the site’s transaction succeeded; confirm that with /wait and /snapshot.
Chrome DevTools Protocol
Section titled “Chrome DevTools Protocol”Launch a Chromium instance with useRemoteDebuggingPort=true and it gets a paired cdp-{n} DevTools Protocol URL, where cdp-1 is the same browser as browser-1. Point Playwright, Puppeteer, or any CDP client at the HTTPS base URL and it discovers the live WebSocket for you:
const { chromium } = require('playwright');
// Connect to a Hoody Browser instance over CDPconst browser = await chromium.connectOverCDP( 'https://PROJECT_ID-CONTAINER_ID-cdp-1.SERVER.containers.hoody.com/');
const page = await browser.newPage();await page.goto('https://example.com');await page.screenshot({ path: 'screenshot.png' });- On demand - Connecting to a cold
cdp-{n}URL starts that instance with the debugging port on and the samestealthdefault as/start: Patchright, or plain Playwright when Patchright can’t be loaded. A later/starton the same instance with default options returns that instance. An explicit different value,stealth=false, still returns409 INSTANCE_BACKEND_MISMATCHuntil the instance is stopped. To drive a plain-Playwright instance over CDP, start it with/start?stealth=falsefirst; connecting attaches to a running instance whatever itsstealthsetting. - Restart-stable - The URL names the instance rather than a port, so it keeps working across browser restarts. Always pass the
https://PROJECT-CONTAINER-cdp-{n}.SERVER.containers.hoody.com/base URL rather than a hard-codedwss://link, because debugger GUIDs change on every launch. - Chromium only - Connecting CDP to a Firefox instance, or to a Chromium instance started with
useRemoteDebuggingPort=false, returns501./devtools-urlon a Firefox instance answers200with every URL fieldnull. - Every page is a tab - A page a CDP client opens is a tab of the instance wherever it opens: with no context named (
browser.newPage()in Puppeteer,contexts()[0].newPage()after Playwright’sconnectOverCDP) or in a browser context the client created. It shows in/tabsand is recorded in/history,/networkand/consolelike any other tab. A page created with its address already set (Target.createTargetwith aurl) has finished loading before the server sees it, so that first load is in/historybut its requests and console lines are not; open a blank page and navigate it, as Puppeteer and Playwright do, to have them recorded. - Paths - Encoded separators (
%2F,%2E,%5C) and backslashes are refused in the request path with400 CDP_BAD_PATH, never in the query string, so a DevTools frontend link with an encodedws=parameter is served.
How a relayed WebSocket session ends tells the client what happened:
| Close code | Reason | Meaning |
|---|---|---|
1001 | instance stopped, instance restarting, instance idle timeout, server shutting down | The server ended the session on purpose (/stop, /restart, the idle timeout, server shutdown). Reconnect when the instance is back. |
1000 | none | The browser closed the session normally. |
1011 | for example browser exited, chrome connect timeout, chrome backpressure | A failure: the browser exited on its own, could not be reached, or could not keep up. |
The DevTools endpoint returns the same paired URL. Open it in your browser to load Chrome DevTools against the remote instance:
# Get the DevTools Protocol URLs for an instancehoody browser devtools urls get -c <container-id> --browser-id "1"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Get the DevTools Protocol URLs for an instanceconst { data: devtools } = await containerClient.browser.instances.getDevtoolsUrls({ browser_id: '1' });// { webSocketDebuggerUrl, devtoolsHttpUrl, devtoolsFrontendUrl }# Get the DevTools Protocol URLs for an instancecurl "https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/devtools-url"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Returns the paired DevTools Protocol URLs for the browser-1 instance, including the webSocketDebuggerUrl.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT-CONTAINER-browser-1.SERVER.containers.hoody.com/api/v1/browser/devtools-url&method=GET&response=transparent Use Cases
Section titled “Use Cases”- Web scraping - Fetch a page’s rendered HTML or text over HTTP
- Automated testing - Run end-to-end tests against web applications, including CDP-driven Playwright and Puppeteer suites
- Screenshot services - Capture page images on demand without running Chrome locally
- AI web interaction - Let AI agents browse pages and evaluate scripts against them
- PDF generation - Render HTML to PDF from a headless instance via Chrome’s print functionality
- Performance monitoring - Audit web performance metrics
What’s Next
Section titled “What’s Next”- Browser Instance Management API - Start, stop, restart instances
- Browser Interaction API - Navigate and evaluate JavaScript
- Browser Control API - Metadata, tabs, and DevTools
- Browser Health API - Metrics and monitoring
- cURL Service - For simpler HTTP requests without a browser
- Exec Service - Execute scripts that orchestrate browser automation