# Proxy Browser Manager — HTTP API

How to drive the system from a script, n8n, or another app: get a key, open and close browsers, use Block Bypass, list sessions, and manage tenants.

| | |
|---|---|
| **Base URL** | `https://proxy.sporttickets.uk` |
| **Alias** | `/v1/...` is the same as `/api/...` |
| **Live docs** | https://proxy.sporttickets.uk/api.html |
| **OpenAPI** | https://proxy.sporttickets.uk/api/openapi.json |
| **Server** | 1.24.5 (agent 1.14.2) |

Opening a browser still happens on **the machine whose agent is connected for the account that owns the API key** — not in the cloud.

---

## 1. Get an API key

1. Sign in to https://proxy.sporttickets.uk
2. Download and run the **agent** for that same account (top bar → Download Agent). Browsers will not open without it.
3. Open the **API** tab in the sidebar.
4. Click **+ Create key**, give it a name (`ci`, `n8n`, `laptop`).
5. Copy the secret. It starts with `pbk_live_` and is shown **once**. Store it in an environment variable or a secret manager.

Revoke unused keys on the same tab. Deleting the account or tenant revokes them automatically. Max 20 active keys per account.

The key has the **same role and tenant as your login**:

- **tenant-user** — own profiles (plus shared ones in the tenant); open/close on *your* agent
- **tenant-admin** — whole tenant: profiles, proxies, accounts, activity
- **admin** — everything, all tenants

---

## 2. Authenticate

Every request except health / agent-version / OpenAPI:

```
Authorization: Bearer pbk_live_YOUR_KEY
```

Same thing: header `X-API-Key: pbk_live_YOUR_KEY`.

```bash
export API=https://proxy.sporttickets.uk
export KEY=pbk_live_YOUR_KEY

curl -s -H "Authorization: Bearer $KEY" $API/api/me
```

Example response:

```json
{
  "username": "jun",
  "role": "tenant-user",
  "accountId": 12,
  "tenantId": 3,
  "authType": "api-key",
  "plan": "pro",
  "limits": { "profiles": 250, "concurrent": 40, "seats": 10 },
  "usage": { "profiles": 40, "concurrent": 2, "seats": 4 },
  "credits": null,
  "qualifyingSeconds": 30
}
```

`401` means a missing, revoked, or mistyped key.

---

## 3. How the pieces fit

| Thing | Meaning |
|---|---|
| **Agent** | Go binary on *your* computer. Must be online to open or close a browser. `GET /api/agents` |
| **Profile** | One browser identity: name, Chrome or Camoufox, one proxy, one fingerprint. Ids start at **0**. |
| **Open** | Server tells *your* agent to launch that profile. Optional Block Bypass `club` injects a shop cookie, then navigates to a fresh Queue-it token URL when TMC has one. |
| **Session** | One run of a browser: start/end, exit IP, billed or not, any block. History via `GET /api/sessions`. |

Scripts should **poll** `GET /api/profiles/:id` (and/or `GET /api/agents`). The dashboard WebSocket is not required.

---

## 4. Everyday workflows

### Confirm the agent is up

```bash
curl -s -H "Authorization: Bearer $KEY" $API/api/agents
```

You want an entry for your `accountId` with `connectedAt` set. If the array is empty, start the agent.

### List profiles

```bash
curl -s -H "Authorization: Bearer $KEY" $API/api/profiles
```

Each row includes `id`, `name`, `browser`, `proxyLabel`, `browserOpen`, `agentOnline`, `blockBypassClub`, `proxyFailed`, `proxyFailReason`, and `insights` (traffic, last IP, blocked session counts). `proxyFailed` is true after Test Proxy or Check pool marked the assigned line dead (unlocks `POST /api/profiles/:id/reveal-proxy`).

One profile plus **live** browser state (use this after open):

```bash
curl -s -H "Authorization: Bearer $KEY" $API/api/profiles/378
```

```json
{
  "id": 378,
  "name": "Lee",
  "browser": "chrome",
  "browserOpen": true,
  "agentOnline": true,
  "blockBypassClub": "arsenal",
  "live": {
    "state": "running",
    "ip": "51.x.x.x",
    "launchedAt": 1720000000000,
    "bandwidth": { "bytesIn": 12000, "bytesOut": 800, "requests": 14 },
    "block": null,
    "blockBypass": { "club": "arsenal", "label": "Arsenal", "activated": true }
  }
}
```

`live` is `null` when the browser is not open. `live.state` is `launching` then `running`.

### Open with Block Bypass

Clubs: `arsenal`, `chelsea`, `tottenhamhotspur`, `cp`. Omit `club` (or send `null`) to leave Block Bypass off.

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"club":"arsenal"}' $API/api/profiles/378/open
```

```json
{
  "success": true,
  "message": "Open command sent to agent",
  "blockBypass": {
    "club": "arsenal",
    "label": "Arsenal",
    "activated": true,
    "queueToken": true
  }
}
```

If the club-cookie pool is empty or unreachable, the browser **still starts**. Then `activated` is `false` and `reason` is `"empty pool"` or `"unavailable"`. When the cookie lands, `queueToken` is `true` if TMC also had a live Queue-it URL (the agent navigates there after inject) and `false` if not (club home instead).

Poll until `live.state` is `"running"` (or until `live.block` is set):

```bash
for i in 1 2 3 4 5 6 7 8 9 10; do
  curl -s -H "Authorization: Bearer $KEY" $API/api/profiles/378
  sleep 2
done
```

`?screen=0` on open skips the pre-launch proxy health screen.

### Re-inject Block Bypass (browser already open)

Does **not** restart Chrome. Members Manager can call this on a pause page instead of close/reopen.

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"club":"arsenal","reload":true}' $API/api/profiles/378/block-bypass
```

```json
{
  "success": true,
  "blockBypass": {
    "club": "arsenal",
    "activated": true,
    "reason": null,
    "injectedAt": "2026-08-29T15:40:00.000Z",
    "queueToken": true
  }
}
```

If the cookie pool is empty: `activated: false`, `reason: "empty pool"`, `injectedAt: null`. `400` if the browser is not open. `reload` defaults to true (inject then navigate to the token URL or club home).

Errors you will see:

| Status | Typical `error` |
|---|---|
| 400 | Agent not connected; browser already open; unknown club; burned proxy |
| 402 | Out of PAYG credits (`limit: "credits"`) |
| 403 | No access, or concurrent/profile cap (`limit` names it) |
| 404 | Unknown profile id |

### Close

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" $API/api/profiles/378/close
```

### Create a profile, then open it

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"Run 28 Aug","browser":"camoufox","blockBypassClub":"arsenal"}' \
  $API/api/profiles
```

`browser` is `chrome` or `camoufox`. Optional `proxyId` picks a free line from your tenant pool (`GET /api/my-proxies`). Admins may also send `tenantId`. The create response includes `identity` (same object as `GET /api/profiles/:id/identity`).

### Export identity for a headless job (no agent)

Members Manager / Docker jobs call this instead of `POST .../open`. It does **not** launch a browser. It returns the fingerprint blob **and** full proxy credentials so the job can replay the same identity.

```bash
curl -sS -H "Authorization: Bearer $KEY" $API/api/profiles/12/identity
```

```json
{
  "profileId": 12,
  "name": "member-alice@example.com",
  "browser": "chrome",
  "fingerprint": {
    "format": "pbm.fingerprint.v1",
    "userAgent": "Mozilla/5.0 …",
    "platform": "Win32",
    "hardwareConcurrency": 8,
    "deviceMemory": 8,
    "languages": ["en-GB", "en"],
    "timezone": "Europe/London",
    "locale": "en-GB",
    "screen": { "width": 1920, "height": 1080, "colorDepth": 24, "pixelRatio": 1 },
    "webgl": { "vendor": "Google Inc. (Intel)", "renderer": "ANGLE (…)" },
    "canvasSeed": "a1b2c3…",
    "audioSeed": "d4e5f6…",
    "webrtc": "disabled",
    "raw": {}
  },
  "proxy": {
    "id": 7,
    "label": "gate.example.net:8080",
    "type": "http",
    "host": "gate.example.net",
    "port": 8080,
    "username": "user",
    "password": "secret",
    "country": "GB"
  },
  "updatedAt": "2026-08-28T20:00:00.000Z"
}
```

`fingerprint.raw` is the stored pool record (pass-through). `canvasSeed` / `audioSeed` are stable per profile+fingerprint. `proxy.country` is parsed from the username (`country-gb`) when present, otherwise `null`. `409` `{ "error": "profile has no proxy" }` if the profile has no assignment.

After `POST /api/profiles/:id/replace-proxy`, call identity again — profile id is unchanged, fingerprint stays, proxy block is new.

Writeup: `docs/PBM_PROFILE_IDENTITY_API.md`.

Create **and** open in one step:

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"browser":"camoufox","club":"chelsea"}' $API/api/quick-open
```

Returns `{ success, profile, blockBypass }`. The new profile is kept (it is not a throwaway unless you delete it later).

### Sessions after a run

```bash
curl -s -H "Authorization: Bearer $KEY" "$API/api/sessions?limit=20"
curl -s -H "Authorization: Bearer $KEY" "$API/api/sessions?blockedOnly=1"
```

Newest first. Report a bad session (no auto-refund; admin reviews):

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"reason":"blocked","note":"pause page"}' $API/api/sessions/SESSION_ID/report
```

`reason`: `blocked`, `captcha`, `slow`, `other`.

### Swap a dead proxy on a profile

```bash
curl -s -X POST -H "Authorization: Bearer $KEY" $API/api/profiles/378/test-proxy
curl -s -X POST -H "Authorization: Bearer $KEY" $API/api/profiles/378/replace-proxy
```

`replace-proxy` only assigns a **verified-live** unused line. `503` means the pool had nothing live.

---

## 5. Python

```python
import os, time, requests

API = os.environ["API"].rstrip("/")
H = {"Authorization": f"Bearer {os.environ['KEY']}"}

def api(method, path, **kw):
    r = requests.request(method, API + path, headers=H, timeout=60, **kw)
    r.raise_for_status()
    return r.json()

me = api("GET", "/api/me")
print(me["username"], me["role"], "agent key type:", me["authType"])

agents = api("GET", "/api/agents")
if not agents:
    raise SystemExit("Start your agent first")

profiles = api("GET", "/api/profiles")
p = next(x for x in profiles if x["id"] == 378)

opened = api("POST", f"/api/profiles/{p['id']}/open", json={"club": "arsenal"})
print("Block Bypass:", opened.get("blockBypass"))

for _ in range(20):
    live = api("GET", f"/api/profiles/{p['id']}")
    info = live.get("live") or {}
    print(info.get("state"), info.get("ip"), info.get("blockBypass"))
    if info.get("state") == "running":
        break
    time.sleep(1.5)

api("POST", f"/api/profiles/{p['id']}/close")
```

---

## 6. JavaScript (Node 18+)

```js
const API = process.env.API.replace(/\/$/, '');
const H = {
  Authorization: `Bearer ${process.env.KEY}`,
  'Content-Type': 'application/json'
};

async function api(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: H,
    body: body ? JSON.stringify(body) : undefined
  });
  const data = await res.json();
  if (!res.ok) throw Object.assign(new Error(data.error || res.status), { status: res.status, data });
  return data;
}

const me = await api('GET', '/api/me');
await api('POST', '/api/profiles/378/open', { club: 'arsenal' });
const live = await api('GET', '/api/profiles/378');
await api('POST', '/api/profiles/378/close');
```

Browser-side `fetch` from another origin is allowed: `/api` and `/v1` send `Access-Control-Allow-Origin: *`.

---

## 7. Errors and plan limits

Body is always `{ "error": "…" }`. Limit rejections add `limit`, `current`, `max`, `plan`.

| Status | Meaning |
|---|---|
| 400 | Bad input, agent offline, already open, unknown club |
| 401 | Missing or invalid key |
| 402 | Out of PAYG credits |
| 403 | Role / tenant / profile, or a plan cap |
| 404 | Unknown id |
| 409 | Profile has no proxy / fingerprint (`GET .../identity`) |
| 503 | Proxy replace found nothing live |

---

## 8. Endpoint catalogue

Unauthenticated: `GET /api/health`, `GET /api/agent-version`, `GET /api/openapi.json`, `POST /api/login`.

### Meta

| Method | Path | Notes |
|---|---|---|
| GET | `/api/me` | Identity, plan, usage |
| GET | `/api/time` | `{ now }` server epoch ms |
| GET | `/api/health` | `{ ok, api, server, agent, time }` |

### API keys

| Method | Path | Notes |
|---|---|---|
| GET | `/api/keys` | Own keys. Admin: `?all=1`. `?includeRevoked=1` |
| POST | `/api/keys` | `{ "name": "ci" }` → `{ key }` **once** |
| DELETE | `/api/keys/:id` | Revoke |

### Profiles and browsers

| Method | Path | Notes |
|---|---|---|
| GET | `/api/profiles` | List. Admin: `?tenantId=`. Includes `proxyFailed` / `proxyFailReason` |
| GET | `/api/profiles/:id` | One profile + `live` + `proxyFailed` / `proxyFailReason` |
| GET | `/api/profiles/:id/identity` | Fingerprint + **full proxy credentials** for job replay. `409` if no proxy |
| POST | `/api/profiles` | `{ name, browser, proxyId?, blockBypassClub?, tenantId? }` — response includes `identity` |
| PATCH | `/api/profiles/:id` | `{ name, note, browser, shared, blockBypassClub }` |
| DELETE | `/api/profiles/:id` | Closes browser, purges agent dir |
| POST | `/api/profiles/:id/open` | `{ club? }`. `?screen=0` skips proxy screen |
| POST | `/api/profiles/:id/block-bypass` | Live re-inject `{ club, reload }`. Browser must be open. Empty pool → `activated: false`. When activated, `queueToken` says whether a Queue-it URL was used |
| POST | `/api/profiles/:id/close` | |
| POST | `/api/quick-open` | Create + open. `{ browser, club? }` |
| POST | `/api/profiles/:id/rotate-proxy` | Next unused (not probed) |
| POST | `/api/profiles/:id/test-proxy` | Probe current. Failure unlocks reveal-proxy |
| POST | `/api/profiles/:id/reveal-proxy` | Credentials after a failed test/check. Logged; password not stored |
| POST | `/api/profiles/:id/replace-proxy` | Verified-live unused proxy |
| POST | `/api/profiles/:id/diary` | `{ text }` |
| DELETE | `/api/profiles/:id/diary/:entryId` | |
| GET | `/api/profiles/:id/logs` | IP + bandwidth; URLs for admins |

### Block Bypass

| Method | Path | Notes |
|---|---|---|
| GET | `/api/block-bypass/clubs` | `{ clubs: [{ id, label }] }` |

Pass `club` on open / quick-open, or save `blockBypassClub` on the profile.

### Sessions

| Method | Path | Notes |
|---|---|---|
| GET | `/api/sessions` | Newest first. `?limit=` `?blockedOnly=1` admin `?tenantId=` |
| GET | `/api/sessions/:id` | |
| POST | `/api/sessions/:id/report` | `{ reason, note }` |

### Agents

| Method | Path | Notes |
|---|---|---|
| GET | `/api/agents` | Connected agents and their open browsers. A launching browser may include `phase`: `preparing` (proxy/TMC) or `starting` (agent launching Chrome/Camoufox) |

### Proxies

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/my-proxies` | any | id / host / port / assigned / profileName, no credentials |
| GET | `/api/proxies` | tenant-admin | `?tenantId=` |
| GET | `/api/tenants/:id/proxies` | tenant-admin | includes credentials |
| POST | `/api/tenants/:id/proxies/upload` | tenant-admin | `{ proxies: "host:port:user:pass\\n..." }` append |
| POST | `/api/tenants/:id/proxies/replace` | tenant-admin | replace file |
| DELETE | `/api/tenants/:tid/proxies/:id` | tenant-admin | later ids shift down |
| POST | `/api/tenants/:tid/proxies/:id/test` | any tenant member | |
| POST | `/api/tenants/:tid/proxies/:id/reveal` | any tenant member | Credentials after a failed test/check. Logged; password not stored |
| GET | `/api/tenants/:id/proxies/check` | any tenant member | Last pool-wellness report + progress. Own tenant; admin any |
| POST | `/api/tenants/:id/proxies/check` | any tenant member | Start background probe. `202`. `409` if already running |
| POST | `/api/tenants/:id/proxies/replace-unhealthy` | any tenant member | Move expired/burned assignments onto live spares. `{ reasons, proxyIds, profileIds }` |

`POST .../check` returns `202 { running, tenantId, total }` and probes in the background (CONNECT through each proxy, same as Open screening). Poll `GET .../check`:

```json
{
  "tenantId": 0,
  "running": false,
  "checked": 548,
  "total": 548,
  "report": {
    "pool": 548,
    "live": 500,
    "dead": 48,
    "assigned": { "total": 412, "live": 380, "dead": 30, "burned": 2, "invalid": 0, "unhealthy": 32 },
    "available": { "total": 136, "live": 120, "dead": 16, "healthy": 118 },
    "replacementCapacity": 118,
    "canReplaceAllUnhealthy": true,
    "unhealthyAssigned": [{ "proxyId": 12, "host": "…", "reason": "expired", "profiles": [{ "id": 499, "name": "…" }] }]
  }
}
```

`available.healthy` / `replacementCapacity` is unused exits that are live and not burned — what you can give a **new profile** or a **replace-proxy**. Passwords are never included. Any member of the tenant may call these (admin: any tenant). `409` if a check is already running.

`POST .../replace-unhealthy` requires a prior pool check. Default `reasons` is `["expired","burned"]`. `proxyIds` / `profileIds` limit the set. Each replacement is probed live; open browsers on those profiles are closed. Response `{ replaced, skipped, remainingUnhealthy }`.

`POST .../proxies/:id/reveal` returns `{ line, host, port, username, password, failReason }` only if that line already failed Test or Check pool (`403` otherwise). The reveal is appended to `proxy-reveals.json` and the activity log **without** the password.

### Tenants, accounts, billing

| Method | Path | Who |
|---|---|---|
| GET | `/api/tenants` | any (scoped) |
| GET | `/api/tenants/:id` | same tenant / admin |
| POST | `/api/tenants` | admin `{ name, adminUsername, adminPassword }` |
| PATCH | `/api/tenants/:id` | admin `{ name, plan, limits, qualifyingSeconds }` |
| DELETE | `/api/tenants/:id` | admin |
| GET | `/api/tenants/:id/credits` | tenant-admin |
| POST | `/api/tenants/:id/credits` | admin `{ amount, note }` |
| GET | `/api/accounts` | tenant-admin |
| GET | `/api/accounts/:id` | tenant-admin |
| POST | `/api/accounts` | tenant-admin |
| PATCH | `/api/accounts/:id` | tenant-admin |
| POST | `/api/accounts/:id/regenerate-token` | tenant-admin (agent token, not API key) |
| DELETE | `/api/accounts/:id` | tenant-admin |
| GET | `/api/settings/billing` | admin |
| PATCH | `/api/settings/billing` | admin `{ qualifyingSeconds }` |
| GET | `/api/billing/sessions` | admin `?since=` `?tenantId=` |
| GET | `/api/reports` | admin |
| GET | `/api/proxy-reputation` | admin |
| GET | `/api/activity` | tenant-admin `?tenantId=` |
| GET | `/api/access-log` | tenant-admin. Last 2,000 HTTP API calls. `?path=` `?method=` `?status=` `?username=` `?authType=` `?limit=` `?tenantId=` (admin). No bodies or secrets. |
| GET | `/api/metrics` | tenant-admin `?tenantId=` |
| GET | `/api/urls` | tenant-admin (last 200) |

Dashboard login (`POST /api/login`) is for the UI. Prefer API keys for scripts.

Agent binary: `GET /download/agent/:platform?token=AGENT_TOKEN`  
platforms: `darwin-arm64`, `darwin-amd64`, `windows-amd64`.

---

## 9. API request log

`GET /api/access-log` (tenant-admin / admin) returns the last HTTP API calls: method, path, status, duration, username, tenant, `authType` (`api-key` or `session`), IP. No bodies, Authorization headers, or query secrets. Health/version/OpenAPI/clock and the log endpoint itself are omitted. Dashboard: Admin → **API requests**. Filter `authType=api-key` to see Members Manager / scripts only.

---

## 10. Maintainer note

The HTTP surface is the product. Any change that adds, removes, or alters a route, query param, request body, or JSON field must update this file, `server/public/api.html`, and `server/public/openapi.json` in the same commit.
