Local API
The Nullprint app runs an HTTP server on your machine (by default http://127.0.0.1:7801) that you can call from scripts, Playwright, or any language. Every request needs an X-API-Key header.
Quickstart
Driving Nullprint from an AI agent? Use the MCP server instead, or follow the DeepSeek dsh guide.
- Install the Nullprint app and log in. The Local API is available whenever the app is running.
- Open the Automation page in the app and copy the address and key from the Local API card (the key is a string starting with
np_). - Try it with curl. If you get your list of profiles back, you're connected:
curl -H "X-API-Key: np_YOUR_KEY" http://127.0.0.1:7801/profiles
The {name} in endpoint paths is the profile's internal ID: the name field returned by GET /profiles. The name shown in the app lives in meta.label and isn't guaranteed to be unique.
Python: launch a profile and attach with Playwright
First turn on external debugging in the profile's settings (needs a second verification). To keep cookies safe, profiles don't open a debugging port by default, so cdp_endpoint stays null and the examples below would wait 60 seconds and fail. To turn it on: in the app, Edit the profile → Advanced → tick External debugging (CDP port), complete the second verification (a grant lasts 24 hours), then close and reopen the profile. While it's off, GET /profiles/{name}, /launch and /sessions include cdp_unavailable (the reason) and cdp_hint (one sentence); the examples stop on it instead of waiting. See cdp_endpoint for the reasons. If you only drive the browser with HTTP commands you don't need it — use Sessions.
After launching (POST /profiles/{name}/launch), poll GET /profiles/{name} until status is running and cdp_endpoint is set, then hand it to Playwright. Requires pip install requests playwright.
import time, requests
from playwright.sync_api import sync_playwright
BASE, KEY = "http://127.0.0.1:7801", "np_YOUR_KEY"
H = {"X-API-Key": KEY}
requests.post(f"{BASE}/profiles/shop-1/launch", json={"headless": False}, headers=H).raise_for_status()
for _ in range(60): # wait for the window and its CDP endpoint
p = requests.get(f"{BASE}/profiles/shop-1", headers=H).json()
if p["status"] == "running" and p.get("cdp_endpoint"):
break
if p.get("cdp_unavailable") not in (None, "starting", "not_running"):
raise RuntimeError(p["cdp_hint"]) # e.g. external debugging is off: waiting won't help
time.sleep(1)
else:
raise RuntimeError("no cdp_endpoint within 60 seconds")
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(p["cdp_endpoint"])
page = browser.contexts[0].pages[0]
page.goto("https://example.com")
print(page.title())
requests.post(f"{BASE}/profiles/shop-1/stop", headers=H)
Node: the same flow
Node 18+. Run npm i playwright, save as quickstart.mjs, and run it.
import { chromium } from "playwright";
const BASE = "http://127.0.0.1:7801";
const H = { "X-API-Key": "np_YOUR_KEY", "Content-Type": "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const r = await fetch(`${BASE}/profiles/shop-1/launch`, {
method: "POST", headers: H, body: JSON.stringify({ headless: false }),
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
let p;
for (let i = 0; i < 60; i++) { // wait for the window and its CDP endpoint
p = await (await fetch(`${BASE}/profiles/shop-1`, { headers: H })).json();
if (p.status === "running" && p.cdp_endpoint) break;
if (![undefined, "starting", "not_running"].includes(p.cdp_unavailable)) throw new Error(p.cdp_hint); // e.g. external debugging is off: waiting won't help
await sleep(1000);
}
if (!p.cdp_endpoint) throw new Error("no cdp_endpoint within 60 seconds");
const browser = await chromium.connectOverCDP(p.cdp_endpoint);
const page = browser.contexts()[0].pages()[0];
await page.goto("https://example.com");
console.log(await page.title());
await browser.close(); // disconnects only; the browser stays open
await fetch(`${BASE}/profiles/shop-1/stop`, { method: "POST", headers: H });
Skip Playwright: use sessions
Open a session, then drive the browser with plain HTTP commands (see Sessions). Opening a session returns only once the browser is ready; session commands don't need external debugging. When external debugging is on, the response includes cdp_endpoint too; when it's off, cdp_endpoint is null and cdp_unavailable / cdp_hint say why.
curl -X POST http://127.0.0.1:7801/sessions/shop-1 -H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" -d '{"headless": false}'
curl -X POST http://127.0.0.1:7801/sessions/shop-1/goto -H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" -d '{"url": "https://example.com"}'
curl -H "X-API-Key: np_YOUR_KEY" -o shot.png http://127.0.0.1:7801/sessions/shop-1/screenshot
curl -X DELETE http://127.0.0.1:7801/sessions/shop-1 -H "X-API-Key: np_YOUR_KEY"
Conventions
Address and port
The base URL is http://127.0.0.1:7801, and the server listens on localhost only. If port 7801 is taken by another program, the app falls back to the first free port in 7802–7810. The actual port and current key are written to a local daemon.json file:
- Windows:
%USERPROFILE%\.nullprint\daemon.json - macOS:
~/.nullprint/daemon.json
The file looks like {"port": 7801, "pid": 12345, "started": "…", "api_key": "np_…"}. For long-running scripts, we recommend reading the port and key from it on every start:
import json, pathlib
d = json.loads((pathlib.Path.home() / ".nullprint" / "daemon.json").read_text(encoding="utf-8-sig"))
BASE, KEY = f"http://127.0.0.1:{d['port']}", d["api_key"]
API key
- Send the header
X-API-Key: <Key>with every request. Only the header is accepted — since 0.2.17 the?api_key=query parameter no longer works. - The only exception is
GET /version, which needs no key and is handy for checking whether the server is up. - The key is stored only on your machine and is never uploaded. Clicking Reset key on the Automation page (or calling
POST /api-key/reset) invalidates the old key immediately. - A missing key returns
401 {"detail": {"reason": "api_key_required"}}; a wrong key returns401 {"detail": {"reason": "invalid_api_key"}}.
Requests and responses
Request and response bodies are JSON (Content-Type: application/json), except for the screenshot endpoint, which returns an image. Successful calls usually return 200; create endpoints return 201, launch endpoints return 202 (accepted and running in the background), and delete endpoints return 204 (no body).
Errors return a body of {"detail": …}. For errors your code can act on, detail is an object with a machine-readable reason (sometimes with extra fields):
{"detail": {"reason": "kernel_missing", "kernel": "chromium-148"}}
For other errors, detail is a plain message, e.g. {"detail": "already running: shop-1"}. FastAPI's built-in validation errors (missing fields, wrong types) return detail as an array.
| Status | Meaning |
|---|---|
| 401 | Missing or invalid key (api_key_required / invalid_api_key) |
| 403 | Not permitted or blocked by your plan: a team member lacks the required permission (forbidden, with perm); not logged in or subscription expired (reason is the license state, e.g. unlicensed / plan_expired); profile count exceeds your plan (quota_exceeded / over_limit) |
| 404 | Profile or proxy not found |
| 409 | State conflict: profile already running or not running, kernel not installed, GeoIP database still downloading, session not open, etc. |
| 422 | Invalid parameters: a bad value, or FastAPI field validation failed |
| 429 | Too many sessions open at once |
| 501 | Not supported on this OS (window tiling and window sync are Windows-only) |
| 502 | A browser action inside a session failed (e.g. element not found, timeout); detail describes the error |
| 503 | Network access required but unavailable (writes on team accounts, needs_network) |
Concurrency limits
maxonPOST /launch-many(default 5) caps the number of windows open at the same time. Extra profiles wait in a queue, and the next one starts only after an open window is closed. To open them all at once, setmaxto the number of profiles.- Up to 8 sessions (
/sessions) can be open at once; opening more returns 429.
Profiles
GET /profiles
Lists all profiles, including their running status.
curl -H "X-API-Key: np_YOUR_KEY" http://127.0.0.1:7801/profiles
[
{
"name": "shop-1",
"os": "windows",
"engine": "chromium",
"kernel": "148",
"created": "2026-10-01T10:20:30+08:00",
"proxy": "http://1.2.3.4:8000",
"status": "running",
"last_error": null,
"meta": {"label": "Shop 1", "group": "US", "tags": ["amazon"], "note": "…", "last_launched": "…"}
}
]
| Field | Description |
|---|---|
name | Internal ID; the {name} used in other endpoint paths |
status | running / idle (running if either a launched window or a session is open) |
engine / kernel | Browser engine and Chrome major version |
proxy | Proxy as scheme://host:port (credentials stripped); null if none |
last_error | Details of the most recent failed launch (at / code / error), or null |
meta | Display name label, group, tags, note, last_launched, and more |
Other fields omitted. Team members don't see profiles they lack launch permission for.
GET /profiles/{name}
Returns a single profile's details, running status, debugging endpoint, and fingerprint.
{
"name": "shop-1",
"status": "running",
"cdp_endpoint": "ws://127.0.0.1:53123/devtools/browser/0f6c…",
"engine": "chromium",
"os": "windows",
"created": "2026-10-01T10:20:30+08:00",
"proxy": "http://1.2.3.4:8000",
"meta": {"label": "Shop 1", "launch": {"noise": "kernel"}, "…": "…"},
"geo": {"timezone": "America/New_York", "locale": "en-US", "latitude": 40.7, "longitude": -74.0, "ip": "1.2.3.4"},
"fingerprint": {"user_agent": null, "seed": 123456789, "…": "…"}
}
| Field | Description |
|---|---|
status | running / idle |
cdp_endpoint | The browser's debugging endpoint (ws://127.0.0.1:<port>/devtools/browser/<id>); pass it to Playwright's connect_over_cdp. Only set while the profile is running with external debugging turned on (off by default; turning it on needs a second verification). Otherwise null — the next two fields say why |
cdp_unavailable | Present only when cdp_endpoint is null; reason codes are in cdp_endpoint: external_debug_off / external_debug_not_granted / not_supported_for_engine (waiting won't help — act on cdp_hint); not_running / starting (still starting, keep polling) |
cdp_hint | One sentence that comes with cdp_unavailable (Chinese by default; English with ?lang=en or the x-np-lang: en header) |
proxy | Proxy as scheme://host:port (credentials stripped) |
meta | Display name, group, tags, note, launch options launch, and more |
Other fields (the full fingerprint) omitted. Errors: 404 if the profile doesn't exist.
POST /profiles
Creates a profile and returns 201.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name (need not be unique). The internal ID is derived from it and guaranteed unique, so always use the name in the response |
proxy | string | No | Proxy address, e.g. http://user:pass@host:port, socks5://host:port, host:port:user:pass |
engine | string | No | Browser engine; defaults to chromium |
kernel | string | No | Chrome major version, e.g. "148"; defaults to the one bundled with the app |
os | string | No | windows / macos / linux; defaults to the host OS |
note | string | No | Note |
tags | string[] | No | Tags |
group | string | No | Group name |
urls | string[] | No | URLs to open on launch |
launch | object | No | Launch options (match the app's Launch settings) |
curl -X POST http://127.0.0.1:7801/profiles \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"name": "Shop 1", "proxy": "http://user:pass@1.2.3.4:8000", "group": "US"}'
{"name": "Shop 1", "label": "Shop 1", "os": "windows", "engine": "chromium", "created": "2026-10-02T09:00:00+08:00"}
Errors: 403 quota_exceeded (with limit / used), not logged in or subscription expired (reason is the license state), forbidden (no create permission); 422 empty name, invalid proxy address, firefox_disabled; 503 needs_network.
PATCH /profiles/{name}
Updates the display name, group, tags, note, proxy, and so on. Send only the fields you want to change; omitted (or null) fields are left as is.
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | Display name; "" reverts to showing the internal ID |
group | string | No | Group name |
tags | string[] | No | Tags (replaces the whole list) |
note | string | No | Note |
proxy | string | No | Proxy address; "" removes the proxy |
urls | string[] | No | URLs to open on launch (replaces the whole list) |
launch | object | No | Launch options (replaced as a whole; to change one option, read meta.launch first and merge) |
curl -X PATCH http://127.0.0.1:7801/profiles/shop-1 \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"note": "Linked to a@example.com", "tags": ["amazon", "us"]}'
{"name": "shop-1", "proxy": "http://1.2.3.4:8000", "meta": {"note": "Linked to a@example.com", "tags": ["amazon", "us"], "…": "…"}}
Errors: 404; 403 forbidden (no edit permission); 422 invalid value; 503 needs_network.
DELETE /profiles/{name}
Moves the profile to the trash (recoverable) and returns 204. Errors: 404; 409 if the profile is running (stop it first); 403 forbidden; 503 needs_network.
GET /trash
Lists profiles in the trash, most recently deleted first.
[{"name": "shop-9", "engine": "chromium", "os": "windows", "created": "…", "deleted_at": "…"}]
POST /trash/{name}/restore
Restores a profile from the trash. A restored profile counts against your plan again.
{"name": "shop-9", "engine": "chromium", "created": "…"}
Errors: 404; 409 if a profile with the same name exists; 403 quota_exceeded / forbidden; 503 needs_network.
Launching & attaching
There are two ways to open a profile:
- Launch (
/launch,/launch-many): the same as clicking Open in the app. It opens a standalone browser window. Best for hands-on use, opening windows in bulk, and tiling. - Session (
/sessions/{name}, see Sessions): a browser managed by the app that you can drive with HTTP commands directly, no Playwright install needed.
A profile can be open in only one of these ways at a time. POST /profiles/{name}/stop closes either.
POST /profiles/{name}/launch
Launches a profile and returns 202 (the process has started; the window appears shortly after).
| Parameter | Type | Required | Description |
|---|---|---|---|
headless | bool | No | Run headless; defaults to false |
url | string | No | URL to open after launch |
curl -X POST http://127.0.0.1:7801/profiles/shop-1/launch \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
{"name": "shop-1", "pid": 23456, "cdp_unavailable": "external_debug_off", "cdp_hint": "External debugging is off for this profile (the default). …"}
When the profile doesn't have external debugging on, the response carries cdp_unavailable / cdp_hint: this launch won't produce a cdp_endpoint. When it's on, cdp_unavailable is starting — keep polling GET /profiles/{name}.
Errors: 404; 403 subscription expired (reason is the license state), over_limit (profile count exceeds your plan, with locked), forbidden; 409 kernel_missing (with kernel), geoip_downloading (the GeoIP database is downloading on first use; retry shortly), firefox_disabled, profile already running, exit IP doesn't match the profile's record or the proxy is unreachable (when the corresponding launch option is enabled); 503 geoip_update_failed (the GeoIP database download couldn't start).
Launching is asynchronous: if the browser process fails afterwards, the failure is recorded in last_error on GET /profiles.
POST /launch-many
Launches profiles in bulk. Returns 202 immediately, then starts them one by one in the background within the concurrency limit.
| Parameter | Type | Required | Description |
|---|---|---|---|
names | string[] | Yes | Profiles to launch |
max | int | No | Maximum windows open at once; defaults to 5. A slot frees up only when a window is closed |
headless | bool | No | Run headless; defaults to false |
curl -X POST http://127.0.0.1:7801/launch-many \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"names": ["shop-1", "shop-2", "shop-3"], "max": 3}'
{"accepted": ["shop-1", "shop-2", "shop-3"], "max": 3}
Errors (the whole batch is validated before it's accepted; if any profile fails, none are launched): 404; 409 kernel_missing, geoip_downloading, a profile is already running; 403 subscription expired / over_limit.
POST /profiles/{name}/stop
Stops a profile by closing its launched window and/or session. The browser is first asked to exit cleanly (so cookies are flushed to disk), then force-killed after a few seconds. Calling it on a profile that isn't running also returns 200.
{"name": "shop-1", "stopped": ["launch"], "status": "idle"}
stopped lists what was actually closed: launch (a launched window) and/or session (a session). Errors: 404.
GET /windows
Process IDs of running launched windows (sessions not included).
[{"name": "shop-1", "engine": "chromium", "wrapper_pid": 23456, "browser_pid": 23460, "pids": [23460, 23471]}]
browser_pid is the main browser process (the one that owns the window). It may still be null right after launch.
cdp_endpoint: attach with Playwright / Puppeteer
External debugging is off by default. A local debugging port has no authentication — any program on the same computer could connect and read all of the profile's cookies — so profiles don't open one by default, and cdp_endpoint stays null. To attach Playwright / Puppeteer, turn it on per profile: in the app, Edit the profile → Advanced → tick External debugging (CDP port), complete the second verification (a grant lasts 24 hours; tick it again after it expires), then close and reopen the profile (turning it on while the profile runs takes effect on the next open). Chrome-engine profiles only.
Whenever cdp_endpoint is null, the same response carries cdp_unavailable (a reason code) and cdp_hint (one sentence):
cdp_unavailable | Meaning | What to do |
|---|---|---|
external_debug_off | External debugging isn't on for this profile (the default) | Turn it on as above, then reopen the profile |
external_debug_not_granted | It was on, but the grant expired or was revoked | Tick it again and complete the second verification, then reopen the profile |
not_supported_for_engine | Firefox-engine profiles have no external debugging | Use Sessions |
not_running | The profile isn't running | Launch it; right after /launch you may briefly see this too — keep polling |
starting | Everything is set; the browser hasn't published its port yet | Keep polling, usually a few seconds; if it never appears, it was probably turned on while running — reopen the profile |
Once it's on, the debugging endpoint is returned here:
- Launched windows:
cdp_endpointonGET /profiles/{name}. Launching is asynchronous, so it starts outnull; poll untilstatus == "running"andcdp_endpointis set. See Quickstart. - Sessions: the responses from
POST /sessions/{name}andGET /sessionsincludecdp_endpointdirectly (nullwithcdp_unavailable/cdp_hintwhen it's off).
With external debugging on, the debugging port is randomized on every launch by default. To pin it (say, another tool needs a fixed address), set the launch option launch.debugPort (1024–65535, unique per profile). launch is replaced as a whole, so read meta.launch first and merge:
launch = requests.get(f"{BASE}/profiles/shop-1", headers=H).json()["meta"].get("launch") or {}
requests.patch(f"{BASE}/profiles/shop-1", json={"launch": {**launch, "debugPort": 9301}}, headers=H)
Another launch option set the same way: launch.forceDark (boolean, default false, Chrome engine only) forces web pages that have no dark theme of their own to render dark — a personal display preference. Off, pages render exactly as the sites intend; on, the profile's color scheme also reads as dark. Sites may notice the changed colors, so use it only on profiles you manage for yourself. Takes effect at the next launch; POST /profiles accepts it in launch too.
Closing the Playwright connection after attaching only disconnects; it doesn't close the browser. To close the browser, use POST /profiles/{name}/stop.
Sessions
If you'd rather not install Playwright, you can drive a session's browser with plain HTTP commands: open a session, send commands, then close it. Commands act on the session's current tab. Each command can run for up to 120 seconds.
Errors common to all commands: 409 if the session isn't open (session not open, also returned when the profile doesn't exist) or has died and must be reopened (session dead); 409 dialog_open if a JavaScript dialog is open on the page (see Dialogs); 502 if a browser action fails (element not found, timeout, etc.; detail describes the error and the session stays usable).
POST /sessions/{name}
Opens a session and returns 201 once the browser is ready. If the session is already open, returns the existing one.
| Parameter | Type | Required | Description |
|---|---|---|---|
headless | bool | No | Run headless; defaults to false (visible window) |
idle_timeout | int | No | Seconds of inactivity before the session closes automatically; defaults to 600. Only commands sent through this API count, so raise it for long jobs that drive the browser purely over a direct Playwright connection |
{"name": "shop-1", "opened_at": 1759370000.0, "idle_seconds": 0, "url": "about:blank", "cdp_endpoint": "ws://127.0.0.1:53123/devtools/browser/…"}
Errors: 404; 409 kernel_missing, firefox_disabled, the browser failed to start (session dead, reopen: session failed to start: …, e.g. the profile is already open via /launch); 403 subscription expired / over_limit; 429 session limit (8) reached.
POST /sessions/{name}/goto
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | Yes | URL to navigate to (waits up to 60 seconds) |
curl -X POST http://127.0.0.1:7801/sessions/shop-1/goto \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
# → {"url": "https://example.com/"}
POST /sessions/{name}/click_selector
| Parameter | Type | Required | Description |
|---|---|---|---|
selector | string | Yes | CSS / Playwright selector |
timeout | int | No | Milliseconds to wait for the element |
Returns {"ok": true}.
POST /sessions/{name}/fill_selector
| Parameter | Type | Required | Description |
|---|---|---|---|
selector | string | Yes | Input field selector |
value | string | Yes | Text to fill in (replaces existing content) |
submit | bool | No | Press Enter after filling; defaults to false |
timeout | int | No | Milliseconds to wait for the element |
Returns {"ok": true}.
POST /sessions/{name}/type
Finds an element by its accessibility role and name and fills in text (no selector needed).
| Parameter | Type | Required | Description |
|---|---|---|---|
role | string | Yes | Role, e.g. textbox / searchbox |
name | string | Yes | The element's accessible name (usually its label text or placeholder) |
text | string | Yes | Text to fill in |
submit | bool | No | Press Enter after filling; defaults to false |
Returns {"ok": true}. You can find roles and names in the snapshot output.
POST /sessions/{name}/press
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Key name, e.g. Enter, Tab, Control+A |
Returns {"ok": true}.
POST /sessions/{name}/scroll
| Parameter | Type | Required | Description |
|---|---|---|---|
dy | number | Yes | Vertical scroll in pixels (positive scrolls down) |
dx | number | No | Horizontal scroll in pixels; defaults to 0 |
Scrolls with real mouse-wheel events. Returns {"ok": true}.
POST /sessions/{name}/wait_for
Waits for a condition. Pass exactly one of selector / text / url (passing none returns 502).
| Parameter | Type | Required | Description |
|---|---|---|---|
selector | string | One of three | Wait for the element to appear |
text | string | One of three | Wait for this text to appear on the page |
url | string | One of three | Wait for the URL to match (wildcards supported, e.g. **/dashboard) |
timeout | int | No | Milliseconds; defaults to 10000. Returns 502 on timeout |
Returns {"ok": true}.
GET /sessions/{name}/screenshot
Returns the image directly (not JSON).
| Query parameter | Type | Required | Description |
|---|---|---|---|
full_page | bool | No | Capture the full scrollable page; defaults to false |
selector | string | No | Capture only this element |
format | string | No | png (default) / jpeg |
quality | int | No | JPEG quality; defaults to 60 |
curl -H "X-API-Key: np_YOUR_KEY" -o shot.png "http://127.0.0.1:7801/sessions/shop-1/screenshot?full_page=true"
GET /sessions/{name}/snapshot
The current page's accessibility tree as text. Useful for letting an AI read the page structure, or for finding the role and name to pass to type.
{"snapshot": "- heading \"Example Domain\" [level=1]\n- link \"More information...\""}
POST /sessions/{name}/eval
| Parameter | Type | Required | Description |
|---|---|---|---|
expr | string | Yes | JavaScript expression to evaluate in the page (max 2 MB; larger returns 413) |
{"ok": true, "result": "Example Domain"}
// A script error in the page is not an API error:
{"ok": false, "error": "ReferenceError: foo is not defined", "error_type": "Error"}
GET /sessions/{name}/tabs
{"tabs": [{"index": 0, "url": "https://example.com/"}, {"index": 1, "url": "https://example.org/"}], "active": 1}
POST /sessions/{name}/switch_tab
| Parameter | Type | Required | Description |
|---|---|---|---|
index | int | Yes | Tab index (see tabs); subsequent commands act on this tab |
{"index": 0, "url": "https://example.com/"}
Returns 502 if the index doesn't exist.
POST /sessions/{name}/new_tab
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | No | URL to open in the new tab; the new tab becomes the current tab |
{"index": 2, "url": "https://example.net/"}
POST /sessions/{name}/color-scheme
Switches the prefers-color-scheme that web pages see while the browser is running, without restarting and without opening a debugging port. Applies to every open tab and to tabs opened afterwards. Works for a session opened here and for a window launched from the app (/launch). It lasts for this run only and does not change the profile setting launch.colorScheme, which still applies on the next start.
| Parameter | Type | Required | Description |
|---|---|---|---|
scheme | string | Yes | dark / light / system (no emulation; the browser reports the OS appearance) |
{"ok": true, "scheme": "dark", "pages": 2}
Errors: 404; 422 bad_scheme; 409 not_running (no session and no window), force_dark (the profile has "Force dark web pages" on, so only dark is accepted), unreachable, dialog_open (a JavaScript dialog is open on the page, with type / message; see Dialogs. In a window launched from the app the dialog is left for the user to click).
POST /sessions/{name}/dialog
JavaScript dialogs (alert / confirm / prompt / beforeunload) are left open, as in a normal Chrome: in a visible window the user sees the native dialog and can click it. While one is open the page is blocked, so any other command (including screenshot and snapshot) returns 409 right away instead of waiting, and so does a command that opens a dialog itself (for example a click on a "Delete?" button):
{"detail": {"reason": "dialog_open", "type": "confirm", "message": "Delete this post?"}}
Answer it with this endpoint, then retry. A beforeunload ("Leave site?") caused by your own command — goto, a click on a link, a reload from eval — is still accepted automatically, and goto also leaves a page whose "Leave site?" dialog is already open. Closing the session works with a dialog open. tabs and cookie reads keep working.
| Parameter | Type | Required | Description |
|---|---|---|---|
action | string | Yes | accept (OK / Leave) or dismiss (Cancel / Stay) |
prompt_text | string | No | Text to enter in a prompt() before accepting; omitted = the prompt's default value |
curl -X POST http://127.0.0.1:7801/sessions/shop-1/dialog -H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" -d '{"action": "accept"}'
{"ok": true, "action": "accept", "type": "confirm", "message": "Delete this post?"}
Errors: 409 {"reason": "no_dialog"} if no dialog is open (for example the user already clicked it); 422 if action is neither value.
DELETE /sessions/{name}
Closes the session and returns 204. Errors: 404 if the profile doesn't exist; 409 if the session isn't open.
Check-up
GET /profiles/{name}/checkup
Checks whether a profile's fingerprint is internally consistent (OS, GPU, time zone and language vs. exit IP, and more). By default it inspects the saved record only, without opening a browser.
| Query parameter | Type | Required | Description |
|---|---|---|---|
live | bool | No | When true, also does a headless launch to measure the TLS fingerprint and User-Agent and compare them with the baseline (slower) |
{
"name": "shop-1",
"engine": "chromium",
"live": false,
"summary": "pass",
"checks": [
{"id": "os_host", "title": "OS 与宿主一致", "verdict": "pass", "detail": "windows(Phase 0 铁律)"}
]
}
summary and each verdict are one of pass / warn / fail (individual checks can also be skip). title and detail are human-readable text and are currently returned in Chinese. Errors: 404.
Proxies
The proxy pool: add proxies to the pool, then assign them to profiles. You can also set proxy on a profile directly (see PATCH /profiles/{name}).
GET /proxies
Lists the proxy pool, with passwords masked.
[{"id": "9f2c1a7b3d4e5f60", "label": "US residential", "url": "http://user:***@1.2.3.4:8000", "type": "http",
"added": "2026-10-02", "last_check": {"ok": true, "exit_ip": "1.2.3.4", "country": "US", "latency_ms": 420, "at": "…"},
"assigned": 2}]
assigned is the number of profiles using the proxy; last_check is null if it has never been checked.
POST /proxies
Adds one or more proxies and returns 201 with the new entries (same format as above). If any entry is invalid, nothing is saved.
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | One of two | A single proxy address |
urls | string[] | One of two | Multiple proxy addresses |
label | string | No | Label |
curl -X POST http://127.0.0.1:7801/proxies \
-H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"urls": ["http://user:pass@1.2.3.4:8000", "5.6.7.8:1080:user:pass"], "label": "US residential"}'
Errors: 422 no address given or invalid address.
PATCH /proxies/{pid}
| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | No | New label |
url | string | No | New address (changing it clears the last check result) |
Returns the updated entry. Errors: 404; 422 invalid address.
DELETE /proxies/{pid}
Removes a proxy from the pool and returns 204. Errors: 404.
POST /proxies/check
Checks each proxy's exit IP, country, and latency, and also saves the result to last_check.
| Parameter | Type | Required | Description |
|---|---|---|---|
ids | string[] | No | Check only these; omit to check all |
[
{"id": "9f2c1a7b3d4e5f60", "ok": true, "exit_ip": "1.2.3.4", "country": "US", "latency_ms": 420, "geo": {"…": "…"}, "at": "…"},
{"id": "0a1b2c3d4e5f6a7b", "ok": false, "error": "ConnectTimeout: …", "at": "…"}
]
POST /profiles/{name}/assign-proxy
Assigns a proxy from the pool to a profile, replacing its current proxy.
| Parameter | Type | Required | Description |
|---|---|---|---|
proxy_id | string | Yes | The pool entry's id |
Returns the profile's full saved record (including name, proxy_id, and proxy as an object: {"server": "http://1.2.3.4:8000", "username": "…", "password": "…"}; other fields omitted). Note: the response includes the proxy username and password, so don't log it verbatim. Errors: 404 profile or proxy not found; 403 forbidden; 503 needs_network.
Windows Windows only
/windows/tile and /sync/start are available on Windows only; other systems return 501 {"detail": {"reason": "unsupported_platform"}}. /sync/stop returns 200 on every OS. These endpoints apply only to windows opened with Launch, not to sessions.
POST /windows/tile
Tiles these profiles' windows on screen in a grid.
| Parameter | Type | Required | Description |
|---|---|---|---|
names | string[] | Yes | Profiles to tile |
cols | int | No | Number of columns; automatic if omitted |
wait | number | No | Maximum seconds to wait for windows to appear (0–60, default 0). Set it when tiling right after launch-many |
{"placed": ["shop-1", "shop-2"], "not_ready": ["shop-3"], "cols": 2}
not_ready lists profiles whose windows weren't found. Errors: 501; 404; 409 not_running (wait is 0 and the profile isn't running).
POST /sync/start
Starts window sync: mouse and keyboard input in the leader window is mirrored to the other windows.
| Parameter | Type | Required | Description |
|---|---|---|---|
leader | string | Yes | Profile of the leader window |
followers | string[] | No | Profiles that follow |
{"active": true, "leader": "shop-1", "followers": [{"…": "…"}], "paused_reason": null, "events_total": 0}
Errors: 501; 404; 409 sync_active (a sync is already running), not_running, no_cdp, leader window hasn't appeared yet.
POST /sync/stop
Stops window sync and returns {"active": false}. Returns the same when no sync is running and on non-Windows systems (never 501).
Other
GET /version No key
App version, installed kernels, and host OS. Requires no key, so it's handy for checking whether the server is up and which port it's on.
{"app": "…", "daemon": "…", "kernels": {"…": "…"}, "host": {"os": "windows", "…": "…"}, "brand": {"id": "nullprint", "…": "…"}}
Other fields omitted.
GET /license
Current license state and plan usage.
{"state": "valid", "plan_name": "…", "profile_limit": 50, "used": 12, "expires_at": "…", "days_left": 20, "role": "…"}
state is valid when everything is in order; other values include unlicensed (not logged in) and plan_expired. Other fields omitted.
POST /api-key/reset
Generates a new key and invalidates the old one immediately (scripts still using it get 401 invalid_api_key). The new key is also written to daemon.json.
{"api_key": "np_…"}
Errors: 500 api_key_write_failed (the file couldn't be written; the old key stays valid).
Full examples
Launch in bulk → tile → open a page in each → export cookies → stop. After launching, poll GET /profiles/{name} for cdp_endpoint, then connect with Playwright — turn on external debugging for these profiles first (needs a second verification); profiles without it report cdp_unavailable and the example stops, see cdp_endpoint. Tiling is Windows-only; other systems return 501, which the examples skip.
Python (requests + playwright)
import base64, time, requests
from playwright.sync_api import sync_playwright
BASE, KEY = "http://127.0.0.1:7801", "np_YOUR_KEY"
H = {"X-API-Key": KEY}
NAMES = ["shop-1", "shop-2", "shop-3"]
def api(method, path, **kw):
r = requests.request(method, f"{BASE}{path}", headers=H, timeout=120, **kw)
r.raise_for_status()
return r.json() if r.content else None
def wait_cdp(name, seconds=60):
for _ in range(seconds): # wait for the window and its CDP endpoint
p = api("GET", f"/profiles/{name}")
if p["status"] == "running" and p.get("cdp_endpoint"):
return p["cdp_endpoint"]
if p.get("cdp_unavailable") not in (None, "starting", "not_running"):
raise RuntimeError(f"{name}: {p['cdp_hint']}") # e.g. external debugging is off: waiting won't help
time.sleep(1)
raise RuntimeError(f"{name}: no cdp_endpoint within {seconds} seconds")
# 1. Launch in bulk (returns immediately; max = count opens them all at once)
api("POST", "/launch-many", json={"names": NAMES, "max": len(NAMES)})
# 2. Tile (Windows only; wait up to 30 seconds for windows to appear)
r = requests.post(f"{BASE}/windows/tile", json={"names": NAMES, "wait": 30}, headers=H, timeout=120)
print("Tile:", r.json() if r.ok else f"skipped ({r.status_code})")
# 3. Open a page in each
with sync_playwright() as pw:
for n in NAMES:
browser = pw.chromium.connect_over_cdp(wait_cdp(n))
page = browser.contexts[0].pages[0]
page.goto("https://example.com")
print(n, page.title())
# 4. Export cookies (as one zip)
z = api("POST", "/cookies/export-batch", json={"names": NAMES, "format": "json"})
with open(z["filename"], "wb") as f:
f.write(base64.b64decode(z["zip_base64"]))
print("Exported:", z["ok"], "Failed:", z["failed"])
# 5. Stop
for n in NAMES:
print(api("POST", f"/profiles/{n}/stop"))
Node (fetch + playwright)
Node 18+. Run npm i playwright, save as batch.mjs, and run it.
import { chromium } from "playwright";
import { writeFileSync } from "node:fs";
const BASE = "http://127.0.0.1:7801", KEY = "np_YOUR_KEY";
const NAMES = ["shop-1", "shop-2", "shop-3"];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(method, path, body) {
const r = await fetch(BASE + path, {
method,
headers: { "X-API-Key": KEY, "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!r.ok) throw Object.assign(new Error(`${method} ${path} → ${r.status} ${await r.text()}`), { status: r.status });
return r.status === 204 ? null : r.json();
}
async function waitCdp(name, seconds = 60) {
for (let i = 0; i < seconds; i++) { // wait for the window and its CDP endpoint
const p = await api("GET", `/profiles/${name}`);
if (p.status === "running" && p.cdp_endpoint) return p.cdp_endpoint;
if (![undefined, "starting", "not_running"].includes(p.cdp_unavailable)) throw new Error(`${name}: ${p.cdp_hint}`); // e.g. external debugging is off: waiting won't help
await sleep(1000);
}
throw new Error(`${name}: no cdp_endpoint within ${seconds} seconds`);
}
// 1. Launch in bulk
await api("POST", "/launch-many", { names: NAMES, max: NAMES.length });
// 2. Tile (Windows only; other systems return 501, so skip)
try {
console.log("Tile:", await api("POST", "/windows/tile", { names: NAMES, wait: 30 }));
} catch (e) {
if (e.status !== 501) throw e;
console.log("Tile: skipped (not Windows)");
}
// 3. Open a page in each
for (const n of NAMES) {
const browser = await chromium.connectOverCDP(await waitCdp(n));
const page = browser.contexts()[0].pages()[0];
await page.goto("https://example.com");
console.log(n, await page.title());
await browser.close(); // disconnects only; the browser stays open
}
// 4. Export cookies
const z = await api("POST", "/cookies/export-batch", { names: NAMES, format: "json" });
writeFileSync(z.filename, Buffer.from(z.zip_base64, "base64"));
console.log("Exported:", z.ok, "Failed:", z.failed);
// 5. Stop
for (const n of NAMES) console.log(await api("POST", `/profiles/${n}/stop`));
Stability & changelog
The endpoints, parameters, and fields documented on this page are backward compatible: we only add, never rename or remove. Any unavoidable breaking change will be announced here in advance. The app also has internal endpoints not listed here; they can change at any time, so don't depend on them.
Changelog
- 2026-10, initial release: fixed port 7801 (falls back up to 7810 if taken), API key authentication, and this page.