API — License, access & certificate
Check license status, manage authentication, read overall system status, and download the root certificate over the API.
License
/api/v1/license/statusAuth requiredReturns whether the license is active, along with your plan, port-pool entitlement (pool) and Challenge Breaker process limits. It does not carry a port limit: the binding ceiling is the max_ports field of GET /api/v1/status.
curl -H "X-API-Key: YOUR_API_KEY" http://127.0.0.1:8891/api/v1/license/status{
"activated": true,
"email": "[email protected]",
"plan": "pro",
"period_end": "2027-02-15T00:00:00Z",
"pool": true,
"js_solver_max_procs": 4,
"js_solver_procs": 4,
"js_solver_live_procs": 1
}- The label and allowed_domains fields are sent ONLY for a service (promo) tariff, which is why the example above has neither: label is a suffix for the plan name, rendered in the dashboard as “Pro (WB)”; allowed_domains is the list of domains that tariff is restricted to, shown under the plan name as “Restricted to: …”.
- 🔴 allowed_domains is a rule, not a hint: while the list is non-empty, the port routes ONLY to those domains. A bare entry covers the domain itself and all of its subdomains; an entry prefixed with “=” matches that exact name only; a bare IP address NEVER matches. An empty list (the field absent) means no restriction.
- 🔴 The refusal comes from the PROXY PORT itself, not from this API: HTTP CONNECT and plain HTTP get 403 Forbidden with an empty body — no JSON, no error field; SOCKS5 gets reply code 0x02 “connection not allowed by ruleset”; UDP ASSOCIATE is not granted at all under such a tariff. The upstream proxy is not at fault — changing the egress will not help; check the address against the list.
- Challenge Breaker does not run for a host outside the list. The API handles, the port test and the rest of the dashboard behave as usual.
Authentication
The password here is the dashboard's, not the API's: API requests are authenticated by the key in the X-API-Key header and need no session cookie. These endpoints serve the interface and first-setup scripts.
/api/v1/auth/loginNo authSigns in to the dashboard with a password; sets a session cookie.
| Parameter | Type | Required | Description |
|---|---|---|---|
password | string | Yes | The dashboard password. |
{ "password": "your-dashboard-password" }curl -X POST -H "Content-Type: application/json" -c cookies.txt \
-d '{"password":"your-dashboard-password"}' \
http://127.0.0.1:8891/api/v1/auth/login{
"status": "ok"
}- 401 “invalid password”; 429 “too many attempts — try again later” after a run of failures from one address.
- 501 “auth not configured” if dashboard sign-in is not configured in this build.
/api/v1/auth/logoutAuth requiredClears the dashboard session cookie.
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/auth/logout{
"status": "ok"
}/api/v1/auth/statusAuth requiredThe state of sign-in and password: whether you are signed in, whether a password is set, and whether it is still the initial one.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/auth/status{
"authenticated": true,
"password_is_initial": false,
"password_set": true
}- password_is_initial=true means the password is still the one issued at install; that is a reason to demand a change.
/api/v1/auth/passwordAuth requiredChanges the dashboard password. The current password is required.
| Parameter | Type | Required | Description |
|---|---|---|---|
current_password | string | Yes | The current password. 🔴 The field is called current_password, not current. |
new_password | string | Yes | The new password, at least 8 characters. 🔴 The field is called new_password, not new. |
{ "current_password": "old-password", "new_password": "new-password" }curl -X POST -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"current_password":"old-password","new_password":"new-password"}' \
http://127.0.0.1:8891/api/v1/auth/password{
"status": "ok"
}- 🔴 A body with the names current and new decodes into two empty strings, and the endpoint answers 401 “current password is incorrect” — as if the password were wrong rather than the field name.
- 400 “password must be at least 8 characters” for a new password that is too short.
- 501 “auth not configured” if dashboard sign-in is not configured in this build.
/api/v1/auth/apikey/rotateAuth requiredIssues a new API key and returns it. The previous one stops working at once.
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/auth/apikey/rotate{
"api_key": "a1b2c3d4e5f6a7b8c9d0"
}- The request goes with the OLD key and the response carries the new one — there is no other occasion to learn it.
- 500 “could not rotate key” if the new key could not be stored; 501 if sign-in is not configured.
System status
/api/v1/statusAuth requiredReturns overall status: version, open and maximum ports (max_ports is the binding ceiling — the lower of the configured maximum and your license limit), identity counts, uptime and more.
curl -H "X-API-Key: YOUR_API_KEY" http://127.0.0.1:8891/api/v1/status{
"version": "1.3.2",
"commit": "a1b2c3d",
"commit_time": "2026-09-01T10:00:00Z",
"open_ports": 2,
"max_ports": 1000,
"idle_timeout_seconds": 1800,
"ports": [
"…"
],
"profile_count": 143900,
"profile_counts": {
"chrome_152+windows": 41000,
"chrome_152+macos": 22000,
"firefox_152+windows": 17400,
"safari_18+ios": 9800
},
"uptime": "3d14h22m",
"domain_routing": {
"domains": [],
"proxy": ""
},
"domain_rules_count": 3,
"pack_loaded": true,
"solver_bundle_state": "ready",
"solver_bundle_total": 1,
"vision_bundle_state": "ready",
"vision_bundle_total": 1,
"font_bundle_state": "ready",
"font_bundle_total": 1,
"build_state": "ok",
"device_bound": true
}{
"pack_loaded": true,
"solver_bundle_state": "downloading",
"vision_bundle_state": "dormant",
"font_bundle_state": "failed",
"font_bundle_reason": "macos: connection refused",
"build_state": "mismatch",
"build_reason": "the program file does not match the published build — please reinstall",
"device_bound": true,
"device_rebound_at": "2026-09-01T10:00:00Z"
}- The first example is the HEALTHY outcome. solver_bundle_state, vision_bundle_state and font_bundle_state each carry one of four values: dormant — no download has ever started (normal for vision: the models are fetched the first time a captcha is met), downloading — in progress, ready — installed, failed — the attempt broke.
- 🔴 Do not write a readiness check as “equals ready, everything else is broken”: a fresh install reports downloading first, and such a script cannot tell “still downloading” from “failed”.
- Every state has a paired reason field — solver_bundle_reason, vision_bundle_reason, font_bundle_reason: a short, secret-free sentence. It is absent while the state is ready or dormant. font_bundle_reason names EVERY failed OS (macos: connection refused; windows: 404), because the state itself is the worst of the per-OS pools.
- 🔴 vision_bundle_state ready together with a NON-EMPTY vision_bundle_reason is not health: the bundle is downloaded, but the content key could not be fetched (a licence with no solver entitlement, an outdated token, Cabinet unreachable) and reCAPTCHA silently never solves.
- pack_loaded false means the real fingerprint recipes are not loaded; pack_reason arrives beside it and says why (not yet attempted, Cabinet unreachable). An empty reason with false means the licence carries no pack entitlement.
- build_state compares the program FILE with the build the licensing server published. Values: ok, mismatch, unregistered. 🔴 The field may be missing from the response entirely — that means “no signal yet” and must NOT be read as ok. With mismatch and unregistered, build_reason carries the ready-to-show sentence: mismatch — the file was mangled or patched and needs a reinstall; unregistered — this build was never published by the licensing server, so reinstalling the same release will not help.
- device_bound false means the installation is not claimed by its device key. device_rebound_at (the last re-binding time) is present only if a re-binding has happened.
/api/v1/healthNo authA simple health check that always returns OK.
curl http://127.0.0.1:8891/api/v1/health
{
"status": "ok"
}
Exactly four paths are open without a key: this health check, POST /api/v1/auth/login, GET /crl (the revocation list is fetched by the OS certificate stack, not by a person) and GET /export/{token}/… (where the token in the address is the access). Everything else answers 401. The health check is what a monitor or a container shell uses to see that the process is alive; it says nothing about the state of the ports or the license.
The root certificate (CA)
The application opens TLS with a root certificate of its own, and the system must trust it — otherwise the browser shows a certificate error on every site. Three endpoints hand out the certificate in three shapes, and a fourth installs it.
/api/v1/caAuth requiredReturns the root certificate as PEM text.
curl -H "X-API-Key: YOUR_API_KEY" -o blanktrail-ca.pem \
http://127.0.0.1:8891/api/v1/ca- 404 “CA certificate path not configured” if the certificate path is unset; 500 “failed to read CA certificate” if the file cannot be read. No PEM parsing happens here — the file is served as it is; the “not PEM” refusal belongs to the endpoints that need the certificate parsed, /ca.crt and /ca.mobileconfig.
/api/v1/ca.crtAuth requiredThe same certificate in binary DER — the format the macOS and iOS system installer understands.
curl -H "X-API-Key: YOUR_API_KEY" -o blanktrail-ca.crt \
http://127.0.0.1:8891/api/v1/ca.crt/api/v1/ca.mobileconfigAuth requiredAn Apple .mobileconfig profile: opened on the device, it installs the certificate itself.
curl -H "X-API-Key: YOUR_API_KEY" -o blanktrail.mobileconfig \
http://127.0.0.1:8891/api/v1/ca.mobileconfig- On an iPhone installing the profile is not enough: the certificate must also be MARKED as trusted in Settings → General → About → Certificate Trust Settings.
/api/v1/ca/installAuth requiredInstalls the root certificate into the user's trusted-root store.
curl -X POST -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/ca/install{
"status": "installed"
}- Windows only: on other systems it answers 501 and the certificate must be downloaded and added by hand.
- After the install the browser must be restarted COMPLETELY — every window closed, not just a new one opened: it reads the trust store at start.
- 500 “install failed: …” if the system store refused the certificate.
Challenge Breaker
These endpoints show what the challenge solver is busy with right now and how much of the machine it is allowed to take. They are available on any plan: without Challenge Breaker the solve counters stay at zero, but the challenges encountered are still visible — that is how you learn what your traffic ran into.
/api/v1/solver/statsAuth requiredCounters since start: attempts and successes by challenge family, the challenges seen, and the resources taken.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/solver/stats{
"families": [ { "family": "recaptcha", "attempts": 41, "solved": 38 } ],
"total_attempts": 41,
"total_solved": 38,
"since_start": true,
"resources": { "live_procs": 2, "busy_procs": 1, "cap": 4,
"constrained": false, "reason": "", "last_error": "",
"launch_failures": 0, "proc_deaths": 0, "mem_recycles": 0 },
"seen": { "total": 47, "since_unix_ms": 1757116800000,
"vendors": [ { "vendor": "recaptcha", "count": 41 },
{ "vendor": "turnstile", "count": 6 } ] }
}- 🔴 On a plan WITHOUT Challenge Breaker only seen stays non-empty: there were no solves, but there were challenges. That is what the field is for.
/api/v1/solver/queueAuth requiredThe live queue: how many are waiting and how many are being solved right now, the queue cap, and one row per request.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/solver/queue{
"queued": 2,
"running": 1,
"max_len": 16,
"items": [ { "port": 20134, "host": "example.com", "vendor": "recaptcha",
"class": "chrome-152-win", "state": "solving",
"waiters": 1, "age_ms": 4120 } ]
}- A separate endpoint rather than a field in /solver/stats: the queue changes orders of magnitude faster than the counters, and the panel polls it at its own pace. There is no queue control here — reading only.
/api/v1/solver/procsAuth requiredSets how many solver processes run at once. It applies with no restart and survives one.
| Parameter | Type | Required | Description |
|---|---|---|---|
procs | int | Yes | The number of processes; 0 turns solving off. |
{ "procs": 4 }curl -X PUT -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"procs":4}' \
http://127.0.0.1:8891/api/v1/solver/procs{
"js_solver_procs": 4
}- 400 “procs must be >= 0”; 409 if more is asked than the license allows — the cap is visible as js_solver_max_procs in the response of GET /api/v1/license/status.
- When no value is set, the license cap applies: allocating processes by hand is not required.
Settings and keys
/api/v1/settings/networkAuth requiredThe network settings: whether the proxy ports accept connections from the local network, whether they demand a login, and at which address the certificate revocation list is visible.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/settings/network{
"allow_lan": false,
"crl_public_host": "",
"proxy_auth_enabled": true,
"proxy_auth_user": "proxy"
}- The proxy password is never returned in the response — it can only be set.
/api/v1/settings/networkAuth requiredChanges the network settings. Send only what changes: a field absent from the body stays as it was.
| Parameter | Type | Required | Description |
|---|---|---|---|
allow_lan | bool | No | Accept connections to the proxy ports from the local network, not only from this machine. |
crl_public_host | string | No | The address at which the certificate revocation list is visible from ANOTHER machine: host or host:port, with no scheme and no path. Loopback is refused — the address must be one this machine is reachable at from outside. |
proxy_auth_enabled | bool | No | Demand a login and password on the proxy ports. |
proxy_auth_user | string | No | The proxy login. |
proxy_auth_pass | string | No | The proxy password. |
{ "allow_lan": true, "proxy_auth_enabled": true,
"proxy_auth_user": "proxy", "proxy_auth_pass": "…" }curl -X PUT -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"allow_lan":true}' \
http://127.0.0.1:8891/api/v1/settings/network{
"allow_lan": true,
"crl_public_host": "",
"proxy_auth_enabled": true,
"proxy_auth_user": "proxy"
}- 400 “proxy_auth_user and proxy_auth_pass are required to enable proxy auth”: the check cannot be turned on without both.
- 400 with an explanation for an invalid crl_public_host; 503 “auth store not configured”. The body is capped at 8 KiB.
/api/v1/auth/apikeyAuth requiredReturns the API key — the one that travels in the X-API-Key header.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/auth/apikey{
"api_key": "a1b2c3d4e5f6a7b8c9d0"
}- The response is empty when no retrievable copy of the key exists: a key stored only as a hash cannot be shown.
/api/v1/auth/apikeyAuth requiredSets an API key of your own instead of the generated one. The previous one stops working at once.
| Parameter | Type | Required | Description |
|---|---|---|---|
api_key | string | Yes | 12–128 printable ASCII characters with no spaces: the key travels in the header verbatim. |
{ "api_key": "my-own-api-key-2026" }curl -X PUT -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"api_key":"my-own-api-key-2026"}' \
http://127.0.0.1:8891/api/v1/auth/apikey{
"api_key": "my-own-api-key-2026"
}- 400 “api key must be 12-128 characters” or “api key must be printable ASCII without spaces”. The body is capped at 8 KiB.
/api/v1/settings/integration-keyAuth requiredReports whether an integration key is stored — it is what activates a license on an integrator's behalf.
curl -H "X-API-Key: YOUR_API_KEY" \
http://127.0.0.1:8891/api/v1/settings/integration-key{
"integration_key": "",
"set": false
}/api/v1/settings/integration-keyAuth requiredStores or clears the integration key. It takes effect with no restart.
| Parameter | Type | Required | Description |
|---|---|---|---|
integration_key | string | Yes | The key, up to 512 characters. An empty value clears the stored one. |
{ "integration_key": "…" }curl -X PUT -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"integration_key":""}' \
http://127.0.0.1:8891/api/v1/settings/integration-key{
"ok": true
}- 400 “integration_key must be at most 512 characters”; 503 “auth store not configured”.
Activation from code
Activation usually happens in the dashboard, but it can be done by request too — while provisioning a machine with a script, say. The credentials go straight to the Cabinet; the application does not store them, only the signed license.
/api/v1/license/enrollAuth requiredStarts activation with a blanktrail.com account.
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The account email. |
password | string | Yes | The account password. |
{ "email": "[email protected]", "password": "…" }curl -X POST -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"…"}' \
http://127.0.0.1:8891/api/v1/license/enroll{
"choose": false,
"activated": true,
"plan": "pro"
}- If the account holds several licenses the answer comes with choose=true, a list and a one-off ticket — the choice is made with the next endpoint.
- 400 “email and password are required”; 502 with the status inside the body if the Cabinet is unreachable; 503 “license manager not configured”.
/api/v1/license/enroll/confirmAuth requiredChooses the license when the account holds several.
| Parameter | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | The one-off ticket from the previous response. |
license_id | int | Yes | The id of the chosen license, from the same response. |
{ "ticket": "…", "license_id": 42 }curl -X POST -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"ticket":"…","license_id":42}' \
http://127.0.0.1:8891/api/v1/license/enroll/confirm{
"activated": true,
"plan": "pro"
}- 400 “ticket and license_id are required”; 502 if the Cabinet is unreachable.
/api/v1/auth/onboard-passwordAuth requiredSets the dashboard password on the first screen — by the one-off bootstrap_token returned by POST /api/v1/auth/onboard-activate, not by a previous password.
| Parameter | Type | Required | Description |
|---|---|---|---|
bootstrap_token | string | Yes | The one-off first-run token, taken from the POST /api/v1/auth/onboard-activate response. Single use, valid 15 minutes. |
new_password | string | Yes | The new password, at least 8 characters. |
{ "bootstrap_token": "…", "new_password": "new-password" }curl -X POST -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"bootstrap_token":"…","new_password":"new-password"}' \
http://127.0.0.1:8891/api/v1/auth/onboard-password{
"status": "ok"
}- 401 “invalid or expired activation token”; 400 with the password rule if it is shorter than eight characters; 501 if sign-in is not configured.
/api/v1/auth/onboard-activateAuth requiredActivates the license on that same first screen, without signing in to the dashboard separately.
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The account email. |
password | string | Yes | The account password. |
ticket | string | No | The license-choice ticket when there are several. |
license_id | int | No | The id of the chosen license. |
{ "email": "[email protected]", "password": "…" }curl -X POST -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"…"}' \
http://127.0.0.1:8891/api/v1/auth/onboard-activate{
"activated": true,
"plan": "pro",
"bootstrap_token": "…"
}- bootstrap_token is returned only here, and only when activated is true. It is single-use and expires in 15 minutes; keep it and pass it to POST /api/v1/auth/onboard-password. There is no other source — the installer does not issue it.
- 400 “email and password are required”; 429 after a run of attempts from one address; 502 if the Cabinet is unreachable; 503 “license manager not configured”.