openapi: 3.1.0 info: title: Browsa API version: "0.1.0" description: | The identity layer for AI agents. Provision a CDP-attached cloud browser identity in 2 seconds; drive it with Playwright / Puppeteer / raw CDP. Two modes share one endpoint: - **Burner** — ephemeral, 5min–1h TTL, self-destructs, pay-per-call - **Persistent** — durable, vault-synced, monthly-billed, sessions opened on demand contact: { email: support@browsa.io } license: { name: Proprietary } servers: - url: https://browsa.io description: Production security: - ApiKeyAuth: [] - BearerAuth: [] paths: /v1/agents: post: summary: Provision an agent (burner or persistent) description: | Mode is auto-detected: if `persona` or `warmup_recipe` is set AND no `ttl`/`ttl_seconds`, persistent. Otherwise burner. Set `mode` explicitly to override. operationId: createAgent requestBody: required: false content: application/json: schema: $ref: "#/components/schemas/CreateAgentRequest" responses: "201": description: Agent provisioned content: application/json: schema: { $ref: "#/components/schemas/Agent" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "402": description: Insufficient credit. Top up at /v1/credits/checkout. content: application/json: schema: { $ref: "#/components/schemas/Error" } "429": { $ref: "#/components/responses/RateLimited" } "502": { $ref: "#/components/responses/UpstreamUnavailable" } /v1/agents/{id}: get: operationId: getAgent parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: Agent payload content: application/json: schema: { $ref: "#/components/schemas/Agent" } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: No agent with that id (or belongs to a different workspace) content: application/json: schema: { $ref: "#/components/schemas/Error" } delete: summary: Destroy an agent immediately operationId: destroyAgent parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "204": { description: Destroyed } "401": { $ref: "#/components/responses/Unauthorized" } "404": description: Not found content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/agents/{id}/cdp: get: summary: Get the CDP URLs for an agent operationId: getAgentCDP parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: CDP URLs content: application/json: schema: type: object properties: cdp_http: { type: string, format: uri } cdp_url: { type: string, description: "wss://... WebSocket endpoint" } profile_id: { type: string } expires_at: { type: string, format: date-time } "409": description: Agent is not running content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/agents/{id}/sessions: post: summary: Open a new session on a persistent identity description: | Only valid on persistent agents. Charges PersistentSessionCredits. The identity's profile (cookies, localStorage) is reused; only the cloud session is new. operationId: openSession parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: false content: application/json: schema: type: object properties: initial_url: { type: string, format: uri } country: { type: string } responses: "201": description: Session opened content: application/json: schema: { $ref: "#/components/schemas/Agent" } "400": description: Tried to open a session on a non-persistent agent "402": { description: Insufficient credit } "404": { description: Agent not found } "502": { $ref: "#/components/responses/UpstreamUnavailable" } /v1/credits: get: summary: Get current credit balance + pack menu operationId: getCredits responses: "200": description: Balance content: application/json: schema: type: object properties: workspace_id: { type: string } balance: { type: integer } packs: type: array items: type: object properties: sku: { type: string } label: { type: string } amount_usd_cents: { type: integer } credits: { type: integer } /v1/credits/checkout: post: summary: Create a Stripe Checkout session for a credit pack operationId: createCheckout requestBody: required: true content: application/json: schema: type: object required: [sku] properties: sku: type: string enum: [starter_20, growth_100, pro_500] customer_email: { type: string, format: email } responses: "201": description: Checkout session created — redirect the customer to checkout_url content: application/json: schema: type: object properties: checkout_url: { type: string, format: uri } stripe_session_id: { type: string } pack_id: { type: string } sku: { type: string } "400": { description: Unknown SKU / missing fields } "503": { description: Stripe Checkout not enabled on this deployment } /v1/credits/webhook: post: summary: Stripe webhook receiver (signed by Stripe, NOT API-key auth'd) description: | Customer-facing endpoint pointed at by Stripe. Verifies the `Stripe-Signature` header before granting credits. Operators configure the webhook in the Stripe dashboard pointing here. operationId: stripeWebhook security: [] responses: "200": { description: Event received and processed } "401": { description: Signature did not verify } /v1/keys: get: summary: List API keys for the workspace operationId: listKeys responses: "200": description: keys content: application/json: schema: type: object properties: keys: type: array items: { $ref: "#/components/schemas/APIKey" } post: summary: Mint a new API key description: | The `secret` field is shown EXACTLY ONCE in the response. Save it client-side or it's lost. operationId: mintKey requestBody: required: false content: application/json: schema: type: object properties: name: { type: string } responses: "201": content: application/json: schema: { $ref: "#/components/schemas/MintedKey" } /v1/keys/{id}: delete: summary: Revoke an API key operationId: revokeKey parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "204": { description: Revoked } "404": { description: Key not found } /v1/webhooks: get: summary: List webhook subscriptions operationId: listWebhooks responses: "200": description: subscriptions content: application/json: schema: type: object properties: webhooks: type: array items: { $ref: "#/components/schemas/WebhookSub" } post: summary: Register a webhook callback URL operationId: createWebhook requestBody: required: true content: application/json: schema: type: object required: [url] properties: url: { type: string, format: uri } events: type: array description: Empty array = all events items: { $ref: "#/components/schemas/EventType" } responses: "201": content: application/json: schema: { $ref: "#/components/schemas/WebhookSub" } /v1/webhooks/{id}: delete: summary: Disable a webhook subscription operationId: disableWebhook parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "204": { description: Disabled } "404": { description: Not found } /v1/jobs/{id}: get: summary: Fetch an async job description: | Returns the full job envelope. Two optional query params: - `wait=N` (1-120) long-polls the API up to N seconds for a terminal state. Lets linear client code feel synchronous without hammering the endpoint. - `format=csv|ndjson` returns a downloadable export of the job's result instead of JSON. Heavy fields (html, screenshot_png_b64) collapse to `_bytes` counts so the export stays consumable in Excel / pandas / DuckDB. Batch + crawl jobs render one row per URL. Content-Disposition: attachment; filename="-." is set so browsers / curl -O drop the file directly to disk. operationId: getJob parameters: - { name: id, in: path, required: true, schema: { type: string } } - name: wait in: query required: false schema: { type: integer, minimum: 0, maximum: 120 } - name: format in: query required: false schema: { type: string, enum: [csv, ndjson] } responses: "200": description: Job payload (JSON) or downloadable export (CSV/NDJSON) content: application/json: schema: { $ref: "#/components/schemas/Job" } text/csv: { schema: { type: string, format: binary } } application/x-ndjson: { schema: { type: string, format: binary } } "404": { description: No job with that id (or different workspace) } "409": description: | Returned when `format=csv|ndjson` is requested for a job that isn't completed yet — there's nothing to export. Poll the status endpoint or use `?wait=N` until status='completed'. content: application/json: schema: { $ref: "#/components/schemas/Error" } /v1/jobs/{id}/status: get: summary: Lightweight status-only poll description: | Returns {id, status, kind, progress_done, progress_total} only. Cheap shape for polling loops that don't want to pull the full result blob every tick. operationId: getJobStatus parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "200": description: Status payload content: application/json: schema: type: object properties: id: { type: string } status: type: string enum: [queued, running, completed, failed, cancelled, input_required] kind: { type: string, enum: [scrape, extract, screenshot, batch, crawl, task] } progress_done: { type: integer } progress_total: { type: integer } "404": { description: No job with that id } /v1/jobs/{id}/cancel: post: summary: Cancel a running job description: | Cooperative cancel — the agent finishes its current step then stops. You're billed only for steps actually taken. Idempotent; returns 409 if the job is already terminal. operationId: cancelJob parameters: - { name: id, in: path, required: true, schema: { type: string } } responses: "204": { description: Cancelled (or already cancelling) } "400": { $ref: "#/components/responses/BadRequest" } "404": { description: No job with that id } "409": { description: Job already terminal } /v1/jobs/{id}/respond: post: summary: Answer an input_required prompt description: | When the agent calls the `ask_user` tool (CAPTCHA solution, decision point, etc.) the job pauses in `input_required` state. POST your answer here and the job flips back to `running`. operationId: respondToJob parameters: - { name: id, in: path, required: true, schema: { type: string } } requestBody: required: true content: application/json: schema: type: object required: [response] properties: response: { type: string } responses: "200": description: Updated job envelope content: application/json: schema: { $ref: "#/components/schemas/Job" } "400": { $ref: "#/components/responses/BadRequest" } "404": { description: No job with that id } "409": { description: Job not in input_required state } /v1/tasks: post: summary: Submit an AI-driven browser task description: | Submit a plain-English instruction; the platform spawns a fresh anti-detect Chromium burner, drives it with the chosen LLM via browser-use, and returns when the agent finishes. Use GET /v1/jobs/{id}?wait=30 to long-poll or watch the live_url noVNC stream while it runs. operationId: submitTask requestBody: required: true content: application/json: schema: type: object required: [task, llm, llm_api_key] properties: task: { type: string, description: "Plain-English instructions for the agent" } framework: { type: string, default: "browser-use" } llm: { type: string, example: "claude-opus-4-7" } llm_api_key: { type: string, description: "Customer's LLM provider key (sk-ant-… / sk-… / jot_…)" } llm_base_url: { type: string, description: "Override provider host for OpenAI-compatible proxies" } country: { type: string, example: "US" } os_type: { type: string, enum: [OS_TYPE_MACOS, OS_TYPE_WINDOWS, OS_TYPE_LINUX] } max_steps: { type: integer, minimum: 1, maximum: 100 } ja3_profile: { type: string, example: "macos_chrome_137" } initial_url: { type: string } agent_id: { type: string, description: "Persistent identity UUID; omit for a fresh burner" } responses: "202": description: Task accepted content: application/json: schema: { $ref: "#/components/schemas/Job" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "402": description: Insufficient credit content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } /v1/credits/ledger: get: summary: Per-job credit ledger description: | Returns the workspace's credit ledger entries — every signup bonus, paid top-up, agent-provision debit, and per-job debit. Each row carries the linked `agent_id` (job id for job-debit rows) so an agency can bill their own client from this data. operationId: listLedger parameters: - name: limit in: query required: false schema: { type: integer, default: 50, minimum: 1, maximum: 500 } responses: "200": description: Ledger snapshot content: application/json: schema: type: object properties: balance: { type: integer } count: { type: integer } entries: type: array items: type: object properties: id: { type: string } delta: { type: integer, description: "Signed; debits are negative" } balance: { type: integer, description: "Running balance after this entry" } reason: { type: string, example: "job:252e8eac-…" } agent_id: { type: string, description: "Linked job or agent UUID" } created_at: { type: string, format: date-time } /v1/health: get: summary: Public health + build SHA description: | Public endpoint; no auth required. Returns build SHA, version, service name, current time, and docs URL. Use for status pages and deployment verification. operationId: health security: [] responses: "200": description: Service is alive content: application/json: schema: type: object properties: service: { type: string, example: "browsa" } status: { type: string, example: "ok" } build: { type: string, example: "a3f4d2b" } version: { type: string, example: "v0.1.0" } now: { type: string, format: date-time } docs: { type: string, format: uri } components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key BearerAuth: type: http scheme: bearer bearerFormat: agt_live_<24-hex> responses: BadRequest: description: Validation failed content: application/json: schema: { $ref: "#/components/schemas/Error" } Unauthorized: description: Missing or invalid API key content: application/json: schema: { $ref: "#/components/schemas/Error" } RateLimited: description: Per-key rate limit exceeded headers: Retry-After: { schema: { type: integer } } content: application/json: schema: { $ref: "#/components/schemas/Error" } UpstreamUnavailable: description: neout gateway or cloud-runner unreachable content: application/json: schema: { $ref: "#/components/schemas/Error" } schemas: CreateAgentRequest: type: object properties: ttl: type: string description: "Duration string: '5m', '300s', '1h'. Capped at 1h for burner." ttl_seconds: type: integer description: Numeric alternative to ttl mode: type: string enum: [burner, persistent] os_type: type: string example: OS_TYPE_MACOS browser_type: type: string example: BROWSER_TYPE_PHANTOM initial_url: type: string format: uri country: type: string example: us persona: type: string description: Persistent mode — freeform label like "us-shopper-30s" warmup_recipe: type: string description: "Optional warmup: 'amazon', 'google', 'social', ''" Agent: type: object properties: id: { type: string, example: "agt_aabbccddeeff00112233" } status: type: string enum: [queued, running, destroyed, failed] mode: { type: string, enum: [burner, persistent] } cdp_url: { type: string } cdp_http: { type: string } expires_at: { type: string, format: date-time } ttl_seconds: { type: integer } trust_score: { type: integer, minimum: 0, maximum: 1000 } cost_credits: { type: integer } created_at: { type: string, format: date-time } error_code: { type: string } error_message: { type: string } APIKey: type: object properties: id: { type: string } name: { type: string } prefix: { type: string } created_at: { type: string, format: date-time } last_used_at: { type: string, format: date-time, nullable: true } revoked_at: { type: string, format: date-time, nullable: true } MintedKey: allOf: - $ref: "#/components/schemas/APIKey" - type: object properties: secret: type: string description: Shown ONCE on creation; the server stores only sha256(secret). Job: type: object description: | Async job row. Created by POST /v1/quick/* (with async=true), POST /v1/quick/batch, POST /v1/quick/crawl, or POST /v1/tasks. Reaches a terminal `completed` or `failed` state via the worker, then optionally fires job.completed / job.failed webhooks to every active subscription. properties: id: { type: string, format: uuid } kind: type: string enum: [scrape, extract, screenshot, batch, crawl, task] status: type: string enum: [queued, running, completed, failed] progress_done: { type: integer } progress_total: { type: integer } created_at: { type: string, format: date-time } started_at: { type: string, format: date-time, nullable: true } completed_at: { type: string, format: date-time, nullable: true } credits_charged: { type: integer, nullable: true } result: type: object description: | Populated only on status='completed'. Shape varies by kind — see service.ScrapeResult / BatchResult / CrawlResult / TaskResult. additionalProperties: true error_code: allOf: - { $ref: "#/components/schemas/JobErrorCode" } description: "Present on status='failed'. See JobErrorCode for full catalogue + remediation." error_message: { type: string, description: "Present on status='failed' — human-readable detail" } framework: { type: string, description: "Task-only: browser-use, claude-cu, openai-cua" } live_url: { type: string, description: "Task-only: noVNC embed for the task burner" } agent_id: { type: string, description: "Task-only: the burner the task ran on" } step_count: { type: integer, description: "Task-only: steps the LLM took" } WebhookSub: type: object properties: id: { type: string } url: { type: string, format: uri } events: type: array items: { $ref: "#/components/schemas/EventType" } created_at: { type: string, format: date-time } disabled_at: { type: string, format: date-time, nullable: true } last_sent_at: { type: string, format: date-time, nullable: true } last_error: { type: string } EventType: type: string enum: - agent.provisioned - agent.destroyed - agent.failed - agent.session.started - agent.session.ended - agent.renewed - agent.renewal_failed - job.completed - job.failed JobEvent: type: object description: | Payload delivered when a /v1/quick/* or /v1/tasks job reaches a terminal state. Signed exactly like every other webhook (see Webhook delivery section in API.md). At-least-once delivery — dedupe on `id` (the event_id). required: [id, type, created_at, workspace_id, job_id, job_kind] properties: id: { type: string, description: "event_id; stable across retries" } type: type: string enum: [job.completed, job.failed] created_at: { type: string, format: date-time } workspace_id: { type: string } job_id: { type: string, description: "GET /v1/jobs/:id to retrieve" } job_kind: type: string enum: [scrape, extract, screenshot, batch, crawl, task] data: type: object description: | Snapshot of the terminal job. Populated fields: - progress_done, progress_total - credits_charged - result (the full job result object — see GET /v1/jobs/:id) On job.failed: data is small and error_code/error_reason carry the failure envelope. additionalProperties: true error_code: { type: string, description: "Present on job.failed" } error_reason: { type: string, description: "Present on job.failed" } Error: type: object required: [error, code] properties: error: { type: string } code: type: string description: | Top-level API error codes returned in {error,code,request_id} envelopes. Job-level failure codes (set on completed jobs with status=failed) live in JobErrorCode below — they describe why a task didn't reach a final result, not why the HTTP call failed. enum: - BAD_REQUEST - UNAUTHORIZED - FORBIDDEN - NOT_FOUND - INSUFFICIENT_CREDIT - TOO_MANY_REQUESTS - UPSTREAM_UNAVAILABLE - UPSTREAM_TIMEOUT - AGENT_NOT_RUNNING - BILLING_DISABLED - INVALID_SIGNATURE - INVALID_API_KEY - MISSING_API_KEY - INTERNAL_ERROR request_id: { type: string } # JobErrorCode is the catalogue customers see on /v1/jobs/:id when # status=failed. Documented at request of DevOps-persona audit # 2026-06-09 P1 — "STALLED error code appears undocumented" was # blocking on-call triage because there was no public reference # for what each terminal failure code means or how to fix it. JobErrorCode: type: string enum: - STALLED - LLM_ERROR - TIMEOUT - CANCELLED - INSUFFICIENT_CREDIT - INVALID_API_KEY - RATE_LIMIT - INTERNAL_ERROR description: | - `STALLED` — the LLM agent ran `step_count` steps but never called `done()` to emit a final result. Causes ranked by frequency: (a) max_steps was too low for the task; (b) the LLM got lost in a wait/scroll loop; (c) the target site presented an anti-bot challenge / CAPTCHA the agent couldn't pass. The error_message will distinguish (a) — "ran out of steps, try raising max_steps" — from (b)/(c) — "agent decided to give up before calling done()". Fix: raise max_steps, narrow the task wording, or use a more capable LLM (sonnet/opus instead of haiku). - `LLM_ERROR` — the LLM provider returned an error on every step. Usually means the BYOK key was rejected mid-run, the model name was wrong, or the provider rate-limited. error_message includes the upstream provider's verbatim error. - `TIMEOUT` — the wallclock budget (default 900s) was exceeded before the agent terminated. Customer can pass a higher `wallclock_seconds` on the create request, but typically this signals a pathologically slow page or LLM. - `CANCELLED` — customer fired DELETE /v1/jobs/{id}/cancel mid-run. No charge for the partial work. - `INSUFFICIENT_CREDIT` — workspace balance hit 0 before provisioning could complete. Top up at /dashboard/credits. - `INVALID_API_KEY` — the BYOK LLM key was rejected by the provider. Re-check the key, base URL, and model name. - `RATE_LIMIT` — the BYOK LLM provider rate-limited and exhausted our retry budget. Either upgrade the LLM-side plan or lower task concurrency. - `INTERNAL_ERROR` — unexpected platform failure. Includes a request_id; please send it to support@neout.com.