Skip to content
← Back to Home

Local API

Last checked against the contract: September 2026

Released, and in the build you can download today — on macOS and on Windows alike. The switch is in the app’s settings and the API stays off until you turn it on. Which calls that build answers is marked on every operation and on every example below, rather than written into a sentence here that the next release would make wrong.

A script on the same machine turns a profile’s browser into something it can drive. The interface is one service, one key and one address file — described first, because everything stands on them — and a handful of routes over the top: find a profile, open it, connect Puppeteer, Playwright or Selenium to the address it answers with, close it again.

Turning it on

Off by default, and reachable from this computer only.

The switch
Open Settings → API & Automation and turn on Local API. Until you do, nothing listens: the port is not opened, and a script gets a refused connection rather than an empty answer.
This computer only
The service accepts connections on 127.0.0.1 and nowhere else. Another machine on your network cannot reach it, and there is no setting that opens it up — a script runs beside Liminal, not against it over the network.
The port
The default is 48361, a port of Liminal’s own so it does not collide with another browser already listening on the machine. Type a different one, or press Random free port and let the app pick. The change takes effect at once, without restarting Liminal.
When the port is taken
If the port cannot be bound, the screen says Port in use and the address file reports port_in_use. Nothing listens until you choose another port, so a script that reads the file knows the difference between "off" and "could not start".

The key

One key, shown once, kept where your operating system keeps secrets.

Copy it when it appears
Enabling the API issues a key of 64 lowercase hexadecimal characters and shows it once. Leave the screen and it is hidden: the app cannot show it again, it can only issue a new one.
Where it is stored
In Keychain on macOS and Credential Manager on Windows — never in a settings file, a sync snapshot or a log, and never in the address file below. Keep your copy the way you keep any other credential your script uses.
Revoking it
Regenerate key issues a new key and stops accepting the old one immediately, with no restart of the app and no change to the port. A script still holding the old key is refused from the next request on.
Sending it
Every request carries the key in one header: Authorization: Bearer followed by the key itself. There is no query parameter and no cookie, and a request without the header is refused like a request with a wrong key.

Where a script finds the address

The app writes its address to a file, so nothing has to be typed twice.

The file
The settings screen shows the absolute path to Local API/status.json inside Liminal’s user-data directory. Read it instead of hardcoding a port: it follows the port you set and the one Random free port picked.
What it holds
Four fields — enabled, status, address, port — and no secret of any kind.
{"enabled":true,"status":"running","address":"http://127.0.0.1:48361","port":48361}
When the API is off
The file stays where it is and says so: off with an empty address and no port. The same shape, a different state — a script reads one file to learn everything.
{"enabled":false,"status":"off","address":"","port":0}
It is replaced, never half-written
Every change — turning the API on or off, a new port, a port that could not be bound — rewrites the file atomically. A script that reads it during a change sees the old contents or the new ones, never a truncated line.

The status request

The call to start from: is Liminal running, and is anyone signed in.

The call
A GET to /v1/status at the address from the file, with the key in the header.
curl -H 'Authorization: Bearer <key>' http://127.0.0.1:48361/v1/status
The answer
HTTP 200, with the running build’s version and whether the app has someone signed in. A false signedIn is not an error — the API answers; it is the operations that read the catalog or open a profile that need an account.
{"status":"running","version":"<version>","signedIn":true}
A missing or revoked key
HTTP 401, and the code invalid_key — permanent, and the thing to branch on. The message beside it is the app’s own wording for a person reading a log; treat it as text that can change, not as part of the contract.
{"error":{"code":"invalid_key","message":"Неверный ключ"}}
Address it exactly as the file does
Use 127.0.0.1 and the port from the file. A request that arrives naming any other host is not answered, even when it reaches the same port — the service replies to its own address only.
What an older Liminal does
Nothing: no port is opened, so a script connecting to it is refused. That refusal is how a script tells a build without the local API from one that has it turned off — in the second case the address file exists and says off.

What this version does and does not do

Said plainly, so nothing here reads as a promise it is not.

An operation reaches this page before it reaches your build
The interface grows release by release, and a call that is written is documented here before the build that answers it — marked, never hidden. So read the mark, not the promise: it is on every operation and on every example below, and it is what tells you whether the thing you are about to script works in the app you have.
Which plans it is for
Every plan, the free one included. The local API is not a paid tier: a script on your own machine is how many people use an anti-detect browser at all, and putting it behind a plan would be a tax on that.Plans
How often a script may ask
There is no quota tied to your plan. The service protects itself from being flooded, which a script written for it will not notice.
A profile under automation is the same profile
It keeps its fingerprint, its proxy and its cookies, and it reaches the network through its proxy exactly as it does when you open it by hand. The API is a way in, not a second mode of the browser.Data & Security

Every operation, one by one

What each route is for, what it takes, what it answers, and every refusal it can give. The badge beside an operation says whether the build you can download today answers it at all.

GET /v1/status

In the released build

Is Liminal running, and is anyone signed in

The cheapest call there is, and the one to poll while you wait for the app. It answers without an account: a false signedIn is a state, not an error.

Parameters

None.

Answer

{
  "status": "running",
  "version": "<version>",
  "signedIn": true
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.

GET /v1/profiles

In the released build

The profiles in the catalog, filtered and paged

Reads the same catalog the app shows. Filters combine, and every one of them is exact except name, which matches any profile whose name contains what you typed, in any case.

Parameters

pagequery · integer · optional · 1 to 1000000 · defaults to 1
Which page to read, counting from one.
pageSizequery · integer · optional · 1 to 100 · defaults to 100
How many profiles a page holds. The ceiling is the default.
workspaceIdquery · string · optional
Only profiles of that workspace.
folderIdquery · string · optional
Only profiles of that folder.
statusquery · string · optional
Only profiles in that state; running is the state of an open profile.
namequery · string · optional
Only profiles whose name contains this, ignoring case.

Answer

{
  "page": 1,
  "pageSize": 100,
  "total": 1,
  "items": [
    "<profile>"
  ]
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 catalog_unavailable — The profile catalog is not ready yet. Ask again.
  • 400 invalid_pagination — page or pageSize is outside the accepted range.

GET /v1/profiles/{id}

In the released build

One profile, with its proxy and its fingerprint

The same object the list returns, for one identifier. The proxy address is shown; its password never is.

Parameters

idpath · string · required
The profile identifier, as the catalog operations return it.

Answer

{
  "id": "<profile-id>",
  "name": "Shop EU",
  "workspaceId": "<workspace-id>",
  "folderId": "<folder-id>",
  "status": "running",
  "health": "ok",
  "proxy": {
    "id": "<proxy-id>",
    "name": "Residential DE",
    "type": "socks5",
    "host": "proxy.example.net",
    "port": 1080,
    "working": true,
    "lastCheckedExternalIp": "203.0.113.10",
    "lastCheckedCountryCode": "DE"
  },
  "fingerprint": {
    "platform": "MacIntel",
    "language": "de-DE",
    "timezone": "Europe/Berlin"
  },
  "lastOpenedAt": "2026-09-30T08:15:00.000Z"
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 catalog_unavailable — The profile catalog is not ready yet. Ask again.
  • 404 profile_not_found — No profile with that identifier is in the catalog.

POST /v1/profiles/{id}/start

In the released build

Open a profile and get the address a driver connects to

Starts the profile the way the app does — its fingerprint, its proxy, its cookies — and answers with the CDP address once the browser is up. The call waits; it does not return a job to poll.

Parameters

idpath · string · required
The profile identifier, as the catalog operations return it.
debugPortbody · integer · optional · 1024 to 65534 · defaults to 0
The port the CDP endpoint binds. 0 lets the app pick a free one; any other value has to be one your user can bind.
timeoutSecondsbody · integer · optional · 1 to 180 · defaults to 60
How long the call waits for the profile to come up before it gives up.
maskAutomationbody · boolean · optional · defaults to true
Only true is accepted. Automation masking cannot be turned off: a profile under a script is the same profile a site sees when you open it by hand.

Answer

{
  "id": "<profile-id>",
  "status": "running",
  "cdpDiscoveryUrl": "http://127.0.0.1:48361/devtools/<token>/json/version",
  "cdpBrowserUrl": "ws://127.0.0.1:9222/devtools/browser/<token>",
  "seleniumDebuggerUrl": "127.0.0.1:9222",
  "chromedriverPath": "/Applications/Liminal.app/.../chromedriver",
  "debugPort": 9222,
  "debugAddress": "127.0.0.1:9222",
  "externalIp": "203.0.113.10",
  "countryCode": "DE"
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 runtime_unavailable — The app cannot start or stop a profile right now.
  • 404 profile_not_found — No profile with that identifier is in the catalog.
  • 409 profile_already_running — The profile is open already, a start for it is still running, or a driver address handed out earlier is still good.
  • 400 invalid_start_options — The body is not a JSON object.
  • 400 invalid_port — debugPort is neither 0 nor a port in the accepted range.
  • 400 invalid_start_timeout — timeoutSeconds is outside the accepted range.
  • 400 masking_unavailable — maskAutomation was sent as false, which the app does not offer.
  • 409 port_in_use — The debug port you asked for is taken.
  • 409 listen_failed — The debug port could not be bound for another reason.
  • 409 start_failed — The profile did not come up. A safe `detail` names the stage when the app can say it.
  • 409 proxy_unavailable — The profile requires its proxy, and the proxy did not answer.
  • 502 start_failed — Preparing the profile failed before the browser was asked to start.
  • 503 cdp_unavailable — The profile started but its CDP endpoint did not come up.
  • 504 start_timeout — The profile did not come up within timeoutSeconds.

POST /v1/profiles/{id}/attach-automation

In the released build

Get a driver address for a profile that is already open

For a profile you opened by hand and now want to drive. It does not restart anything: the session, its tabs and its cookies stay as they are.

Parameters

idpath · string · required
The profile identifier, as the catalog operations return it.
debugPortbody · integer · optional · 1024 to 65534 · defaults to 0
The port the CDP endpoint binds. 0 lets the app pick a free one; any other value has to be one your user can bind.
timeoutSecondsbody · integer · optional · 1 to 180 · defaults to 60
How long the call waits for the profile to come up before it gives up.
maskAutomationbody · boolean · optional · defaults to true
Only true is accepted. Automation masking cannot be turned off: a profile under a script is the same profile a site sees when you open it by hand.

Answer

{
  "id": "<profile-id>",
  "status": "running",
  "cdpDiscoveryUrl": "http://127.0.0.1:48361/devtools/<token>/json/version",
  "cdpBrowserUrl": "ws://127.0.0.1:9222/devtools/browser/<token>",
  "seleniumDebuggerUrl": "127.0.0.1:9222",
  "chromedriverPath": "/Applications/Liminal.app/.../chromedriver",
  "debugPort": 9222,
  "debugAddress": "127.0.0.1:9222",
  "externalIp": "203.0.113.10",
  "countryCode": "DE"
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 runtime_unavailable — The app cannot start or stop a profile right now.
  • 404 profile_not_found — No profile with that identifier is in the catalog.
  • 409 profile_already_running — The profile is open already, a start for it is still running, or a driver address handed out earlier is still good.
  • 400 invalid_start_options — The body is not a JSON object.
  • 400 invalid_port — debugPort is neither 0 nor a port in the accepted range.
  • 400 invalid_start_timeout — timeoutSeconds is outside the accepted range.
  • 400 masking_unavailable — maskAutomation was sent as false, which the app does not offer.
  • 409 port_in_use — The debug port you asked for is taken.
  • 409 listen_failed — The debug port could not be bound for another reason.
  • 409 start_failed — The profile did not come up. A safe `detail` names the stage when the app can say it.
  • 409 proxy_unavailable — The profile requires its proxy, and the proxy did not answer.
  • 502 start_failed — Preparing the profile failed before the browser was asked to start.
  • 503 cdp_unavailable — The profile started but its CDP endpoint did not come up.
  • 504 start_timeout — The profile did not come up within timeoutSeconds.
  • 409 profile_not_running — The profile is not open, so there is nothing to attach to.

POST /v1/profiles/{id}/stop

In the released build

Close a profile the way its window would

Lets the session finish writing its state. This is the one to call between scripted runs on the same profile.

Parameters

idpath · string · required
The profile identifier, as the catalog operations return it.

Answer

{
  "id": "<profile-id>",
  "status": "closed"
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 runtime_unavailable — The app cannot start or stop a profile right now.
  • 404 profile_not_found — No profile with that identifier is in the catalog.
  • 409 profile_not_running — The profile is not open, so there is nothing to close.
  • 409 stop_failed — The app could not close the profile.

POST /v1/profiles/{id}/force-stop

In the released build

Close a profile that will not close

The same answer, without waiting for the session to settle. Reach for it when a stop did not return, not as a matter of course.

Parameters

idpath · string · required
The profile identifier, as the catalog operations return it.

Answer

{
  "id": "<profile-id>",
  "status": "closed"
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 runtime_unavailable — The app cannot start or stop a profile right now.
  • 404 profile_not_found — No profile with that identifier is in the catalog.
  • 409 profile_not_running — The profile is not open, so there is nothing to close.
  • 409 stop_failed — The app could not close the profile.

GET /v1/running-profiles

In the released build

What is open right now, and what can be driven

Every profile that is open, whether a window or a call opened it — startedBy says which — with its driver address when one is available. Reconnect from here after your script restarts.

Parameters

None.

Answer

{
  "items": [
    {
      "id": "<profile-id>",
      "workspaceId": "<workspace-id>",
      "status": "running",
      "automationAvailable": true,
      "startedBy": "api",
      "cdpBrowserUrl": "ws://127.0.0.1:9222/devtools/browser/<token>",
      "seleniumDebuggerUrl": "127.0.0.1:9222",
      "chromedriverPath": "/Applications/Liminal.app/.../chromedriver",
      "debugPort": 9222,
      "debugAddress": "127.0.0.1:9222"
    }
  ]
}

Refusals

  • 401 invalid_key — The key is missing, malformed or has been regenerated since.
  • 403 not_signed_in — Nobody is signed in to the app; the catalog operations need an account.
  • 503 catalog_unavailable — The profile catalog is not ready yet. Ask again.

Driving a profile from your own script

Each block is whole: it reads the address file, calls the API and connects the driver. Copy one, put the name of your own profile in it, and it runs. The badge says whether the build you can download today answers everything that block needs.

Puppeteer (JavaScript)

In the released build

Connects over CDP to the address the start answered with. The closest thing to a script already written against AdsPower or Octo.

Calls /v1/profiles · /v1/profiles/{id}/start

import { readFileSync } from 'node:fs'
import puppeteer from 'puppeteer-core'

// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))

async function call(path, init) {
  const response = await fetch(address + path, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
  })
  const body = await response.json()
  if (!response.ok) throw new Error(body.error.code)
  return body
}

const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })

const browser = await puppeteer.connect({ browserWSEndpoint: started.cdpBrowserUrl })
const page = await browser.newPage()
await page.goto('https://example.com')
console.log(await page.title())
await browser.disconnect()

Playwright (JavaScript)

In the released build

The same connection through Playwright’s CDP entry point, into the context the profile already has rather than a fresh one.

Calls /v1/profiles · /v1/profiles/{id}/start

import { readFileSync } from 'node:fs'
import { chromium } from 'playwright'

// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))

async function call(path, init) {
  const response = await fetch(address + path, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
  })
  const body = await response.json()
  if (!response.ok) throw new Error(body.error.code)
  return body
}

const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })

const browser = await chromium.connectOverCDP(started.cdpBrowserUrl)
const page = await browser.contexts()[0].newPage()
await page.goto('https://example.com')
console.log(await page.title())
await browser.close()

Playwright (Python)

In the released build

The Python side of the same thing, reading the address file with nothing outside the standard library.

Calls /v1/profiles · /v1/profiles/{id}/start

import json
import os
import urllib.parse
import urllib.request
from playwright.sync_api import sync_playwright

# Local API/status.json, inside Liminal's user-data directory. The settings
# screen shows the absolute path; that path and the key are your script’s to
# keep — the app puts neither into the environment for you.
with open(os.environ["LIMINAL_ADDRESS_FILE"], encoding="utf-8") as handle:
    address = json.load(handle)["address"]


def call(path, method="GET"):
    request = urllib.request.Request(address + path, method=method)
    request.add_header("Authorization", "Bearer " + os.environ["LIMINAL_API_KEY"])
    with urllib.request.urlopen(request) as response:
        return json.load(response)


profile = call("/v1/profiles?name=" + urllib.parse.quote("Shop EU"))["items"][0]
started = call(f"/v1/profiles/{profile['id']}/start", method="POST")

with sync_playwright() as playwright:
    browser = playwright.chromium.connect_over_cdp(started["cdpBrowserUrl"])
    page = browser.contexts[0].new_page()
    page.goto("https://example.com")
    print(page.title())

Selenium (Python)

In the released build

Selenium attaches to a browser that is already running, by the debugger address, and takes the chromedriver the answer points at — nothing is downloaded and nothing is guessed.

Calls /v1/profiles · /v1/profiles/{id}/start

import json
import os
import urllib.parse
import urllib.request
from selenium import webdriver
from selenium.webdriver.chrome.service import Service

# Local API/status.json, inside Liminal's user-data directory. The settings
# screen shows the absolute path; that path and the key are your script’s to
# keep — the app puts neither into the environment for you.
with open(os.environ["LIMINAL_ADDRESS_FILE"], encoding="utf-8") as handle:
    address = json.load(handle)["address"]


def call(path, method="GET"):
    request = urllib.request.Request(address + path, method=method)
    request.add_header("Authorization", "Bearer " + os.environ["LIMINAL_API_KEY"])
    with urllib.request.urlopen(request) as response:
        return json.load(response)


profile = call("/v1/profiles?name=" + urllib.parse.quote("Shop EU"))["items"][0]
started = call(f"/v1/profiles/{profile['id']}/start", method="POST")

options = webdriver.ChromeOptions()
options.debugger_address = started["seleniumDebuggerUrl"]
driver = webdriver.Chrome(service=Service(started["chromedriverPath"]), options=options)
driver.get("https://example.com")
print(driver.title)
driver.quit()

Selenium (JavaScript)

In the released build

The same for Node: both the debugger address and the path to a matching chromedriver come out of the start answer.

Calls /v1/profiles · /v1/profiles/{id}/start

import { readFileSync } from 'node:fs'
import { Builder } from 'selenium-webdriver'
import chrome from 'selenium-webdriver/chrome.js'

// Local API/status.json, inside Liminal's user-data directory. The settings
// screen shows the absolute path; that path and the key are your script’s to
// keep — the app puts neither into the environment for you.
const { address } = JSON.parse(readFileSync(process.env.LIMINAL_ADDRESS_FILE, 'utf8'))

async function call(path, init) {
  const response = await fetch(address + path, {
    ...init,
    headers: { Authorization: `Bearer ${process.env.LIMINAL_API_KEY}` },
  })
  const body = await response.json()
  if (!response.ok) throw new Error(body.error.code)
  return body
}

const { items } = await call('/v1/profiles?name=' + encodeURIComponent('Shop EU'))
const started = await call(`/v1/profiles/${items[0].id}/start`, { method: 'POST' })

const driver = await new Builder()
  .forBrowser('chrome')
  .setChromeOptions(new chrome.Options().debuggerAddress(started.seleniumDebuggerUrl))
  .setChromeService(new chrome.ServiceBuilder(started.chromedriverPath))
  .build()
await driver.get('https://example.com')
console.log(await driver.getTitle())
await driver.quit()

The description a tool can import

The same contract as a file, at one address that does not move: OpenAPI, in JSON, generated from the data this page renders. Import it into Postman or Insomnia, or generate a client from it. Every operation in it carries the same availability as the badge above.

openapi.json

The release that shipped it, and every release that extends it, is in the changelog. A question about the contract, or a value here that does not match what your build does: [email protected].