Last updated:

Afina HTTP API#

Local HTTP server for external scripts, automation, and AI agents via MCP. It starts together with the Afina desktop app.

  • Base URL: http://127.0.0.1:50778
  • Default port: 50778; if busy, the server tries the next ports.
  • JSON: all responses use application/json, except /api/tasks/logs and /api/scripts/run-logs (text/plain).
  • Source of truth: src-tauri/src/browser/server.rs.

Authentication#

All /api/* endpoints require x-api-key from Settings → General → API key. If the key is missing or invalid: 401 Unauthorized / silent connection close.

bash

Only GET /api/health, GET /, and GET /oauth/callback?code=... are available without a key. CORS is allowed only for localhost origins (http://localhost, http://127.0.0.1, tauri://); methods GET, POST, DELETE, OPTIONS; headers Content-Type, X-API-Key.

Conventions#

  • id = numeric PK in SQLite; accountId = profile UUID. Most write endpoints accept both.
  • Boolean: true/false or 0/1; datetime: ISO-8601, default datetime('now').
  • */delete = soft delete (isDeleted=1); */hard-delete = physical deletion from DB/disk.
  • */update = PATCH for submitted fields only.
  • Write endpoints emit reload-event for UI refresh.

Health#

GET /api/health
Heartbeat without API key. Response: { "status": "ok", "running": 2 }, where running is the number of running browsers.

Profiles - accounts (CRUD)#

GET /api/profiles/list
Lists accounts; each item includes isRunning, tags:[{id,name,color}], groups:[{id,name}].

Query params (optional, AND):

ParameterTypeDescription
isRunningtrue/falseRunning/stopped
groupIdintFilter by group
tagIdintFilter by tag

Response: { message, count, accounts:[{ id, accountId, name, ua, proxyId, os, macChip, isRunning, tags, groups }] }.

GET /api/profiles/get / POST /api/profiles/get
One account by UUID: query/body { accountId }. Response: { message, profile:{...} }.

POST /api/profiles/create
Creates a profile. fingerprint: omitted → generated; full → used as provided with normalization; partial → base is generated and merged with incoming-wins.

Canonical fingerprint fields: userAgent, platform, platformVersion, vendor, productSub, CPUcores, deviceMemory, language, languages, AcceptLanguage, Brand, BrandVersion, BrandFullVersion, WebGLRenderer, WebGLVendor, Arch, Bitness, wow64, plus NoiseCanvas/Audio/Rects/GL.

Always ignored:

FieldWhy
userAgentMust match the Afina-Chromium build
BrandFullVersionBuild constant linked to UA
deviceMemoryFor ua<147, the browser reports max 8; 16/32 is allowed with ua>=147

Fingerprint aliases:

SubmittedBecomes
uaPlatformVersion, platform_versionplatformVersion
uaArchitecture, architectureArch
uaBitnessBitness
webGLVendor, webglVendor, unmasked_vendorWebGLVendor
webGLRenderer, webglRenderer, unmasked_rendererWebGLRenderer
hardwareConcurrency, hardware_concurrency, cpuCoresCPUcores
device_memorydeviceMemory
user_agentuserAgent
product_subproductSub
accept_language, acceptLanguageAcceptLanguage
brandVersion / brandFullVersion / uaFullVersionBrandVersion / BrandFullVersion
ua_full_versionBrandFullVersion
  • ua is taken from local {data_dir}/browser/UA*; the client value is ignored.
  • os for generation: payload → fingerprint.platformmacos; allowed: macos, windows10, windows11.
  • os in DB: Windows10 / Windows11 / x86 / arm64; Intel WebGL gives os="x86", macChip="", platform="x86", Arch="".
  • macChip: WebGLRenderer (Apple M2m2) → payload.chip/macChipfp.chip"".
  • osArch: arm64/x86 for macOS; ignored on Windows.

Other parameters (name, proxy*, tags*, settings, noise flags, screenSize, etc.) are listed below.

Body (everything is optional except scenario-specific requirements):

FieldTypeDescription
namestringName
accountIdstringForce UUID
osmacos/windows10FP generation; default macos
osArcharm64/x86macOS arch; default arm64
chipstringm1/m2/m3/m4/intel
uastringIgnored; local UA is used
browserTypestringafina/mimic/octo/vision/ads/dolphin; default afina
notestringNote
proxyIdintSaved proxy; mutually exclusive with proxyData
proxyTypesaved/set/without_proxyProxy type
proxyDataobjectInline proxy.
Required: host, port, type.
Optional: username, password, changeIpUrl, remark, country, countryCode, timezone, visible_ip, isActive, isUDPSupported
tagIds / tagNamesarrayTags by id/name; new tags are created
accountGroupIds / accountGroupNamesarrayGroups by id/name
languagesarrayLanguage override
language / timezonestringIf *_from_ip=false
timezone_from_ip / language_from_ip / languages_from_ipboolAuto from IP; default true
languageInterfaceTypefrom_language/real_valueDefault real_value
screenSizestring1920x1080; avail auto
useScreenDefaultboolDefault screen
availWidth/availHeight/colorDepth/pixelDepthintScreen
blockedPorts[int]`--afina-fp
blockOnProxyCountryChangebool/nullnull=global, true=block, false=allow
localCacheModedefault/no_cacheno_cache--disk-cache-size=0
startupUrls[string]Open on first launch
extraArgsstringChromium CLI args
settingsobjectPer-account KV ${key}
isNoiseCanvasEnabled / isNoiseAudioEnabled / isNoiseRectsEnabled / isNoiseGLEnabledboolFP noise
fingerprintobjectFull/partial FP
teamUuidstringLicense team

Success: { "status": "success", "id": 50, "accountId": "<uuid>", "account": {...} }.

POST /api/profiles/update
PATCH by { id } or { accountId }. Accepts create fields, isDeleted, skipServerSync, tags tagIds + selection(replace|append|delete|clear), groups accountGroupIds + selectionGroups(replaceGroup|appendGroup|deleteGroup|clearGroup).

POST /api/profiles/delete
Soft-delete. Body { id } or { accountId } (UUID-string). Success: { "message": "Account successfully deleted" }.

POST /api/profiles/hard-delete
Permanently deletes account/profile/junction, proxy_usage, and profile files; syncs Afina server DELETE /profiles/:uuid; closes the browser before deleting. Body { id } / { ids:[...] } or { accountId } / { accountIds:[...] }. Success: { "status":"success","deleted":3 }.

Browser control#

POST /api/profiles/start
Starts a browser. Body { "profileId": "<UUID>" }. Response contains wsEndpoint and data.port; if already running: alreadyRunning:true. wsEndpoint works with Puppeteer/Playwright/CDP.

POST /api/profiles/stop
Closes a running browser via CDP Browser.close, then graceful kill after timeout. Body { "profileId": "<UUID>" }. Returns 404 if the profile is not running.

One-time profiles#

POST /api/profiles/one-time
Creates a disposable profile, starts it immediately, and hard-deletes it after stop. Body = /api/profiles/create fields; name default one-time-<ts>.

  • Creation uses /api/profiles/create with partial-fingerprint merge and internal __oneTime_internal__.
  • Returns id, accountId, wsEndpoint, port, isOneTime:true.
  • Stop: POST /api/profiles/stop, browser.close(), or crash; after that the profile disappears from /api/profiles/list and Trash.
  • If start_browser fails, the created one-time profile is deleted synchronously.
  • RPM/RPH: one call = create + start + stop + delete; do not call more often than about 1/sec.

isOneTime cannot be set through /api/profiles/create or /api/profiles/update; hard-delete in the exit handler runs only after SQL check account.isOneTime=1.

Browser introspection - eval / screenshot#

POST /api/profiles/eval
Runs JS in the current visible tab of a running profile. Body { profileId, code }; code is wrapped in an IIFE, promises are awaited, result uses returnByValue. 404 if the browser is not running. Example response: { "value": "https://example.com" }.

POST /api/profiles/screenshot
Screenshot of the current visible tab. Body { profileId, format:"png" }; response { mimeType:"image/png", data:"iVBORw0..." }.

POST /api/profiles/cookies/set
Puts cookies into the import queue at {data_dir}/cookies/{uuid}/cookies_{ts}.json; on the next browser start, the server injects them via CDP. A pending cookies_*.json is overwritten.

Body:

FieldTypeDescription
accountIdint/UUIDaccount.id or UUID; alias id
cookiesarrayCookie objects: domain/name/value/path/expirationDate/secure/httpOnly/sameSite/...

Success: { "status":"success", "data": { "count": 1 } }.

The account must not be running: cookies are applied on the next /api/profiles/start. For hot injection, use CDP Network.setCookies through /api/profiles/eval or wsEndpoint.

POST /api/profiles/cookies/export
Exports decrypted cookies from the open profile folder, then .zip, then .afbk. Chrome extensions format: domain/name/value/path/expirationDate/hostOnly/httpOnly/sameSite/secure/session/storeId.

Body:

FieldTypeDescription
idintOne account.id
ids[int]Bulk ids
accountIdUUIDOne UUID
accountIds[UUID]Bulk UUID
pathstringFile or folder
  • Single without path → cookies in data.cookies.
  • Single + path ending in .json → one file.
  • Bulk or path without .json → folder; files cookie_{accountName}_{ts}.json.
  • Response: { status:"success", data:{ id?, accountName?, count?, cookies?, path?, exported?, errors? } }.

Unlocked session required: cookie key is read from vault. Otherwise 500 Cookie key not available - decrypt keys first.

Account vars#

Variables are available in RPA as ${key}:

  • plain = account.settings, unencrypted JSON.
  • encrypted = account_data_blob, sealed-box; requires master-password, executor decrypts before launch.

GET /api/accounts/vars?accountId=N (or ?accountUuid=UUID)
Returns { accountId, plain:{...}, encrypted:{...} }.

POST /api/accounts/vars/set
One key. Body { accountId | accountUuid, key, value, encrypted?:bool }; encrypted=false writes to account.settings and registers the key in catalog; encrypted=true decrypt → merge → encrypt. value can be string/number/bool/object/array.

POST /api/accounts/vars/delete
Deletes one key. Body { accountId | accountUuid, key, encrypted?:bool }; response contains removed.

Proxies#

POST /api/proxies/check
Checks account proxies with the same checker (ipapicom/ipinfoio), plus UDP for socks5. On success updates proxy (visible_ip, country, timezone, UDP, active) and per-account proxy_usage; respects country-block.

Body (AND filters):

FieldTypeDescription
accountIds[int]account.id
groupIdintAccounts in group
tagIdintAccounts with tag
(nothing)-All non-deleted with proxy

Response: { message, checked, results:[{ accountId, accountName, accountUuid, proxyId, host, port, type, result }] }; result.status = success / error / no_proxy.

POST /api/proxies/check-all
Checks all proxy records; updates proxy and proxy_usage for all accounts using each proxy. Response: { message, checked, results:[...] }.

POST /api/proxies/add
Adds a proxy after warm-up validation; on fail, it is not saved. Body { host, port, type?, username?, password?, remark?, changeIpUrl? }, type default http. Success response { added:true, proxyId, result } or fail { added:false, result:{status:"error",message} }.

Databases#

Connections for the RPA database block; table connections.

GET /api/databases/list
Database list: { message, count, databases:[{ id, name, type, filePath?|host?|port?|user?|database?|ssl? }] }.

GET /api/databases/get?id=N
One database: { message, database:{...} }.

POST /api/databases/create

Body:

FieldTypeDescription
namestringName
typesqlite/postgres/mysql/mssql/mongodb/redisDefault sqlite
filePathstring.db for sqlite
host/port/user/password/database-Network DBs
sslboolTLS
uristringConnection URI override
folderIdint/nullUI folder
isCreateFileboolFor sqlite, create .db in <dataDir>/databases/

Success: { "status":"success", "data": { "id":3, "name":"scratch", "type":"sqlite", "filePath":"..." } }.

POST /api/databases/update
PATCH by id; create fields + isFavorite, isDeleted, tagIds + selection.

POST /api/databases/delete
Soft-delete. Body { id } or { ids:[...] }.

POST /api/databases/hard-delete
Deletes connections, connections_tags_tag, and the disk file if filePath exists. Body { id } or { ids:[...] }.

Global vars#

Name/value from Settings → Environment variables, available in RPA as ${name}; table settings.

GET /api/global-vars/list
Response: { message, count, vars:[{ id, name, value, enable, isRestricted, owner }] }.

POST /api/global-vars/create
Body { name, value }; both must be unique. Duplicates: setting.error.duplicate_name / setting.error.duplicate_value.

POST /api/global-vars/update
Body { id, name?, value? }; same uniqueness rules.

POST /api/global-vars/delete
Body { id } / { ids:[...] } / { settingIds:[...] }.

Key catalog#

key_entity stores key names from account.settings and account_data_blob; values are in /api/accounts/vars.

GET /api/keys/list
Response: { message, count, keys:[{ id, key, createdAt }] }.

POST /api/keys/delete
Deletes only the name catalog, not account values. Body { ids:[...] } or { globalKeyIds:[...] }.

Scripts - RPA scripts#

GET /api/scripts/list
All non-deleted scripts with settings tree and form. Response: { message, count, scripts:[{ id, name, hash, form, settings, isFavorite }] }. form = input/select/checkbox fields passed into tasks through additionalData.

GET /api/scripts/get?id=N
Full structure of one script: { message, script:{ id, name, settings, form } }.

POST /api/scripts/create
Creates a script. Body { name, settings, form?, tab?, browser?, headlessMode?, noBrowser?, extraArgs? }.

Critical settings format:

  • elements[]: { id, start, type, left, top, label:"", hash:"", note:"", settings:{} }; left/top are on the element, not position.
  • Exactly one start:true; settings.startElement = its id.
  • connections[]: { sourceId, targetId, sourcePosition:"bottom|right|left", targetPosition:"top|left|right" }; targetPosition is required.
  • visualGroups: [] optional.

Success: { status:"success", data:{ id, hash, name, settings } }.

POST /api/scripts/update
PATCH by id: name, settings (full replacement), form, tab, browser, headlessMode, noBrowser, isFavorite, folderId, tagIds, extraArgs.

POST /api/scripts/run
Direct script run on a profile without task-group/tasks. Body { profileId:"<UUID>", scriptId:<numeric|hash>, closeBrowserAfter?:bool }; response { status:"success", uuid:"<task-uuid>" }.

GET /api/scripts/run-logs?uuid=UUID (or ?taskUuid=UUID)
Direct-run log (text/plain), equivalent to /api/tasks/logs for /api/scripts/run.

POST /api/scripts/stop
Stops a running script by task uuid. Body { uuid:"<task-uuid>" } or { taskUuid }; executor stops at the next await.

Modules - RPA modules (executeModule)#

A module is JS code for the executeModule block: module row + folder (index.js, utils_<id>.js, package.json, settings.json).

Workflow: POST /api/modules/create → edit files in moduleDirAbs (index.js without the IPC block process.on('message',...) and process.send({status:'ready'}), settings.json, package.json) → POST /api/modules/resign. Without a fresh Ed25519 signature, executor returns modules.error.signature_invalid.

GET /api/modules/list
Response: { message, count, modules:[{ id, hash, name, sig, moduleDir, moduleDirAbs }] }.

GET /api/modules/get?id=N (or ?hash=UUID)
Returns row + moduleDirAbs + top-level files.

POST /api/modules/create

Body:

FieldTypeDescription
namestringrequired
hashstringForce UUID
codestringUI mirror of index.js
settingsobject{ type:"module", fields:[{name,label,type:"text"|"number"|"checkbox"|"select",default,options?,groupId?}] }
folderIdint/nullFolder
tagIds[int]Tags
allowedFunctions[string]Node API whitelist
useCustomFolderboolUse customFolder
customFolderstringExisting folder

Success: { status:"success", data:{ id, hash, moduleDir, moduleDirAbs } }.

After create, the server runs npm install and signs the module in the background. Always call /api/modules/resign after editing files.

POST /api/modules/update
PATCH DB row, not files: id, name, code, moduleDir, warnReason, settings, allowedFunctions, hashes, warningFindings, flags isFavorite/isDirty/isMigrated/isWarn/requiresReview/isDeleted, folderId, tagIds + selection.

POST /api/modules/resign
Recalculate folder signature. Body { id }; success { status:"success", sig:"base64-ed25519-signature..." }.

POST /api/modules/delete
Soft-delete. Body { id } or { ids:[...] }; files remain.

POST /api/modules/hard-delete
Deletes module row, module_tags_tag, and folder. Body { id } or { ids:[...] }.

Task Groups#

Task group = schedule/retry/timeout/concurrency container. active=1 starts scheduler.

FieldTypeDescription
schedulebooltimeFrom/timeTo window
timeFrom / timeTostring HH:MMRequires schedule=true
scheduleTimeboolstartHour/endHour window
startHour / endHourint 0-23Requires scheduleTime=true
isRepeatableboolRepeat group
repeatCountintRepeat count
timeoutint sec0 = none
activeSessionintConcurrency; 0 = unlimited
waitForOtherTaskCompletionboolWait for other groups
folderIdint/nullUI folder

GET /api/task-groups/list
Response: { message, count, groups:[{ id, tag, active, schedule, timeFrom, timeTo, isRepeatable, timeout, activeSession }] }.

GET /api/task-groups/get?id=N
Group + tasks: { message, group:{...}, tasks:[{ id, uuid, scriptId, accountId, status, executeAt, additionalData }], tasksCount }.

GET /api/task-groups/tasks?groupId=N
Only group tasks. Statuses: waiting, working, finished, error, stop, stopWithError.

POST /api/task-groups/create
Creates an empty group. Body { tag?:string, name?:string, active?:bool, ...scheduleFields }. Recommended: active:false, then create tasks and call start.

POST /api/task-groups/update
PATCH by id: schedule fields, tag, active, isFavorite, isDeleted, isRescheduled.

POST /api/task-groups/start
Body { id } or { groupId }. Sets active=1; scheduler picks up waiting tasks. Idempotent, does not reset completed tasks.

POST /api/task-groups/restart
Body { id }. Sets active=1 and moves finished/error/stop/stopWithError back to waiting with executeAt=now().

POST /api/task-groups/stop
Body { id }. Sets active=0, moves working to stopWithError; does not close browsers.

POST /api/task-groups/delete or DELETE /api/task-groups/delete?id=N
Soft-deletes the group and its tasks (isDeleted=1, UI deleteGroup). POST body { id }.

POST /api/task-groups/hard-delete
Deletes group tasks and the group row. Body { id } or { ids:[...] }; response contains deletedGroups, deletedTasks, ids.

Tasks#

Task = scriptId × accountId with executeAt, status, additionalData, sort.

POST /api/tasks/create
Creates one/multiple tasks in a group in one transaction. Body { groupId:int, tasks:[...] }; response { message, created, requested, errors }.

Task field:

FieldTypeDescription
accountIdintrequired, account.id
scriptIdint/stringrequired
additionalDataobjectscript form fields; otherwise defaultValue
executeAtISO stringdefault now
tagstringLabel
sortintOrder

additionalData is critical when a script has form fields.

POST /api/tasks/update
PATCH by id or taskId: status, tag, description, executeAt, sort, additionalData.

GET /api/tasks/list
Flat task list; AND filters.

Query params:

ParameterTypeDescription
statusstringCSV: working,waiting,finished,error,stop,stopWithError
groupIdintGroup
accountIdintaccount.id
scriptIdint/stringScript
limitintDefault 500, max 5000

Response: { message, count, tasks:[{ id, uuid, status, groupId, accountId, scriptId, executeAt }] }.

GET /api/tasks/active
All working tasks with account:{id,name,accountId} and script:{id,name}.

POST /api/tasks/delete
Permanently deletes tasks. Body { id } or { ids:[...] }.

POST /api/tasks/stop
Stops working/waiting: status → stop + abort executor by uuid; closeBrowser:true additionally closes the account browser. Body { id } / { ids:[...] } / [{ id, uuid? }, ...], plus closeBrowser?:bool.

Logs#

GET /api/tasks/logs?taskUuid=UUID (or ?taskId=UUID)
Text log by task.uuid (not numeric task.id), Content-Type: text/plain; charset=utf-8; can be read during execution. 404 if the file has not been created yet or was deleted.

GET /api/scripts/run-logs?uuid=UUID
Same for direct runs through /api/scripts/run.

Emails - IMAP credentials#

GET /api/emails/list
List of IMAP credentials; passwords are not returned. Response: { message, count, emails:[{ id, email, imapServer, port, isActive, mailboxes }] }.

POST /api/emails/toggle
Enables/disables IMAP monitoring: updates isActive and synchronously opens/closes the connection. Body { email:"user@gmail.com", isActive:true }.

Error format#

Error: HTTP 4xx/5xx + { "error": "Описание ошибки" }.

  • 400 - required fields / JSON.
  • 401 - invalid/missing x-api-key (connection closes).
  • 404 - resource not found.
  • 500 - DB/internal error.
  • Some business errors use HTTP 200 with { status:"error", code:"<i18n-key>", message:"..." }, for example duplicate global vars.

Full example: run a scheduled script on accounts#

Scenario: MintNFT (scriptId=12) with walletPassword, mintCount on accounts 42/43/44, window 08:00-20:00, repeat 2, concurrency 5, start 2026-05-11T09:00:00.000Z.

  1. Find IDs: GET /api/scripts/list; GET /api/profiles/list?groupId=5.
  2. Check/warm up proxy: POST /api/proxies/check, body { accountIds:[42,43,44] }.
  3. Create group: POST /api/task-groups/create, body { tag:"MintNFT run", active:false, schedule:true, timeFrom:"08:00", timeTo:"20:00", isRepeatable:true, repeatCount:2, activeSession:5 }; save group.id.
  4. Create tasks: POST /api/tasks/create, body { groupId:7, tasks:[{accountId:42,scriptId:12,additionalData:{walletPassword:"pwd1",mintCount:"1"},executeAt:"2026-05-11T09:00:00.000Z"}, ...] }.
  5. Activate: POST /api/task-groups/start, body { id:7 }.
  6. Monitor: GET /api/task-groups/get?id=7, then GET /api/tasks/logs?taskUuid=<uuid>.
  7. Control: GET /api/tasks/active; POST /api/tasks/stop with { ids:[100], closeBrowser:true }; POST /api/task-groups/restart; POST /api/task-groups/hard-delete.

Direct browser connection via CDP#

After POST /api/profiles/start, use wsEndpoint.

js

Python pyppeteer: browser = await connect(browserWSEndpoint=ws_endpoint). Without a CDP client, use POST /api/profiles/eval and POST /api/profiles/screenshot.

OAuth callback#

GET / or GET /oauth/callback?code=AUTH_CODE does not require api-key; it is used for Google Drive OAuth flow, passes code to the internal channel, and closes the window.

Source of truth#

Implementation: src-tauri/src/browser/server.rs. If endpoint behavior differs from the docs, source code is source of truth.

Related glossary