The Kalkan Local API lets your own scripts manage profiles, proxies and folders, start and stop browsers and drive them with Playwright, Puppeteer or Selenium. It is a REST API served by the running Kalkan desktop app on your own computer:
http://127.0.0.1:50360/api/v1
All requests and responses are JSON. The API is part of the Starter, Pro and Unlimited plans (not the trial).
Enable it and get your key
- Open Kalkan → Settings → API and turn the API on.
- Change the port if 50360 is taken on your computer.
- Copy the API key (it starts with
kk_). Regenerate creates a new key and the old one stops working immediately.
The API is available while the Kalkan app is open. For servers and scripts without a window there is also the developer command adb api, which serves the same API.
Authentication and security
- Send the key with every request in the
X-Token: <key>header.Authorization: Bearer <key>is accepted too. - A missing or wrong key returns
401 unauthorized. After 10 wrong keys within a minute the API answers429 lockedfor 60 seconds. - The API only listens on
127.0.0.1and only answers requests addressed to127.0.0.1orlocalhost(otherwise403 forbidden_host). It cannot be reached from other computers. - It is meant for scripts, not web pages: requests that carry an
Originheader (sent by browsers) are refused with403 forbidden_origin, and there is no CORS. - The key is the only secret. Do not commit it to code repositories; if it leaks, regenerate it.
- Without the API in your plan every call returns
403 plan.GET /statusalways works and tells you inapi_allowedwhether your plan includes it.
Responses and errors
A successful call returns {"ok": true, "data": …}; a failed one returns {"ok": false, "error": {"code": "…", "message": "…"}}.
{"ok": true, "data": {"id": "…", "running": true, "ws": "ws://127.0.0.1:49731/devtools/browser/…", "debug_port": 49731, "pid": 12345}}
{"ok": false, "error": {"code": "unauthorized", "message": "missing or wrong API key (send it as the X-Token header)"}}
| Status | Codes | Meaning |
|---|---|---|
| 200 / 201 / 202 | Done / created / started in the background (wait: false) | |
| 400 | bad_request | Missing or invalid field |
| 401 | unauthorized | Missing or wrong API key |
| 403 | plan, forbidden_host, forbidden_origin | Plan without the API, wrong Host, request from a web page |
| 404 / 405 | not_found, method_not_allowed | No such profile / proxy / endpoint, or wrong HTTP method |
| 409 | running, direct, ledger, cross_os | The profile is open, or the launch needs a decision. direct: the profile has no proxy (repeat with allow_direct: true). ledger: the proxy's exit IP was recently used by other profiles; cross_os: the profile's OS does not suit this engine on this computer (repeat with force: true). The message explains the case. |
| 429 | locked | Too many wrong keys, wait 60 seconds |
| 500 | internal, launch_failed | Unexpected error, or the browser could not start |
| 504 | timeout | The browser did not come up within timeout |
Engines
Kalkan has four engines: Kalkan Native (recommended; our own Chromium with 150+ C++ patches that produce the fingerprint in its code; the app installs and updates it in the background), Kalkan (Chrome patch: your real Google Chrome with a patched driver), Kalkan Edge (the same layer on your installed Microsoft Edge; "engine": "edge" when creating) and Firefox (Windows, macOS and Linux profiles). Kalkan, Kalkan Edge and Kalkan Native profiles start with a DevTools (CDP) endpoint for automation; Firefox profiles start without one (ws is null).
Endpoints
All paths are relative to http://127.0.0.1:50360/api/v1.
| Request | What it does |
|---|---|
GET /status | App and API state: app, version, api, api_allowed, plan, running (number of open profiles). |
GET /profiles | Lists profiles. Filters: folder, q (search), tag, engine (the engine value a profile reports in this list), proxy (yes | no), status (running | used | new | frozen). Sorting: sort (name | created | last_used | folder | engine | proxy), desc=1. Paging: page, page_size (default 100, max 500). Returns {total, page, page_size, items}. |
POST /profiles | Creates a profile (201). Fields: name (required), engine, os (windows | macos | linux), mode (native | balanced | maximum | gmail | custom; balanced is recommended), folder, tags, notes, start_url, color. Proxy: an object {type, host, port, username, password} (type: http | https | socks5), or a URL such as "socks5://host:port" with proxy_user / proxy_pass, or pool_id (a saved proxy id, or "auto"). |
GET /profiles/{id} | One profile with all its details. Proxy passwords are never returned. |
PATCH /profiles/{id} | Edits name, notes, tags, color, start_url, folder, allow_direct, frozen, extensions, bookmarks, proxy (+ proxy_user, proxy_pass). Fingerprint and device fields cannot be changed (400). |
DELETE /profiles/{id} | Deletes the profile (409 while it is open). |
POST /profiles/{id}/clone | Copies the profile, optionally with a new name (201). |
POST /profiles/{id}/start | Opens the profile. Optional: url, allow_direct, force, cdp (default true), wait (default true), timeout in seconds (5–180, default 60). Returns id, name, engine, running, already_running, ws, cdp, debug_port, http, pid. With wait: false it answers 202 {starting: true} at once. |
POST /profiles/{id}/stop | Closes the profile: {stopped}. |
GET /profiles/{id}/status | running, ws, cdp, debug_port, warnings, errors. |
GET /profiles/{id}/cookies | Exports cookies, ?format=json (default) or netscape. The profile must be closed (otherwise 409). |
POST /profiles/{id}/cookies | Imports {cookies: …} as a JSON list or Netscape text and returns the number imported. The profile must be closed. |
PUT /profiles/{id}/proxy | Sets the proxy: {proxy_id} for a saved proxy, {proxy: {…}} for a new one, or {proxy: null} to remove it. |
GET /running | Open profiles: [{id, engine, cdp, debug_port, pid}]. |
POST /stop-all | Closes every open profile: {stopped: [ids]}. |
GET /foldersPOST /folders | Lists folders / creates one with {name, color?} (201). |
GET /proxies | Saved proxies (passwords are never returned). |
POST /proxies | Adds proxies (201): {text: "…"} with one proxy per line, or {proxies: [...], scheme?, label?}. |
DELETE /proxies/{id} | Deletes a saved proxy. One linked to profiles is deleted only with ?force=true (those profiles are left without a proxy). |
POST /proxies/{id}/check | Tests the proxy: exit IP, country, type, latency, what detection sites can see, and reputation. |
Examples: curl
In PowerShell type curl.exe, because curl there is an alias of Invoke-WebRequest. JSON bodies are easiest to send from Git Bash, cmd or a script.
curl -H "X-Token: kk_..." http://127.0.0.1:50360/api/v1/status
curl -H "X-Token: kk_..." "http://127.0.0.1:50360/api/v1/profiles?status=running&sort=last_used&desc=1"
curl -X POST -H "X-Token: kk_..." -H "Content-Type: application/json" \
-d '{"name": "shop-01", "os": "windows", "mode": "balanced", "proxy": "socks5://1.2.3.4:1080", "proxy_user": "user", "proxy_pass": "pass"}' \
http://127.0.0.1:50360/api/v1/profiles
curl -X POST -H "X-Token: kk_..." -H "Content-Type: application/json" \
-d '{"url": "https://example.com"}' \
http://127.0.0.1:50360/api/v1/profiles/PROFILE_ID/start
Examples: Python + Playwright
Creates a profile with a proxy, opens it and connects with connect_over_cdp (pip install requests playwright).
import requests
from playwright.sync_api import sync_playwright
API = "http://127.0.0.1:50360/api/v1"
H = {"X-Token": "kk_..."}
def call(method, path, **kw):
r = requests.request(method, API + path, headers=H, timeout=200, **kw).json()
if not r["ok"]:
raise RuntimeError(r["error"]["code"] + ": " + r["error"]["message"])
return r["data"]
prof = call("POST", "/profiles", json={
"name": "shop-01", "os": "windows", "mode": "balanced",
"proxy": {"type": "socks5", "host": "1.2.3.4", "port": 1080,
"username": "user", "password": "pass"},
})
run = call("POST", f"/profiles/{prof['id']}/start")
with sync_playwright() as pw:
browser = pw.chromium.connect_over_cdp(run["ws"])
ctx = browser.contexts[0] # the profile's own context
page = ctx.pages[0] if ctx.pages else ctx.new_page()
page.goto("https://example.com")
print(page.title())
call("POST", f"/profiles/{prof['id']}/stop")
Examples: Node.js + Puppeteer
Use puppeteer-core: Kalkan starts the browser, Puppeteer only connects to it.
const puppeteer = require("puppeteer-core");
const API = "http://127.0.0.1:50360/api/v1";
const H = { "X-Token": "kk_...", "Content-Type": "application/json" };
(async () => {
const res = await fetch(`${API}/profiles/PROFILE_ID/start`, { method: "POST", headers: H, body: "{}" });
const { ok, data, error } = await res.json();
if (!ok) throw new Error(`${error.code}: ${error.message}`);
const browser = await puppeteer.connect({ browserWSEndpoint: data.ws, defaultViewport: null });
const [page] = await browser.pages();
await page.goto("https://example.com");
console.log(await page.title());
await browser.disconnect();
})();
Examples: Selenium
Selenium attaches through debug_port. The major version of chromedriver must match the browser's (Kalkan uses your installed Chrome).
import requests
from selenium import webdriver
API = "http://127.0.0.1:50360/api/v1"
H = {"X-Token": "kk_..."}
run = requests.post(f"{API}/profiles/PROFILE_ID/start", headers=H, json={}).json()["data"]
opts = webdriver.ChromeOptions()
opts.debugger_address = f"127.0.0.1:{run['debug_port']}"
driver = webdriver.Chrome(options=opts)
driver.get("https://example.com")
print(driver.title)
Tips
- Do not set a viewport from your script. The profile's screen size is part of its fingerprint. Use
defaultViewport: nullin Puppeteer and do not passviewportin Playwright. - Use the profile's existing context. In Playwright take
browser.contexts[0]instead ofnew_context(): a new context has none of the profile's cookies or settings. - When your script ends, disconnect instead of closing the browser, and stop the profile with
POST /profiles/{id}/stopso Kalkan saves the session properly. - Starting a profile that is already open returns the same endpoint with
already_running: true, so scripts can safely callstartagain. - Handle 409 deliberately:
allow_directandforceexist so that a script never opens a profile without a proxy or on a reused IP by accident.