---
name: sophtron-browser-stack
version: 0.6.0
description: Drive a real browser through the Sophtron Browser Stack API. Observe a page as an accessibility tree with element refs plus a screenshot, then click, type, scroll or navigate by ref. Type passwords and other secrets with per-session RSA encryption, or hand a one-time secure form to your sign-in vault.
homepage: https://unblock.sophtron.com
metadata: {"api_base": "https://unblock.sophtron.com/api/v1"}
---

# Sophtron Browser Stack

Sophtron runs a real browser for you. You drive it with a small REST API: **observe** the page, decide what to do, send one **action**, and repeat.

**Base URL:** `https://unblock.sophtron.com/api/v1`

## 1. Register (once)

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/agents/register \
  -H "Content-Type: application/json" \
  -d '{"name": "my-agent", "description": "What I do"}'
```

```json
{"success": true, "data": {"agent_id": "…", "api_key": "sk_sph_…", "expires_at": "…"}}
```

**Save `api_key` now.** It is shown once. Send it as `Authorization: Bearer <api_key>` on every other call, and only to `https://unblock.sophtron.com`. Keys expire after 30 days; then calls answer `401 unauthorized` and your sessions are deleted. Register again for a new key.

## 2. Quick start

```bash
KEY="sk_sph_…"

# Init: url is required and declares the session's target site and navigation scope (§6.4).
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"url": "https://www.example.com/"}'
# {"success": true, "data": {"session_id": "…", "state": "creating", "expires_at": "…",
#   "sensitive_text_key": {"algorithm": "RSA-OAEP-256", "key_id": "…",
#     "public_key": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
#     "max_plaintext_bytes": 446}}}

# Observe until state is "ready". Wait a few seconds between calls.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/observe -H "Authorization: Bearer $KEY"

# Act on a ref from your latest observe.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/action -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" -d '{"action_type": "click", "action_parameters": {"ref": "e12"}}'

# Close when done; the session is deleted once its browser closes.
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/close -H "Authorization: Bearer $KEY"
```

If an init response omits `sensitive_text_key`, never fall back to plaintext: close the session and init again. §5.1 shows how to use the key.

## 3. Reading an observe

When `state` is `ready`, observe returns:

- **`aria_snapshot`** (YAML): a `tabs:` header (index, title, url, active), then the accessibility tree, one element per line, for example `- button "Checkout" [ref=e12]`. Elements you can act on carry `[ref=eN]`; long runs of similar items may be folded.
- **`screenshot`** (`{media_type, data}`): base64 of the visible page with each ref's box and label drawn on it. JPEG, or PNG when nothing is labeled.

**View the screenshot if you can read images** — it shows layout, images and charts the tree can't. Don't read the base64 as text. Show screenshots to your user when you can.

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/observe -H "Authorization: Bearer $KEY" > observe.json
jq -r '.data.aria_snapshot' observe.json
jq -r '.data.screenshot.data' observe.json | base64 -d > screenshot.jpg   # .png if image/png
```

Refs are renumbered on every observe: use only refs from your **latest** observe. Before the browser is ready, observe returns only `state` and `reason`. Once the browser closes, the session is deleted and calls return `session_not_found`.

## 4. Actions

`POST https://unblock.sophtron.com/api/v1/sessions/{session_id}/action` with `{"action_type": "…", "action_parameters": {…}}` (omit `action_parameters` for `back`, `forward` and `reload`):

| `action_type` | `action_parameters` | What it does |
|---|---|---|
| `click` | `ref` | Clicks the element; clicking an `option` selects it. |
| `input` | `ref`, `text` | Replaces the field's text (`text` ≤ 10,000 chars, may be empty). |
| `set_sensitive_text` | `ref`, `encrypted_text`, optional `key_id` | Decrypts a secret made with this session's key and types it. Only ciphertext is accepted; see §5.1. |
| `press_key` | `key` | `enter`, `tab`, `backspace`, `escape`, `arrowup`, `arrowdown`, `arrowleft`, `arrowright` or `ctrl+a`. |
| `scroll` | `delta_x`, `delta_y` | Scrolls by pixels: each −10,000…10,000, at least one non-zero; positive scrolls down or right. |
| `navigate` | `url` | Opens a URL and waits up to 30 s. It must stay inside the init URL's navigation scope (§6.4). |
| `back`, `forward`, `reload` | none | History navigation or reload. |
| `switch_tab` | `tab_index` | Switches to a tab from the `tabs:` header (`0` or more). |

Refs match `^e[0-9]+$` and come from your latest observe. Each action answers with a message, for example `{"success": true, "data": {"message": "Clicked button \"Checkout\"."}}`; observe again to see the page. There is no wait action: wait on your side before observing.

## 5. Secrets

Never send a password, one-time code, PIN, card number, API key or security answer as `input.text`. Use `set_sensitive_text`, or the secure form when your vault can fill a page but cannot call the API.

### 5.1 Typing secrets safely

Init returns this session's public key:

```json
"sensitive_text_key": {
  "algorithm": "RSA-OAEP-256",
  "key_id": "9f2c…",
  "public_key": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
  "max_plaintext_bytes": 446
}
```

Encrypt the UTF-8 bytes of the secret with RSA-OAEP using SHA-256 for both the OAEP digest and MGF1, with an empty label. The key is 4096-bit, so the ciphertext is exactly 512 bytes; send it as standard base64 with padding. The plaintext may be at most `max_plaintext_bytes` (446) bytes. `key_id` is optional but recommended: a stale key then fails clearly.

```bash
# OpenSSL 1.1.1+. secret.bin is exactly the UTF-8 bytes to type (no added newline).
jq -r '.data.sensitive_text_key.public_key' init.json > public.pem
KEY_ID=$(jq -r '.data.sensitive_text_key.key_id' init.json)
openssl pkeyutl -encrypt -pubin -inkey public.pem \
  -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256 \
  -pkeyopt rsa_mgf1_md:sha256 -in secret.bin -out ciphertext.bin
base64 ciphertext.bin | tr -d '\n' > encrypted_text.txt
```

```js
// Node.js 18+
const { publicEncrypt, constants } = require('node:crypto');
const key = init.data.sensitive_text_key;              // this session's init response
const bytes = Buffer.from(secret, 'utf8');
if (bytes.length > key.max_plaintext_bytes) throw new Error('secret is too long');
const encrypted_text = publicEncrypt(
  { key: key.public_key, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
  bytes
).toString('base64');
```

```python
# Python 3 (cryptography)
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding

key = init["data"]["sensitive_text_key"]
data = secret.encode("utf-8")
if len(data) > key["max_plaintext_bytes"]:
    raise ValueError("secret is too long")
public_key = serialization.load_pem_public_key(key["public_key"].encode("ascii"))
encrypted_text = base64.b64encode(public_key.encrypt(
    data, padding.OAEP(mgf=padding.MGF1(hashes.SHA256()),
                       algorithm=hashes.SHA256(), label=None))).decode("ascii")
```

```bash
jq -n --arg ref "e7" --arg encrypted "$ENCRYPTED_TEXT" --arg key_id "$KEY_ID" \
  '{action_type: "set_sensitive_text",
    action_parameters: {ref: $ref, encrypted_text: $encrypted, key_id: $key_id}}' \
  | curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/action \
      -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" --data-binary @-
```

The action uses the same ref and text-field checks as `input`; only `ref`, `encrypted_text` and `key_id` are accepted. A `text` field or any other key is `400 invalid_request`. The key belongs to its session: a new session gets a new key, and another session's ciphertext fails. If you lost the key, close the session and init again. The website still receives the plaintext when it is typed; it never appears in your request.

### 5.2 Signing in with a secure form

Use this when you cannot pass credentials through `set_sensitive_text`, for example when your vault can fill a form but cannot put plaintext into an API call.

1. **Observe the login page** and note the refs of the fields.
2. **Create a one-time form** immediately (within 120 s of that observe):

```bash
curl -s -X POST https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/secure-inputs \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"title": "PSE login", "fields": [
        {"ref": "e3", "kind": "username"},
        {"ref": "e5", "kind": "password"}]}'
# {"success": true, "data": {"secure_input_id": "…", "state": "pending",
#   "expires_at": "…", "url": "https://…/secure-input?id=…#t=…"}}
```

`fields` accepts 1–4 entries with unique refs and unique kinds: `username`, `password`, `otp` or `text` (an optional `label` names a `text` field). `title` ≤ 100, `label` ≤ 60. The form uses the refs and target host from your latest observe; creating a new form cancels a pending one.

3. **Fill the form.** If your environment can open a page and your vault can fill it, open `url` in the vault's browser — **not the Sophtron session browser**; the session must stay on the target page. Otherwise give `url` to the user (it works on a phone): tell them the target host, that the link is one-time and expires in 5 minutes, that their entries are encrypted and never stored by Sophtron, and that they must press **Send credentials**. The URL is a bearer capability: don't log it or post it publicly.

4. **Ask for completion.** If the user is filling it, offer **Done / Still filling / Cancel**, and poll the status rather than observing; the server status is authoritative, so polling decides when to proceed. `applied` → continue; `applying` → wait and poll; still `pending` after a few seconds → ask them to press Send credentials; `failed` → read the message, observe, and decide whether to create a new form; `expired`/`cancelled` → create a new form. If there is no reply, keep polling; when the form expires, tell the user and create a new one when they are ready.

```bash
curl -s https://unblock.sophtron.com/api/v1/sessions/$SESSION_ID/secure-inputs/$SECURE_INPUT_ID \
  -H "Authorization: Bearer $KEY"
# {"success": true, "data": {"secure_input_id": "…", "state": "applied",
#   "expires_at": "…", "message": "Typed into textbox \"Username\" and textbox \"Password\"."}}
```

The submit is accepted immediately as `applying`; poll until `applied` or `failed`. While the form is `pending` or `applying`, observe and action return `409 secure_input_pending`; don't observe to "check", because a new snapshot renumbers refs. `DELETE /sessions/{id}/secure-inputs/{secure_input_id}` cancels a **pending** form and unblocks the session. After `applied`, observe if the page changed, then continue the sign-in.

The page encrypts each value in the browser with the session's key (RSA-OAEP-SHA256, one 512-byte ciphertext per field) and posts only ciphertext; the worker decrypts it and types it. The plaintext never enters your context, the API, or logs; the website still receives it. The form accepts at most 300 bytes of plaintext across all fields.

## 6. API reference

All paths are relative to `https://unblock.sophtron.com/api/v1`. §6.5 covers the token-only endpoints used by the form page; agents must not call those.

### 6.1 Conventions

- **Auth.** `Authorization: Bearer <api_key>` on every call except `POST /agents/register` and the form-page endpoints. Missing, invalid, disabled or expired keys answer `401 unauthorized`.
- **JSON.** Request bodies are JSON (`Content-Type: application/json`), capped at 64 KiB. Field names are snake_case. Null optional fields are omitted.
- **Envelopes.** Success `{"success": true, "data": {…}}`; error `{"success": false, "error": {"code": "…", "message": "…"}}`.
- **IDs and timestamps.** IDs are UUID strings. Timestamps are ISO 8601 with an offset, for example `2026-10-21T16:15:40.471+00:00`.
- **Statuses.** `200` for reads, close, delete, secure-input status/cancel; `201` for register, init, secure-input create. Errors use §7; `rate_limited` (429) and `no_capacity` (503) send `Retry-After` seconds, `session_limit` does not.
- **Unknown fields.** Most endpoints ignore unknown body properties; secure-input create is strict and rejects unknown or duplicate properties.

### 6.2 Endpoints

| Method | Path | Auth | Success |
|---|---|---|---|
| POST | `/agents/register` | none | 201 |
| POST | `/sessions` | agent | 201 |
| GET | `/sessions` | agent | 200 |
| POST | `/sessions/{session_id}/observe` | agent | 200 |
| POST | `/sessions/{session_id}/action` | agent | 200 |
| POST | `/sessions/{session_id}/close` | agent | 200 |
| DELETE | `/sessions/{session_id}` | agent | 200 |
| POST | `/sessions/{session_id}/secure-inputs` | agent | 201 |
| GET | `/sessions/{session_id}/secure-inputs/{secure_input_id}` | agent | 200 |
| DELETE | `/sessions/{session_id}/secure-inputs/{secure_input_id}` | agent | 200 |

`{session_id}` and `{secure_input_id}` are UUIDs. Field lists below are the `data` object. Failures use §7.

**`POST /agents/register`** (no auth; 5 registrations per IP per hour by default): request `name` (required; 1–64 chars: letters, digits, space, `.`, `_`, `-`) and `description` (optional; ≤ 500); response `agent_id`, `api_key` (shown once), `expires_at` (`30` days).

**`POST /sessions`** (init): request `url` (required; http/https, ≤ 2048 chars, no local or private hosts; `https://` is added when there is no scheme) and omit `session_id` (sessions can't be reopened; any non-empty value is `400 invalid_request`); response `session_id`, `state` (`creating`), `expires_at` (run deadline, 30 min by default), `sensitive_text_key` (§6.3; if omitted, init again).

**`GET /sessions`**: response `sessions: [{session_id, state, last_used_at}]`, this agent's open sessions, most recently used first.

**`POST /sessions/{session_id}/observe`** (no body; it takes a fresh snapshot and renumbers refs): response `session_id`, `state`, optional `reason` (when the run ends or fails, for example `agent_closed`, `max_lifetime`, `launch_failed`, `browser_crashed`, `worker_lost`), `aria_snapshot` (only when ready), `screenshot: {media_type: image/jpeg|image/png, data: base64}` (only when ready).

**`POST /sessions/{session_id}/action`**: body `action_type` + `action_parameters`; the types and parameters are in §4; response `message`.

**`POST /sessions/{session_id}/close`** (no body): response `session_id`, `state` (`closing`, or the stored final state).

**`DELETE /sessions/{session_id}`**: closes a running browser, waits for it to end, deletes the session and frees the slot; response `deleted: true`.

**`POST /sessions/{session_id}/secure-inputs`** (ready session; observe within 120 s): request `title` (optional; ≤ 100; default `Secure sign-in`) and `fields` (1–4; each `ref` (required; `^e[0-9]+$`, ≤ 16), `kind` (required; `username`, `password`, `otp` or `text`; unique), `label` (optional; ≤ 60)); response `secure_input_id`, `url` (one-time; token in the fragment), `state` (`pending`), `expires_at` (5 min, capped by session expiry).

**`GET /sessions/{session_id}/secure-inputs/{secure_input_id}`**: response `secure_input_id`, `state` (`pending`, `applying`, `applied`, `failed`, `expired` or `cancelled`), `expires_at`, optional `error_code` (set when `failed`), optional `message`.

**`DELETE /sessions/{session_id}/secure-inputs/{secure_input_id}`**: cancels only a `pending` form; response `secure_input_id`, `state` (`cancelled`).

### 6.3 Common objects

**Session states:** `creating` → `launching` → `ready` → `closing` → `closed`, or `failed`. Before ready, observe returns only `session_id`, `state` and usually `reason`.

**Secure-input states:** `pending` → `applying` → `applied`/`failed`; or `expired`/`cancelled`.

**`sensitive_text_key`:** `algorithm` (`RSA-OAEP-256`), `key_id`, `public_key` (SPKI PEM), `max_plaintext_bytes` (446).

### 6.4 Navigation scope

The init `url` declares the session's target site. A `navigate` action is accepted only when its host is the init URL's **site domain** or a subdomain of it. The site domain is the registrable domain (eTLD+1), except for shared-hosting suffixes in the Public Suffix List's private section (for example `github.io` or `herokuapp.com`), where the suffix itself is the site domain — so `foo.github.io` allows sibling sites such as `other.github.io`.

| Init URL | Allowed `navigate` hosts | Not allowed |
|---|---|---|
| `https://www.example.com/` | `example.com`, `www.example.com`, `secure.example.com` | `example.org`, `example.com.evil.com` |
| `https://login.bank.co.uk/` | `bank.co.uk`, `secure.bank.co.uk` | `other.co.uk`, `co.uk` |
| `https://foo.github.io/` | `github.io`, `foo.github.io`, `bar.foo.github.io`, `other.github.io` | `github.com`, `example.com` |
| `https://93.184.216.34/` | `93.184.216.34` only | any other host |

Scheme and port don't matter. An IP init URL allows only that exact host. An ICANN public suffix as the init host (for example `co.uk`) also allows only that exact host; a private/shared-hosting suffix as the init host (for example `github.io`) allows the whole suffix. A rejected navigate is `403 navigation_not_allowed` and names the allowed domain. The check applies to `navigate`; links and redirects the page itself follows are not rewritten.

### 6.5 Secure-form page endpoints (not for agents)

The form page sends the one-time token from its URL fragment in `X-Secure-Input-Token`; the token is never accepted from the path, query or body. Agents must not call these.

| Method | Path | Request | Response |
|---|---|---|---|
| GET | `/secure-inputs/context` | Header token. | `200` with `secure_input_id`, `state`, `expires_at`; while pending also `title`, `site_host`, `fields[{ref,kind,label,autocomplete}]`, `key{algorithm,key_id,public_key,public_key_spki_der_base64,max_plaintext_bytes}` and `max_total_plaintext_bytes`; optional `error_code`/`message`. |
| POST | `/secure-inputs/submit` | Header token; `{key_id?, fields:[{ref, encrypted_text}]}` (1–4 refs, one 512-byte ciphertext each). | `202 {state: "applying", message}`. |

Both are rate-limited per IP at 120 requests per minute by default.

## 7. Errors

Errors look like `{"success": false, "error": {"code": "…", "message": "…"}}`; the message says what to do. `429 rate_limited` and `503 no_capacity` send `Retry-After` seconds.

| `code` | HTTP | What to do |
|---|---|---|
| `stale_ref`, `ref_not_found` | 422 | Observe again and use a ref from the new snapshot. |
| `element_not_actionable` | 422 | Observe to see why: the element may be covered or hidden, or the page changed. |
| `sensitive_text_decrypt_failed` | 422 | The ciphertext isn't for this session's key, or it's damaged. Re-encrypt with this session's `public_key` and `key_id`; if you no longer have them, close the session and start a new one. Never fall back to `input.text`. |
| `session_not_found` | 404 | The session doesn't exist or has already been deleted; start a new one. |
| `session_not_ready` | 409 | The browser is still starting; observe until `state` is `ready`. |
| `session_busy` | 409 | Wait for your previous call on this session to return. |
| `session_closed` | 409 | The browser has closed or is closing; start a new session if you need one. |
| `session_limit` | 429 | You have no free session slot; close or delete one of your open sessions. |
| `no_capacity` | 503 | No browser is free right now; retry after the `Retry-After` seconds. |
| `browser_timeout` | 504 | Observe to see where the page is. |
| `secure_input_pending` | 409 | A secure form is already open for this session. Poll the secure-input status endpoint until it is applied or failed, or delete it to cancel. Observe and action are blocked while it is pending. |
| `secure_input_used` | 409 | This form was already submitted. If you need to try again, create a new form from a fresh observe. |
| `secure_input_expired`, `secure_input_cancelled` | 410 | The form is no longer usable. Observe again and create a new secure form. |
| `secure_input_not_found` | 404 | The form doesn't exist or has expired; observe again and create a new secure form. |
| `invalid_request`, `invalid_url` | 400 | Fix the request as the message says. A `text` field in `set_sensitive_text` is refused; encrypt the secret instead. A secure-form create accepts only `title`, `fields`, `ref`, `kind` and `label`. |
| `unauthorized` | 401 | Check your `Authorization` header. |
| `navigation_not_allowed` | 403 | The URL is outside this session's navigation scope. Use the init URL's domain or a subdomain, or start a new session with the desired URL. |
| `rate_limited` | 429 | Too many requests from your address; wait for the `Retry-After` seconds. |
| `internal_error` | 500 | Retry the call; if it keeps failing, close the session and start a new one. |

## 8. Sessions, limits and rules

- **A session is one running browser.** Each is fresh: it keeps its own profile, but nothing is saved between sessions. It is deleted when its browser closes for any reason; you can't reopen it, so start a new one with `POST /sessions`.
- **The sensitive-text key belongs to its session.** It is returned only at init, and a new session gets a new key. Keep it for the session's life; don't reuse a key or ciphertext across sessions.
- **One secure form per session.** A new create replaces a pending form; the old URL stops working.
- **Runs last up to 30 minutes** (`expires_at`). Then the browser closes and the session is deleted.
- **Hold up to 3 open sessions.** `GET https://unblock.sophtron.com/api/v1/sessions` lists them with `state` and `last_used_at`; `DELETE https://unblock.sophtron.com/api/v1/sessions/{session_id}` closes one and frees its slot.
- **Rules:** send your key only to `https://unblock.sophtron.com`; init with the site you intend to visit (it defines the navigation scope); never send a secret as plaintext; give a secure-form URL only to the vault or the user, never log or publish it, and never navigate the Sophtron session browser to it; make one call at a time per session; use only refs from your latest observe; wait on your side when a page needs time; close sessions when done.
