Echidna
Automate

Engine API and CDP

The engine that runs inside the Echidna app answers a small HTTP API on your own computer. Turn on automation once, then start a profile from a script and attach Playwright, Puppeteer or Selenium to the browser Echidna has already set up.

How it works

When the Echidna app is open, its engine listens on 127.0.0.1 only. Nothing is exposed to your network or the internet. Every request needs a bearer token.

The flow is always the same:

  1. Ask the engine to start a profile.
  2. Read the profile's DevTools address (cdp_url) from the answer.
  3. Connect your automation library to that address and work in the profile's existing browser context.
  4. Ask the engine to stop the profile. Cookies and storage are saved.
Attach, don't launch. A profile started by Echidna already has its fingerprint, proxy, timezone and cookies in place. Your script only connects to it. Don't add launch flags or stealth plugins on top: the engine already handles this, and additions can make the profile inconsistent.

Turn on automation

Open Settings → Automation & API and switch on Allow scripts and AI agents. Echidna creates your automation token, a key that starts with eca_.

The same screen has copy-ready examples for Python and Node.js Playwright, Puppeteer, curl and AI agents (MCP), filled in with your address and, if you choose, your token.

Treat the token like a password. Anyone who has it can create, open and drive your profiles on this computer while the app is open. Keep it out of repositories and chats; read it from an environment variable such as ECHIDNA_API_TOKEN instead.

The address

By default the engine picks a free port each time the app starts and writes the address, as 127.0.0.1:PORT, to the api-port file in Echidna's data folder:

SystemFile
macOS, Linux~/.echidna/api-port
WindowsC:\Users\you\.echidna\api-port

Read the file when your script starts. Or turn on Keep the same port in Settings → Automation & API and choose a port from 1024 to 65535 (50360 by default). It applies when the engine restarts: the screen offers Restart engine, which closes open browsers after saving their cookies. If another program holds the port when the engine starts, the engine uses a free port instead and the screen says so.

import os
from pathlib import Path

API = "http://" + (Path.home() / ".echidna" / "api-port").read_text().strip()
# or, with a fixed port:  API = "http://127.0.0.1:50360"
H = {"Authorization": "Bearer " + os.environ["ECHIDNA_API_TOKEN"]}

The api-token file next to api-port holds the app's own token. It changes every time the app starts and can do everything the app can, including signing this computer out, so give scripts and agents the automation token instead.

The engine never listens on your network. To reach it from another computer, use SSH port forwarding to 127.0.0.1, for example ssh -L 50360:127.0.0.1:50360 you@that-computer with a fixed port.

Keep the Echidna app open while your scripts run. When you quit the app, it closes every browser it started (cookies are saved first) and stops its engine.

What the automation token can do

The automation token is limited to running your automation. Everything else stays in the app, even for someone who has the token.

AllowedOnly in the app
Create, read, edit, rename, duplicate, import and delete profiles; bulk actions, groups, tags and templatesLicense sign-in, sign-out and devices
Start and stop profiles, read their cdp_url, new fingerprints, cookies (view, import, clear)Saved logins, 2FA codes, mail, cards and billing, unless you turn on the vault switch; card numbers, CVVs and passwords in clear always
Tabs, windows, multi-window sync, recordings, Magic Browser and URL monitorsChanging settings, captcha service keys and notifications (webhooks)
Proxies: add, edit, test, assign, rotate and clean up (logins are masked in answers)Backups, restore, storage and moving the profile folder
Flows and schedules: save, check, run, cancel and read their historyEngine updates, repair and diagnostics; the audit log
Events, the launch queue and read-only engine information (/system, /settings, /engines)Showing or replacing the automation token, and bulk exports

A call outside this list answers 403 with "code": "forbidden" and a detail that names the area, for example the automation token cannot use licensing. Flows saved, scheduled or run with the automation token can't type saved passwords, cards or billing details ({account.password}, {card.…}, {billing.…}, {shipping.…}) unless you turn on the switch described next.

Saved cards, billing and logins

By default the automation token can't touch your saved cards, billing details, logins or one-time codes. To let your scripts check out or sign in with them, turn on Settings → Automation & API → Let scripts use saved cards, billing and logins and confirm. Only the app can change this switch; the automation token itself can't.

The risk. With the switch on, anyone who has your automation token can make purchases with your saved cards and type your saved logins into websites. They still can't read a card number, CVV or password: the engine types them into the page and never sends them back. If you think the token leaked, press New token and turn the switch off.

With the switch on, the automation token can also:

CallWhat it gives your script
POST /profiles/{name}/vault-fillTypes one saved value into a field of the profile's open page (see below). Answers {"ok": true, "card": "••4242"}, never the value.
GET /billing/cards, GET /billing/cards/{id}Your saved cards with id, brand, last 4 digits, expiry and nickname, so a script can choose which card each profile uses. Never the full number or CVV.
GET /profiles/{name}/accountYes/no flags only: has_username, has_password, has_totp, mailbox readiness. Never the username, password or mailbox address.
GET /profiles/{name}/totpThe profile's current 2FA code: {"code": "123456", "remaining_s": 17}.
POST /profiles/{name}/otp/wait (and /otp/arm, /otp/cancel)Waits for the sign-in email and answers with the code or link only, not the message.
Flows and schedulesMay use {card.…}, {billing.…}, {shipping.…}, {account.password} and card_id. Run traces show *** in place of the values.

Still refused with the switch on: revealing a card number or CVV, exporting cards, billing identities or mail accounts, listing billing identities (they hold full addresses), changing saved logins, cards or autofill settings, card and mail settings, mailbox passwords and inbox listings. The app keeps all of these.

Fill a saved value

POST /profiles/{name}/vault-fill types one value through the same human-like input as flows. The profile must be open. The body:

FieldValues
sourcecard, billing, shipping or account (the profile's saved login)
fieldCard: number, cvv, exp (MM/YY), exp_month, exp_year, exp_year2, name. Billing and shipping: first_name, last_name, address.line1, city, postcode and the other identity fields. Account: username, password.
card_idOptional, cards only: which saved card. Default: the card linked to the profile.
selectorOptional: the field to type into. Default: the field that has focus.
clearOptional: true empties the field first.

It never submits a form; your script decides when to buy. A checkout across several profiles, each with its own card:

import os
from pathlib import Path
import requests
from playwright.sync_api import sync_playwright

API = "http://" + (Path.home() / ".echidna" / "api-port").read_text().strip()
H = {"Authorization": "Bearer " + os.environ["ECHIDNA_API_TOKEN"]}

# which saved card each profile pays with (masked list: id, brand, last4)
cards = {c["last4"]: c["id"] for c in requests.get(f"{API}/billing/cards", headers=H).json()["cards"]}
PLAN = {"shop-01": cards["4242"], "shop-02": cards["1881"], "shop-03": cards["0005"]}

def fill(name, source, field, selector, card_id=None):
    body = {"source": source, "field": field, "selector": selector}
    if card_id:
        body["card_id"] = card_id
    requests.post(f"{API}/profiles/{name}/vault-fill", json=body, headers=H).raise_for_status()

with sync_playwright() as p:
    for name, card_id in PLAN.items():
        info = requests.post(f"{API}/profiles/{name}/start", headers=H).json()
        page = p.chromium.connect_over_cdp(info["cdp_url"]).contexts[0].new_page()
        page.goto("https://shop.example/checkout")
        fill(name, "billing", "first_name", "#first-name")
        fill(name, "billing", "address.line1", "#address")
        fill(name, "card", "number", "#card-number", card_id)
        fill(name, "card", "exp", "#card-exp", card_id)
        fill(name, "card", "cvv", "#card-cvc", card_id)
        page.click("button[type=submit]")
        requests.post(f"{API}/profiles/{name}/stop", headers=H)

With the switch off, these calls answer 403 with a hint naming the switch.

Every use is logged

Each time the automation token types a saved card, billing detail or login, reads a 2FA code or fetches an email code, Echidna records it, masked: for example “card ••4242 used on profile shop-01 via automation token, fill”. The latest uses are listed in Settings → Automation & API, and every use is also in Activity → Audit log (as automation) and in the event stream as automation.vault_used. A flow logs each kind of value once per run.

Making requests

Send the token in the Authorization header of every request:

curl -H "Authorization: Bearer $ECHIDNA_API_TOKEN" http://127.0.0.1:PORT/profiles

Calls made with the automation token appear in the app under Activity → Audit log as automation.

Two read-only routes answer without a token, which is handy for checking that the engine is up: GET /healthz and GET /version.

Request and response bodies are JSON. A missing or wrong token, or a token that was replaced or turned off, returns 401 with {"detail": "invalid or missing bearer token", "code": "unauthorized"}. Other errors use the same shape: a readable detail, a stable code and, where it helps, a hint that says what to do next.

Routes

These are the routes most scripts need. {name} is the profile's name as shown in the app; URL-encode it if it contains spaces or other special characters.

Method and pathWhat it does
GET /profilesLists your profiles as a JSON array. Filter with group, tag, q (search), running=true; page with offset and limit (1–1000). The total is in the X-Total-Count header.
GET /profiles/{name}One profile, including whether it is running.
POST /profilesCreates a profile, for example {"name": "shop-01", "proxy": "socks5://user:pass@host:1080"}.
PUT /profiles/{name}Changes a profile's settings, group, tags, proxy or notes.
POST /profiles/{name}/startStarts the profile and returns its row, including cdp_url.
GET /profiles/{name}/cdpReturns {"cdp_url": "ws://127.0.0.1:…", "raw_cdp_url": "ws://127.0.0.1:…", "input_pacing": true} for a running profile, or 409 with code not_running.
POST /profiles/{name}/stopCloses the profile and saves its cookies and storage.
POST /profiles/bulkOne action on many profiles, for example {"action": "start", "names": ["a", "b"]}.
GET /queueShows the launch queue (see below).
POST /system/stop-allCloses every running profile.
DELETE /profiles/{name}Deletes the profile and its data. This can't be undone.

Start options

Pass these as query parameters on POST /profiles/{name}/start:

ParameterEffect
headless=trueOpen the profile without a window (an “automated” session). Use headless=false for a visible window.
start_url=…Open this page first.
require_proxy=trueRefuse to start if the profile's proxy can't be used, instead of continuing.
queue=falseFail straight away with 409 limit_reached when every launch slot is busy, instead of waiting in the queue.

You can also send a JSON body. launch_args accepts only these window flags; anything else is refused, because Echidna sets the profile, proxy and fingerprint flags itself:

The body can also carry "start_url" and "clear_cache_after_closing": true.

The launch queue

Echidna limits how many profiles run at once. When every slot is in use, a start waits in the queue: the engine answers 202 with "queued": true and the profile opens as soon as a slot frees up. Until then GET /profiles/{name}/cdp answers 409 not_running, so poll it, or start with queue=false if you'd rather fail fast.

Starting a profile that is already running is not an error: the answer has "already_running": true and the existing cdp_url. Launch options are not applied to an already running profile.

Useful fields in the profile row

FieldMeaning
runningtrue while the browser is open.
cdp_urlThe DevTools WebSocket address to connect to, with human-paced input (see below).
raw_cdp_urlThe same browser without input pacing: fastest, raw input.
webdriverThe same browser as 127.0.0.1:PORT, for Selenium's debuggerAddress.
mode"automated" for headless sessions, "manual" for windows.
status, last_errorThe current state and the last launch error, if any.

Paced or raw input

The start answer, the profile row and GET /profiles/{name}/cdp all give two addresses for a running profile. cdp_url goes through Echidna's input gateway: your Playwright or Puppeteer clicks travel along a human mouse path with a pause before the press, and keys get human press and gap timing ("input_pacing": true). raw_cdp_url is the browser itself: the fastest option, for example for a speed-critical checkout, but your library's input arrives as raw automation input with no mouse path or human timing. Everything else (fingerprint, proxy, cookies) is the same on both. Use cdp_url unless you need the speed.

Playwright (Python)

pip install playwright requests
import os
from pathlib import Path
import requests
from playwright.sync_api import sync_playwright

API = "http://" + (Path.home() / ".echidna" / "api-port").read_text().strip()
H = {"Authorization": "Bearer " + os.environ["ECHIDNA_API_TOKEN"]}
NAME = "shop-01"

# 1) start the profile without a window
info = requests.post(f"{API}/profiles/{NAME}/start",
                     params={"headless": "true"}, headers=H).json()
cdp = info["cdp_url"]  # if the start was queued, poll GET /profiles/{NAME}/cdp

# 2) attach to the running, already configured browser
with sync_playwright() as p:
    browser = p.chromium.connect_over_cdp(cdp)
    page = browser.contexts[0].new_page()   # reuse the profile's own context
    page.goto("https://example.com")
    print(page.title())

# 3) close it (cookies are saved)
requests.post(f"{API}/profiles/{NAME}/stop", headers=H)

Always work in browser.contexts[0]. Creating a new context would start an empty session without the profile's cookies and storage.

Playwright or Puppeteer (Node.js)

npm i playwright
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { chromium } from "playwright";

const API = "http://" + readFileSync(join(homedir(), ".echidna", "api-port"), "utf8").trim();
const H = { Authorization: `Bearer ${process.env.ECHIDNA_API_TOKEN}` };
const NAME = "shop-01";

const info = await (await fetch(`${API}/profiles/${NAME}/start?headless=true`,
  { method: "POST", headers: H })).json();

const browser = await chromium.connectOverCDP(info.cdp_url);
const page = await browser.contexts()[0].newPage();
await page.goto("https://example.com");

await fetch(`${API}/profiles/${NAME}/stop`, { method: "POST", headers: H });

With Puppeteer, use puppeteer-core (no bundled browser needed) and connect to the same address:

import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({ browserWSEndpoint: info.cdp_url, defaultViewport: null });

Selenium

Selenium attaches through the webdriver field. It needs a chromedriver that matches the profile's Chrome major version; Playwright and Puppeteer speak the DevTools protocol directly and need no driver.

from selenium import webdriver

info = requests.post(f"{API}/profiles/{NAME}/start", headers=H).json()
opts = webdriver.ChromeOptions()
opts.add_experimental_option("debuggerAddress", info["webdriver"])  # 127.0.0.1:PORT
driver = webdriver.Chrome(options=opts)
driver.get("https://example.com")

Good practice