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
- 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
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
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;
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, and the response includes cdp_endpoint too.
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. If you can't set headers, use the query parameter?api_key=<Key>instead. - 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. It's null when the profile isn't running or hasn't finished starting, so poll this endpoint after launching until it's set |
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:[email protected]: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 [email protected]", "tags": ["amazon", "us"]}'
{"name": "shop-1", "proxy": "http://1.2.3.4:8000", "meta": {"note": "Linked to [email protected]", "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}
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
Every running Chrome-engine profile exposes a local debugging port. 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.
By default the debugging port is randomized on every launch. 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)
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); 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/"}
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:[email protected]: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. 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"]
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;
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.