Nullprint

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

  1. Install the Nullprint app and log in. The Local API is available whenever the app is running.
  2. 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_).
  3. 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 returns 401 {"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.

StatusMeaning
401Missing or invalid key (api_key_required / invalid_api_key)
403Not 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)
404Profile or proxy not found
409State conflict: profile already running or not running, kernel not installed, GeoIP database still downloading, session not open, etc.
422Invalid parameters: a bad value, or FastAPI field validation failed
429Too many sessions open at once
501Not supported on this OS (window tiling and window sync are Windows-only)
502A browser action inside a session failed (e.g. element not found, timeout); detail describes the error
503Network access required but unavailable (writes on team accounts, needs_network)

Concurrency limits

  • max on POST /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, set max to 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": "…"}
  }
]
FieldDescription
nameInternal ID; the {name} used in other endpoint paths
statusrunning / idle (running if either a launched window or a session is open)
engine / kernelBrowser engine and Chrome major version
proxyProxy as scheme://host:port (credentials stripped); null if none
last_errorDetails of the most recent failed launch (at / code / error), or null
metaDisplay 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, "…": "…"}
}
FieldDescription
statusrunning / idle
cdp_endpointThe 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
proxyProxy as scheme://host:port (credentials stripped)
metaDisplay 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.

ParameterTypeRequiredDescription
namestringYesDisplay name (need not be unique). The internal ID is derived from it and guaranteed unique, so always use the name in the response
proxystringNoProxy address, e.g. http://user:pass@host:port, socks5://host:port, host:port:user:pass
enginestringNoBrowser engine; defaults to chromium
kernelstringNoChrome major version, e.g. "148"; defaults to the one bundled with the app
osstringNowindows / macos / linux; defaults to the host OS
notestringNoNote
tagsstring[]NoTags
groupstringNoGroup name
urlsstring[]NoURLs to open on launch
launchobjectNoLaunch 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.

ParameterTypeRequiredDescription
labelstringNoDisplay name; "" reverts to showing the internal ID
groupstringNoGroup name
tagsstring[]NoTags (replaces the whole list)
notestringNoNote
proxystringNoProxy address; "" removes the proxy
urlsstring[]NoURLs to open on launch (replaces the whole list)
launchobjectNoLaunch 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).

ParameterTypeRequiredDescription
headlessboolNoRun headless; defaults to false
urlstringNoURL 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.

ParameterTypeRequiredDescription
namesstring[]YesProfiles to launch
maxintNoMaximum windows open at once; defaults to 5. A slot frees up only when a window is closed
headlessboolNoRun 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_endpoint on GET /profiles/{name}. Launching is asynchronous, so it starts out null; poll until status == "running" and cdp_endpoint is set. See Quickstart.
  • Sessions: the responses from POST /sessions/{name} and GET /sessions include cdp_endpoint directly.

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.

ParameterTypeRequiredDescription
headlessboolNoRun headless; defaults to false (visible window)
idle_timeoutintNoSeconds 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

ParameterTypeRequiredDescription
urlstringYesURL 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

ParameterTypeRequiredDescription
selectorstringYesCSS / Playwright selector
timeoutintNoMilliseconds to wait for the element

Returns {"ok": true}.

POST /sessions/{name}/fill_selector

ParameterTypeRequiredDescription
selectorstringYesInput field selector
valuestringYesText to fill in (replaces existing content)
submitboolNoPress Enter after filling; defaults to false
timeoutintNoMilliseconds 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).

ParameterTypeRequiredDescription
rolestringYesRole, e.g. textbox / searchbox
namestringYesThe element's accessible name (usually its label text or placeholder)
textstringYesText to fill in
submitboolNoPress Enter after filling; defaults to false

Returns {"ok": true}. You can find roles and names in the snapshot output.

POST /sessions/{name}/press

ParameterTypeRequiredDescription
keystringYesKey name, e.g. Enter, Tab, Control+A

Returns {"ok": true}.

POST /sessions/{name}/scroll

ParameterTypeRequiredDescription
dynumberYesVertical scroll in pixels (positive scrolls down)
dxnumberNoHorizontal 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).

ParameterTypeRequiredDescription
selectorstringOne of threeWait for the element to appear
textstringOne of threeWait for this text to appear on the page
urlstringOne of threeWait for the URL to match (wildcards supported, e.g. **/dashboard)
timeoutintNoMilliseconds; defaults to 10000. Returns 502 on timeout

Returns {"ok": true}.

GET /sessions/{name}/screenshot

Returns the image directly (not JSON).

Query parameterTypeRequiredDescription
full_pageboolNoCapture the full scrollable page; defaults to false
selectorstringNoCapture only this element
formatstringNopng (default) / jpeg
qualityintNoJPEG 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

ParameterTypeRequiredDescription
exprstringYesJavaScript 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

ParameterTypeRequiredDescription
indexintYesTab 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

ParameterTypeRequiredDescription
urlstringNoURL 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 parameterTypeRequiredDescription
liveboolNoWhen 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.

Cookies

Exports all of a profile's cookies, values included. If the profile is running, cookies are read straight from the browser; if not, the app briefly opens it headless in the background, reads them, and closes it.

Query parameterTypeRequiredDescription
formatstringNojson (default) / netscape
curl -H "X-API-Key: np_YOUR_KEY" "http://127.0.0.1:7801/profiles/shop-1/cookies/export?format=json"
{
  "name": "shop-1",
  "format": "json",
  "filename": "shop-1-cookies-20261002-0930.json",
  "count": 42,
  "text": "[{\"name\": \"session-id\", \"value\": \"…\", \"domain\": \".amazon.com\", \"path\": \"/\", …}]"
}

text is the file content; save it as filename. Errors: 422 invalid format; 404; 403 forbidden (no export permission); 409 kernel_missing, read_failed, launch_failed, no_cdp.

Exports cookies for several profiles as a single zip. A failure on one profile doesn't affect the others; each failed profile gets a <name>.error.txt in the zip.

ParameterTypeRequiredDescription
namesstring[]YesProfiles to export
formatstringNojson (default) / netscape
{
  "filename": "cookies-20261002-0930.zip",
  "zip_base64": "UEsDBBQAAAAIA…",
  "ok": ["shop-1", "shop-2"],
  "failed": [{"name": "shop-3", "reason": "forbidden"}]
}

Base64-decode zip_base64 and write it to a file. Errors: 422 invalid format.

Imports cookies. Imported cookies are queued and injected into the browser the next time the profile launches (or opens a session).

ParameterTypeRequiredDescription
textstringYesCookie text: JSON, Netscape format, or name=value; …
formatstringNojson / netscape / namevalue; auto-detected if omitted
domainstringNoRequired for namevalue; also applied to JSON entries missing a domain
modestringNomerge (default, adds to the queue) / replace (replaces the queue)
curl -X POST http://127.0.0.1:7801/profiles/shop-1/cookies/import \
  -H "X-API-Key: np_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"text": "session-id=abc; ubid-main=xyz", "domain": ".amazon.com"}'
{"name": "shop-1", "imported": 2, "pending": 2}

imported is the number of cookies parsed from this request; pending is the total queued for injection. Errors: 422 unparseable text or invalid mode; 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.

ParameterTypeRequiredDescription
urlstringOne of twoA single proxy address
urlsstring[]One of twoMultiple proxy addresses
labelstringNoLabel
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}

ParameterTypeRequiredDescription
labelstringNoNew label
urlstringNoNew 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.

ParameterTypeRequiredDescription
idsstring[]NoCheck 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.

ParameterTypeRequiredDescription
proxy_idstringYesThe 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.

ParameterTypeRequiredDescription
namesstring[]YesProfiles to tile
colsintNoNumber of columns; automatic if omitted
waitnumberNoMaximum 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.

ParameterTypeRequiredDescription
leaderstringYesProfile of the leader window
followersstring[]NoProfiles 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.