Docs
Request a chain, send it steps, check how it is going.
The idea
The servers are real browsers, each one a persona: one person in one place, on its own machine. A server does one chain at a time, and a chain can take up to an hour. botproxy sits in front of them: you ask for a chain, it waits in a queue until a server is free, then the server is yours until the chain ends.
- Chain
- Your turn on one server. It has one plugin and a list of steps, all run on the same server, in order, in a browser tab of its own.
- Step
- One operation of the chain's plugin, with its payload. Each step has a result.
- Plugin and operation
- What a server can do, e.g. plugin
bper, operationinit-login. List them with their parameters. A plugin that needs an account (Instagram, a bank) only runs on personas logged into it; each such plugin needs one account, so it runs either whole or not at all on a persona. - Class
- Set on your account by the admin. It gives your chains their priority, how long the server is held for you, and whether you may use raw.
Get access
Your admin creates your client and gives you an API key (starts with brk_, shown only once). Send it on every request:
export BR=https://botproxy.example.com
export KEY=brk_...
curl -H "Authorization: Bearer $KEY" $BR/api/chains
No key yet? Ask for one with the email and password you were given:
curl -X POST $BR/api/api-key \
-H 'Content-Type: application/json' \
-d '{"identity": "pc1@example.com", "password": "..."}'
# {"api_key": "brk_..."}
Each call makes a new key and the old one stops working at once (keys are stored hashed, so they cannot be shown again). Ask once and keep it.
Keep the key secret. If it leaks, get a new one with the call above (or ask the admin): the old one stops working at once.
Find plugins
The plugins you can request a chain of right now (at least one server has them on), each with its operations and their parameters:
curl -H "Authorization: Bearer $KEY" $BR/api/plugins
[{"name": "instagram", "operations": [
{"name": "open-account", "params": [{"name": "handle", "type": "text", "required": true}]},
...]}]
Only names and parameters, as the servers report them. raw is listed only if you may use it.
Request a chain
curl -X POST $BR/api/chains \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"plugin": "bper"}'
201 {"id": "k3x9...", "state": "queued"}
The chain is queued. Keep its id. Optional fields:
| Field | Meaning |
|---|---|
country | Two lowercase letters, e.g. de: run on a persona in that country. See Choose a country. |
labels | An object, e.g. {"instagram": "alice"}: run only on a persona with all of these labels. See Choose by labels. |
wait_for_me | true: keep the server after the last step, so you can decide what to do next. See Keep the server. |
webhook_url | Called when the chain changes state. See Webhooks. |
preferred_server | Server name you would like. Only a hint, and only for privileged clients. An unknown name is refused with 400. |
steps | The chain's steps, already inside the request: a list of {operation, payload, idempotency_key}, run in that order. idempotency_key is optional here (it defaults to the step's number). See below. |
The whole chain in one request
You do not have to talk to botproxy at every step. If you know the steps up front, put them in the request: the chain is created with them, waits in the queue, and runs them one after the other on its server, on its own.
curl -X POST $BR/api/chains \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"plugin": "instagram", "steps": [
{"operation": "open-account", "payload": {"handle": "alice"}},
{"operation": "info-profile", "payload": {}},
{"operation": "get-posts-profile", "payload": {}}]}'
201 {"id": "k3x9...", "state": "queued", "closes_in_s": null,
"steps": [{"seq": 1, "state": "pending"}, {"seq": 2, "state": "pending"}, {"seq": 3, "state": "pending"}]}
It is all or nothing: if one step is invalid (an operation not in the plugin, a missing parameter), nothing is created and the answer says which step. You can still add more steps later, as below. Then come back for the results whenever you like.
Choose a country
Some personas are always on. Others sleep, and botproxy starts them when a chain needs them, often abroad behind a local connection.
- No
country(the default): the chain goes to any server that is already on. It never starts a sleeping one: if none is on, it waits in the queue. - With
country: the chain only goes to a persona in that country, and may start a sleeping one. Starting a browser takes a while (a minute or more), and botproxy stops it again when the chain ends. A persona that is already on is used first.
A country where no server can run your plugin is refused with 400 right away. If the persona there cannot start for now, the chain simply stays queued and is tried again later.
Choose by labels
Each persona has labels, key: value pairs that say what it is. Ask for some with labels, and the chain runs only on a persona that has every one of them, with those values:
curl -X POST $BR/api/chains \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"plugin": "instagram", "labels": {"instagram": "alice"}}'
Two kinds:
- Built-in:
country,cityandproxy(residentialormobile). They come from the persona's own settings, which it runs by, so they are true while it runs.proxyis there only for a persona that goes through a proxy, abroad: one at home has none. - Set by the admin, for something done once by hand on that persona. For example
instagram: alicewhen it is logged into the Instagram accountalice. Nobody checks them: ask the admin which ones exist.
Use them when the persona matters, not only the plugin: the account that follows a private page, a mobile connection for a site that blocks the others. Like country, labels may start a sleeping persona, and a chain waits in the queue for a persona that has them rather than take another. Labels no enabled server has, together with your plugin, are refused with 400. See How the queue works for how they affect the wait.
Labels are checked once, when you request the chain. If the admin later changes the persona's labels, or disables the only server that has them, the chain stays queued with nothing to take it: a queued chain never expires on its own. If yours waits longer than you expect, check the labels (ask the admin) and close it.
Where your chain ran. Once a chain has a server, the chain you read back carries server_labels: every label of that persona, not only the ones you asked for, as the admin last imported them. So you can tell, for example, which Instagram account read a page without having asked for one. That and the server's name are all you see of it: never its address or its key.
Send steps
Besides the steps sent with the chain, you can add steps one by one, right away or later, even while the chain is still queued. They wait and run in order once a server is assigned.
curl -X POST $BR/api/chains/k3x9.../steps \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"operation": "init-login", "payload": {}, "idempotency_key": "init-login"}'
202 {"seq": 1, "state": "pending", "closes_in_s": 120}
The operation must belong to the chain's plugin, and the payload must carry its required parameters, or the step is refused with 400. Payloads up to 10 MB; results up to 50 MB.
The idempotency_key is yours to choose, unique within the chain. Sending the same key again does not add a second step, so retrying after a network error is safe.
A chain without wait_for_me ends as soon as it has no step left to run. Send all its steps before the last one finishes.
How a chain runs
- Its own tab. Once the chain has a server, botproxy opens a browser tab just for it and locks it: no one else can use that tab until the chain ends. Each step starts where the previous one left the page (a step that opens a profile, then one that reads it). When the chain ends the tab is closed: nothing carries over to your next chain, except what the persona itself keeps, like its logins.
- Waiting for the first step. A chain can get its server before you sent any step. It then waits for the first one for as long as you may hold a server (state
idle); if none comes, it becomesexpired. - The result of a step is the operation's answer, as JSON, exactly as the server gave it, except for the links to files it made, which point to botproxy (Download files).
- A step fails when the server answers with an error, or answers
"success": false. Its result says why. For example a plugin that needs an account the persona is not logged into fails with a403in its result. - A busy tab (still finishing something) is waited for, up to about a minute, before the step fails.
- A step cannot be cancelled once it runs: closing the chain lets it finish first.
Check status
All your chains, newest first (optionally ?state=running):
curl -H "Authorization: Bearer $KEY" $BR/api/chains
One chain, with its steps:
curl -H "Authorization: Bearer $KEY" $BR/api/chains/k3x9...
{"id": "k3x9...", "plugin": "bper", "state": "running", "server": "s1", "closes_in_s": 120,
"server_labels": {"country": "it", "city": "milano"},
"steps": [{"seq": 1, "operation": "init-login", "state": "running"}]}
server_labels is there once the chain has a server: see Choose by labels.
One step, with its result:
curl -H "Authorization: Bearer $KEY" $BR/api/chains/k3x9.../steps/1
Reading a result deletes it. Once the step has ended, the answer to this call carries the result and botproxy then drops it: the step is still there, with "result_pruned": true and no result. To read it without deleting it, for example while you poll or if you may need to read it again, add ?do-not-consume=true, and delete it yourself when you are done:
curl -H "Authorization: Bearer $KEY" "$BR/api/chains/k3x9.../steps/1?do-not-consume=true"
curl -X DELETE -H "Authorization: Bearer $KEY" $BR/api/chains/k3x9.../steps/1/result
If the answer is lost on the way (a network error), a plain read has already dropped the result: if you cannot afford that, read with do-not-consume first. Polling GET /chains/{id} never deletes anything.
Results nobody reads are kept for 1 day after the step ends (your admin may set a different time), then dropped the same way.
Download files
Some operations make a file (e.g. instagram/get-post-media, a post's photos or video). Its result does not carry the bytes but a link, which botproxy rewrites to its own address:
{"success": true, "format": "zip", "media_url": "/api/chains/k3x9.../steps/4/media/0", ...}
Fetch it with your key, like any other call. botproxy streams the file from the server that ran the step, it keeps no copy:
curl -H "Authorization: Bearer $KEY" -o post.zip $BR/api/chains/k3x9.../steps/4/media/0
- Once, and soon. A file can be downloaded once, within 10 minutes of the end of its step; after that it answers
404. So a file is not something to send and forget: poll the step, or use a webhook, and fetch it right away. - Before you close. Fetch it before the chain closes: some servers are switched off when a chain ends, and their files go with them. With
wait_for_me, fetch, then close. - Reading the result does not touch the file: the link still works after the result is dropped.
- Any link in a result is rewritten, under any field, numbered from
0in the order they were found.
Chain states
queued | Waiting for a server. |
leased | Got a server, no step started yet. |
running | A step is running. |
idle | Holding the server for you, waiting for a new step (wait_for_me), or for the first one. |
closed | Done: all steps ran (some may have failed: check them), or you closed it. |
expired | You did not send a step in time; the server was released. |
failed | The server was lost after some steps had run (closed_reason: server_lost). |
Step states
pending → running → done or failed. If a step fails, the steps queued after it are not run: they are marked failed too. Then the chain ends as usual: closed, or idle with wait_for_me, so you can decide what to do.
Keep the server
Sometimes the next step depends on a result. Create the chain with "wait_for_me": true: when its last step ends, the chain goes idle and the server stays yours for a while (the admin sets how long, client by client). Then either:
- send a new step: the chain runs again, and the wait starts over after it;
- close the chain when you are done;
- do nothing: after the wait the chain becomes
expiredand the server goes to the next in line.
Every answer about a chain (creating it, adding a step, reading it, its steps or the list) carries closes_in_s: the seconds left before it expires if you send nothing more.
- A number that goes down: the chain is waiting for you (
idle, or a server is assigned and no step was sent yet). At0it expires within a few seconds. - The whole wait (e.g.
120), withwait_for_meand a step still to run: the clock is stopped and starts again from there when the step ends. null: nothing counts down. The chain is queued, has ended, or is running its last steps withoutwait_for_me(it then closes).
Only a new step restarts the clock; reading the chain or a step does not. If you need time (a 2FA on your phone, say), watch closes_in_s and send the next step before it reaches 0. If the wait is too short for you, ask the admin to raise it.
Close as soon as you know you are done: someone else may be waiting for that server.
Close a chain
curl -X DELETE -H "Authorization: Bearer $KEY" $BR/api/chains/k3x9...
Frees the server. A queued chain leaves the queue. A running step cannot be stopped: the server finishes it first. Steps that had not started yet are marked failed.
How the queue works
- There is one queue for everyone. A chain gets no server when you request it: the server is chosen only when one frees up.
- When a server frees up, the waiting chain with the highest priority gets it, among those it can serve: the server has the chain's plugin, is in its country if it asked for one, has all its labels if it asked for some, and is on (or, for a chain with a country or labels, can be started). The chain then keeps that server until it ends.
- Labels and country filter, they do not rank. They only decide which servers a chain may take; among the chains a server can take, priority alone decides. So a chain with labels does not hold up anyone else: other chains keep taking the servers it cannot use. It just has fewer servers to wait for, and may wait longer, its priority growing all the while like everyone's. The more labels, the fewer servers match.
- Priority = your base priority (set by the admin) + a little more for every second waited, so every chain gets its turn.
- Some servers go first to privileged clients.
- Some servers rest a while after a chain before taking the next one.
- A running chain is never interrupted for a higher-priority one.
- If a server is lost before any of your steps ran, nothing is lost: the chain goes back in the queue by itself, a little ahead.
- If a server is lost after some steps ran, the page they built is gone with it: the chain fails with
server_lost, and its unfinished steps fail too. Request it again from the start.
Webhooks
With webhook_url, botproxy calls you: it sends a POST to that URL every time the chain changes state. You need a server listening there, reachable from the internet (no localhost or private addresses).
- The call says what changed. It does not carry step results: fetch them with
GET.POST <your webhook_url> Content-Type: application/json {"event": "chain.state", "chain": "k3x9...", "state": "closed", "previous": "running", "closed_reason": "done", "at": "2026-10-27 18:04:11.512Z"} - Calls can arrive out of order when one is retried:
atsays when the state changed, so keep the latest. - Answer with any
2xxstatus to confirm. - No
2xx? botproxy tries 3 times in all: right away, then after 5 seconds, then after 10 more (your admin may set longer waits). After that it gives up on that event.
Webhooks are a shortcut, not a guarantee: if you miss one, poll the chain to catch up.
The raw plugin
For trusted clients only (anyone else gets 403): instead of a site's plugin, drive the browser yourself. Request a chain with "plugin": "raw" and send steps whose operation is one of the browser's own calls. They act on the chain's tab: you never name a tab.
| Operations | Do |
|---|---|
ops/navigate, ops/extract-content, ops/screenshot, ops/network, ops/search-object | Go to a URL, read the page's HTML, take a screenshot, read the network traffic, find something on the page. |
primitive/click-element, primitive/type-text, primitive/scroll-page, ... | Click, type, scroll, hover, press keys, like a person would. |
system/ping, system/status, system/status/pages, system/metrics | Read how the server is doing. Nothing that stops or restarts it. |
The exact list and parameters are what the server reports; your admin can show them. Raw steps run while you wait: a navigate gives up after a minute.
ID=$(curl -s -X POST $BR/api/chains -H "$H" -H "$J" -d '{"plugin": "raw", "country": "de"}' | jq -r .id)
curl -s -X POST $BR/api/chains/$ID/steps -H "$H" -H "$J" \
-d '{"operation": "ops/navigate", "payload": {"url": "https://example.com"}, "idempotency_key": "go"}'
curl -s -X POST $BR/api/chains/$ID/steps -H "$H" -H "$J" \
-d '{"operation": "ops/extract-content", "payload": {}, "idempotency_key": "read"}'
The persona may be logged into accounts: raw browses with them. Trusted means trusted with those too.
Full example
Log in to the bank, then check where it got before deciding the next step, holding the server in between.
H="Authorization: Bearer $KEY"
J='Content-Type: application/json'
# 1. Request a chain that keeps its server
ID=$(curl -s -X POST $BR/api/chains -H "$H" -H "$J" \
-d '{"plugin": "bper", "wait_for_me": true}' | jq -r .id)
# 2. Open the login form and submit it (queued until a server is free)
for op in init-login hydrate-login; do
curl -s -X POST $BR/api/chains/$ID/steps -H "$H" -H "$J" \
-d "{\"operation\": \"$op\", \"payload\": {}, \"idempotency_key\": \"$op\"}"
done
# 3. Wait for them to end (do-not-consume: polling must not drop the result)
step() { curl -s -H "$H" "$BR/api/chains/$ID/steps/$1?do-not-consume=true" | jq -r .state; }
while [ "$(step 2)" = pending ] || [ "$(step 2)" = running ]; do sleep 10; done
# 4. Approve on the phone; closes_in_s says how long the server stays yours
curl -s -H "$H" $BR/api/chains/$ID | jq .closes_in_s
# 5. Then check where the login got
curl -s -X POST $BR/api/chains/$ID/steps -H "$H" -H "$J" \
-d '{"operation": "read-state", "payload": {}, "idempotency_key": "state-1"}'
# 6. ...and when you are done, free the server
curl -s -X DELETE -H "$H" $BR/api/chains/$ID
Send and forget
When you know every step up front, you do not need to stay around: no wait_for_me, no webhook_url, no polling while it runs. Send the whole chain in one request, then come back later for the results.
# One request: the chain and all its steps
ID=$(curl -s -X POST $BR/api/chains -H "$H" -H "$J" -d '{"plugin": "instagram", "steps": [
{"operation": "open-account", "payload": {"handle": "alice"}},
{"operation": "open-account", "payload": {"handle": "bob"}},
{"operation": "open-account", "payload": {"handle": "carol"}}]}' | jq -r .id)
# ...later (within a day): the chain is closed, the results are there
curl -s -H "$H" $BR/api/chains/$ID
curl -s -H "$H" $BR/api/chains/$ID/steps/1 # reads and drops the result
Unread results stay for 1 day after each step ends. A webhook is optional: set webhook_url only if you want to know the moment it ends.
Reference
All paths start with /api. Every request needs Authorization: Bearer <key>.
| Request | Does |
|---|---|
POST /api-key | No auth. Body: identity (email), password. Returns a new api_key, replacing the old one. |
GET /plugins | Plugins you can request now, with their operations and parameters. |
POST /chains | Request a chain. Body: plugin, optional country, labels, wait_for_me, webhook_url, preferred_server, steps ([{operation, payload, idempotency_key?}], created with the chain, all or nothing). |
GET /chains | Your chains. Optional ?state=. |
GET /chains/{id} | One chain and its steps. |
POST /chains/{id}/steps | Add a step. Body: operation, payload, idempotency_key. |
GET /chains/{id}/steps/{seq} | One step and its result. Drops the result of an ended step once sent, unless ?do-not-consume=true. |
DELETE /chains/{id}/steps/{seq}/result | Drop an ended step's result now. 204, also when already dropped; 409 while the step runs. |
GET /chains/{id}/steps/{seq}/media/{n} | Download the step's n-th file (the link in its result), streamed. Once, within 10 minutes of the step's end. |
DELETE /chains/{id} | Close the chain, free the server. |
Errors
Errors come as {"status": 400, "message": "...", "data": {...}}, with field details in data.
400 | Invalid body, e.g. an operation not in the chain's plugin, a missing required parameter, or a country or labels no server has together with the plugin. |
401 | Missing, wrong or revoked key, or your client is disabled. |
403 | The raw plugin, or another plugin your admin keeps for trusted clients, and you are not trusted. |
404 | No such chain or step (or not yours); or a file already downloaded, too late, or not in the step's result. |
409 | The chain is already closed, expired or failed; or a result is dropped while its step still runs. |
502 | The server that ran the step could not send the file (switched off, unreachable). |