Automation
Local API, keys and webhooks
- 50325
- Default port on 127.0.0.1
- 12
- Routes in the local API
- 7
- Scopes a server key can carry
- 13
- Events a webhook can carry
The API runs on your computer, not on our server
The local API is a small HTTP server inside the desktop app. It listens on 127.0.0.1 and answers programs running on the same machine.
It lives there because that is where the work is. Profiles, cookies, the engine and the proxy bridge are all on your disk. Opening a browser window is a local act, and no server of ours can perform it for you.
That also decides what happens when the network is down. Your scripts keep calling the app over loopback, and the app itself keeps working offline for 24 hours on a checked licence.
The answer envelope follows the shape this category settled on: code, data and msg. A script written for a neighbouring tool usually needs a new address and a new token, not a rewrite.
Drive profiles from your own code
List, create, start, stop and delete them without touching the window.
Attach Puppeteer, Playwright or Selenium
The start route answers with the debugging endpoint for that profile.
Run saved flows from outside
Your own scheduler or build server can start a flow over the profiles you name.
Let an AI client act
The bundled MCP server speaks to these same routes and needs the API switched on.
One switch, one port, one token
The API is off until you turn it on. Enabling it opens a port, so it is never on by default.
On first run the app writes local-api.json into its data folder. It generates a random token there, 24 bytes shown as 48 characters, and writes the file so only your account can read it.
The port is 50325 by default. If something else already holds it, the server takes a free port instead and the panel says which one it took. That matters, because your scripts and your AI client still point at the old address.
The token travels three ways: as the token parameter in the URL, as the x-api-token header, or as Authorization: Bearer. Regenerating it stops every script that still carries the old one, so the app asks you to confirm first.
- 1
Open the API screen in the app
It shows the switch, the address, the token and the list of routes.
- 2
Turn the local API on
The status has to read Running. If the usual port was busy, the panel names the port it took instead.
- 3
Copy the address and the token
The panel offers a ready curl line with your own token already in it.
- 4
Point your script at it
Every call except /status carries the token. A wrong token comes back as 401.

A request has to look like a program, not a web page
Binding to loopback sounds like a boundary. It is not one. The pages you visit run on this same computer, so they can reach 127.0.0.1 as well.
So the server first asks whether the caller looks like a page. Any request carrying an Origin header is refused. So is any request whose Sec-Fetch-Site is anything other than none, because only a typed address gives none. curl, Python and the MCP client send neither header.
The Host header is checked too, against the address actually being listened on. A domain name that resolves to 127.0.0.1 on the second lookup arrives here as evil.example, and that request is refused. Without the check the browser treats the answer as same-origin and hands the page the body.
Sec-Fetch-Mode is deliberately not used as a tell. Node's own fetch sends cors with no origin, and reading that as a browser would reject most automation scripts ever written.
Even /status sits behind the page check
Twelve routes and one envelope
Everything sits under 127.0.0.1:50325, and every answer has the same three fields. code is 0 on success and msg says success, while data carries the payload.
A refusal returns code -1 with the reason. A missing or wrong token is HTTP 401 with code -401, a page-shaped request is 403 with -403, an unknown path is 404 with -404. The envelope and the HTTP status always agree, so your script can branch on either.
Request bodies are read as JSON and capped at about a megabyte. Anything bigger is cut off and the call is handled as if no body had arrived.
- Call
- curl -H x-api-token:YOUR_TOKEN http://127.0.0.1:50325/api/v1/browser/list
- Envelope
- { code: 0, msg: success, data: { list: [ ... ] } }
- One profile
- profileId, name, scenario, group, fingerprintTemplate, tags, lastUsedAt, running
- Its proxy field
- protocol, host, port. Never the login, never the password
- Wrong token
- HTTP 401, { code: -401, msg: unauthorized }
- Called from a page
- HTTP 403, { code: -403, msg: forbidden }
The token can also travel as ?token= in the URL or as Authorization: Bearer.
| Route | Method | What it does |
|---|---|---|
| /status | GET | Says the app is up and names its version. The only route with no token. |
| /api/v1/browser/list | GET | Your profiles, each with the state it is in right now. |
| /api/v1/profile/list | GET | The same set, under the name other tools in this category use. |
| /api/v1/browser/start | POST | Opens a profile and answers with its debugging endpoint. |
| /api/v1/browser/stop | POST | Closes every window of a profile and answers how many it closed. |
| /api/v1/browser/active | GET | The ids of the profiles open at this moment. |
| /api/v1/profile/create | POST | Creates a profile through the same quota gate as the window. |
| /api/v1/profile/delete | POST | Moves a profile to the recycle bin. |
| /api/v1/profile/code | GET | The current one-time code for a profile that has a 2FA key stored. |
| /api/v1/proxy/list | GET | The saved proxy library: hosts and ports, never the passwords. |
| /api/v1/flow/list | GET | Saved automation flows with the number of steps in each. |
| /api/v1/flow/run | POST | Runs a flow over the profile ids you name. |
Starting a profile, and what you attach to it
POST /api/v1/browser/start takes profileId in the body or in the query string. The examples for Puppeteer and Playwright pass it in the query, so both are accepted.
You can ask for one run without a window. Send headless in the body, or headless=1 in the query. The strings 0, false and empty read as no, not as a non-empty string meaning yes. Send nothing at all and the profile's own setting decides, because a night run and a day of manual work are two different visits to the same profile.
What comes back depends on the engine. On the ARMANOS Browser each profile gets its own DevTools endpoint, isolated from every other profile. The app reads the port from that profile's own file, polling for up to about four and a half seconds, and answers with debugPort and a ws address for that profile alone.
The built-in Electron engine has no per-profile socket. Its answer names the app-level port and says plainly that it is shared by every profile and unauthenticated on loopback. That port is opened only when the built-in engine is selected: it is protected by nothing, and through it any program on the machine drives the manager window, not just profiles.
ARMANOS Browser engine
debugPort and ws for this profile only. Attach your automation library straight to that address.
Built-in engine
The shared app-level port, named in the answer together with the warning that it is shared.
Neither available
The answer says no endpoint is open and why, instead of handing back an empty field.
The API passes the same gate as the buttons
The API is not a side door. Creating a profile goes through the same licence and quota check as the button in the window, so plan limits apply: 2 profiles on Free, 10 to 100 on Professional, 200 to 1000 on Business, 5000 and up on Enterprise.
Starting a profile checks the licence first. If the copy is locked, the call comes back with the reason in words instead of a blank failure.
flow/run is strict about ids. Profile ids it does not know are named back to you in data.unknown, so you can fix your call. Zero profiles taken into work is a refusal, not a success: the route once answered started: 0 while a run was going fine, and callers launched the same profiles a second time.
Two routes exist because scripts stall without them. profile/code returns the current one-time code for a profile with a 2FA key stored, with the seconds left, the issuer and the account. proxy/list returns your saved proxies with host and port, and never the credentials, so a leaked token does not hand over every proxy you bought.
Deleting through the API is a soft delete
Server keys carry rights, and they are a paid tool
The token above drives one installation on one computer. A server key is a different thing: it talks to our server from anywhere, and it never opens a browser window. The server route called start marks a profile busy and writes the open log, and that is all it does.
A key looks like ak_ followed by 24 random bytes. You see it once, at creation. We keep a sha256 hash, the first ten characters and the name you gave it, which is enough to tell your keys apart and not enough to use one. Every key carries scopes, and a route with no declared scope is closed to keys entirely, even to a key holding every scope you can hand out.
Keys belong to the paid plans. Free has none. Creating one on Free is refused, and a key whose paid time has run out answers 402, not 401 and not 403. The difference matters to a program: 401 sends it to issue a new key, 403 sends it to ask for more rights, 402 tells it the truth.
Some rules exist for the day a key leaks. A key cannot create keys or webhooks, only a person signed in with a password can. Blocking an account kills its keys on the next call. Revoking keeps the row, so the log can still name the key that acted, and the log records which key it was, not only which person. You may hold 20 live keys, the API allows 300 requests a minute by default with tighter limits on the sign-in routes, and the plan behind a key is re-checked at most once a minute.
| Scope | What it opens |
|---|---|
| profiles:read | The profile list. |
| profiles:write | Create, edit and delete a profile. |
| browser:run | Mark a profile open or closed on the server, without the right to edit it. |
| proxies:read | The saved proxy list, without passwords. |
| proxies:write | Create, edit and delete a proxy, if the member's role allows it. |
| flows:read | The list of saved automation flows. |
| logs:read | The action log, the open log, sign-in addresses and the suspicion rules. |
Webhooks tell your own server what happened
A webhook is our server calling yours when something happens in your workspace. Subscriptions are created in the cabinet by a person signed in with a password, never by a key, and you may keep up to ten. Events come from the audit trail, so they name what was actually recorded.
Each call is signed. The signature is an HMAC-SHA256 over the timestamp and the body, sent as X-Armanos-Signature next to X-Armanos-Event and X-Armanos-Timestamp. Both SDKs verify it and both refuse a signature older than five minutes, so an intercepted call cannot be replayed forever. Fields whose names look like a credential are stripped before sending: code, token, secret, password, link, hash. An invite code or a password reset link never leaves for a third party server, even one you own.
Delivery is honest about failure. Three attempts, ten seconds each, retried only on 5xx and 429, with a pause between them. Redirects are not followed, because an answer saying go to 127.0.0.1 would undo the address check. The last status, the reason in words and the number of failures in a row are written on the subscription and shown in the cabinet. After fifteen failures in a row the subscription switches itself off, with the reason written down rather than in silence.
The address is re-checked before every send, with the name resolved again. Private ranges are refused, including the cloud metadata address that hands out credentials for a whole account. A test send from the cabinet carries test: true, so your system does not create a phantom profile from it.
- Method
- POST, Content-Type application/json
- X-Armanos-Event
- profile.start
- X-Armanos-Timestamp
- Unix seconds, also part of the signature
- X-Armanos-Signature
- sha256=... HMAC of timestamp and body
- Body
- { id, event, createdAt, data }
- Delivery
- At least once. Keep the id and drop repeats
Redirects are not followed, and 4xx answers other than 429 are not retried.
Profiles
profile.create, profile.update, profile.delete, profile.start, profile.stop
Proxies
proxy.create, proxy.update, proxy.delete
Keys
apikey.create, apikey.revoke
Team
team.invite, team.remove
Money
plan.change
The SDKs stay a thin layer on purpose
There are two packages: one for Node, and a single file for Python that imports nothing outside the standard library. Both are at version 0.1.0, and both are deliberately thin. A kit that knows more than the server drifts away from it and starts to lie.
Both read the key and the address from the environment, default to our API address and give up after 30 seconds. Both check that the key looks like ak_ plus latin letters and digits before sending anything. A stray space from the clipboard used to break the request header itself, and the person saw server unreachable while the server was alive.
Errors are typed, not prose. A missing scope, a bad or revoked key and no connection at all are three different codes, so your script branches on the code and not on a message that may be translated.
Coverage follows the scopes: profiles list, create, update, delete, start and stop; proxies list, create, update and delete; the flow list; and the four log views. start and stop send a device id built from your machine name, because the profile lock has to tell two machines apart, and a single word sent from every machine broke both the lock and the seat count.
The half your receiver needs
What this does not do
- The local API reaches only the copy of ARMANOS on that one computer. There is no cloud route that opens a browser window for you, and a server key starts nothing: it only marks a profile busy and writes the log.
- The local token is one token for the whole API. It has no scopes, no expiry and no per-route rights, and regenerating it stops every script you have at once.
- The local API has no rate limit of its own. It trusts the machine it runs on, which is why the page check and the token gate carry the whole load there.
- Server keys cannot create keys or webhooks, and any route with no declared scope is closed to them. The recycle bin, the fingerprint preview, proxy rotation and checks, and flow editing are all password only.
- Webhook delivery is at least once, and a call carries only the fields left after redaction. Your receiver has to drop repeats by id and fetch the object itself when it needs more than the event.
How to check
Every claim on this page comes from a file you can open, and most are held by a stand that runs.
- The local API refuses browser-shaped requests, and /status sits behind that check too
- apps/desktop/src/lib/local-api.js · apps/desktop/test/local-api-gate.js
- The unprotected app-level debug port is opened only for the built-in engine
- apps/desktop/src/main/main.js · apps/desktop/test/cdp-port-only-when-needed.js
- The token is random per installation and the API starts out disabled
- apps/desktop/src/lib/api-config.js · apps/desktop/src/main/main.js
- Server keys are a paid tool, and every scope guards a real route
- apps/server/src/api-keys/api-keys.service.ts · apps/server/test/ключи-платные.js · apps/server/test/права-ключей-живые.js
- Webhooks are signed, cannot be aimed inside our network, and record their failures
- apps/server/src/webhooks/webhook-dispatch.service.ts · apps/server/test/webhooks.js · apps/web/test/webhooks-web.js
Read next
MCP for AI clients
The bundled server that gives an AI client these same routes.
Automation builder
The flows that flow/list and flow/run reach from outside.
Scheduler
Runs inside the app on a timetable, when you would rather not write your own.
Plans and prices
Which plans carry API keys, and what a seat or an extra profile costs.
Turn it on and call it today
The API screen is in every build. Install the app, switch the API on, and point your script at 127.0.0.1.