Kalkan logoKalkan
User guide

API

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

  1. Open Kalkan → Settings → API and turn the API on.
  2. Change the port if 50360 is taken on your computer.
  3. 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 answers 429 locked for 60 seconds.
  • The API only listens on 127.0.0.1 and only answers requests addressed to 127.0.0.1 or localhost (otherwise 403 forbidden_host). It cannot be reached from other computers.
  • It is meant for scripts, not web pages: requests that carry an Origin header (sent by browsers) are refused with 403 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 /status always works and tells you in api_allowed whether 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)"}}
StatusCodesMeaning
200 / 201 / 202Done / created / started in the background (wait: false)
400bad_requestMissing or invalid field
401unauthorizedMissing or wrong API key
403plan, forbidden_host, forbidden_originPlan without the API, wrong Host, request from a web page
404 / 405not_found, method_not_allowedNo such profile / proxy / endpoint, or wrong HTTP method
409running, direct, ledger, cross_osThe 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.
429lockedToo many wrong keys, wait 60 seconds
500internal, launch_failedUnexpected error, or the browser could not start
504timeoutThe 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.

RequestWhat it does
GET /statusApp and API state: app, version, api, api_allowed, plan, running (number of open profiles).
GET /profilesLists 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 /profilesCreates 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}/cloneCopies the profile, optionally with a new name (201).
POST /profiles/{id}/startOpens 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}/stopCloses the profile: {stopped}.
GET /profiles/{id}/statusrunning, ws, cdp, debug_port, warnings, errors.
GET /profiles/{id}/cookiesExports cookies, ?format=json (default) or netscape. The profile must be closed (otherwise 409).
POST /profiles/{id}/cookiesImports {cookies: …} as a JSON list or Netscape text and returns the number imported. The profile must be closed.
PUT /profiles/{id}/proxySets the proxy: {proxy_id} for a saved proxy, {proxy: {…}} for a new one, or {proxy: null} to remove it.
GET /runningOpen profiles: [{id, engine, cdp, debug_port, pid}].
POST /stop-allCloses every open profile: {stopped: [ids]}.
GET /folders
POST /folders
Lists folders / creates one with {name, color?} (201).
GET /proxiesSaved proxies (passwords are never returned).
POST /proxiesAdds 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}/checkTests 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: null in Puppeteer and do not pass viewport in Playwright.
  • Use the profile's existing context. In Playwright take browser.contexts[0] instead of new_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}/stop so Kalkan saves the session properly.
  • Starting a profile that is already open returns the same endpoint with already_running: true, so scripts can safely call start again.
  • Handle 409 deliberately: allow_direct and force exist so that a script never opens a profile without a proxy or on a reused IP by accident.