```yaml
product: AlterLab
title: Research API
category: api
comparison_context: "AlterLab is an alternative to Firecrawl, ScrapingBee, and Bright Data."
source_url: https://alterlab.io/docs/api/research
```

# Research API

Start an asynchronous research job: AlterLab searches, reads sources and returns a cited answer within the budget you set. Planning and synthesis run on your own LLM key.

> **Bring your own key.** Research requires an LLM key (BYOK) added in your dashboard settings. Without one, the request returns `byok_key_required` with HTTP 400, nothing is stored and you are not charged. Model tokens are billed by your provider, never by AlterLab.

## Overview

- Jobs are asynchronous: `POST` returns 202 with a job id; poll the job, stream events, or register a webhook.
- Every job runs under a hard budget (cost, tool calls, runtime, depth, sources). When a cap is reached the job ends as `completed_partial` with a coverage report.
- Job statuses: `awaiting_approval`, `queued`, `running`, `completed`, `completed_partial`, `failed`, `cancelled`.
- Responses never name the search sources behind a result. Auth uses the same `X-API-Key` header as every other endpoint (scope: `scrape`; write for create, read for fetch).

## POST /api/v1/research

Start a research job. Returns 202 with the job, its resolved budget and, when a webhook is set, the webhook secret (returned once).

| Parameter | Type | Required | Description |
|---|---|---|---|
| `query` | string | yes | Research question, 1-4000 characters. |
| `effort` | string | no | lite, standard, deep, exhaustive or frontier (default `standard`). See Effort Tiers. |
| `max_cost_microcents` | integer | no | Hard cap on platform spend (1 microcent = one millionth of a dollar). Lowers the effort tier cap. |
| `max_tool_calls` | integer | no | Cap on searches plus fetches. |
| `max_runtime_seconds` | integer | no | Wall-clock cap. |
| `max_depth` | integer | no | Maximum follow-up rounds, 1-10. |
| `max_sources` | integer | no | Maximum sources read, 1-500. |
| `output` | string | no | `json` (structured answer), `report` (cited prose) or `both` (default `json`). |
| `citation_format` | string | no | numbered, mla, apa or chicago (default `numbered`). |
| `output_length` | string | no | short, standard or long (default `standard`). |
| `criteria` | string[] | no | Up to 20 free-text criteria (1-500 characters each). The result reports each as met or unmet. |
| `min_sources` | integer | no | Independent sources required per field, 1-10 (default 1). |
| `sources` | object | no | `{ include_domains (soft boost), exclude_domains (hard), seed_urls, only_these_urls (read only the seed URLs, no discovery searches), categories (web, news, academic, code, discussions) }`. |
| `output_schema` | object | no | JSON Schema (object) for the structured answer. |
| `files` | object[] | no | Up to 50 caller files, `{ name, text }`, searched as sources and cited as file:// spans. Requires `tools.file_search`. |
| `tools` | object | no | `{ file_search (default true), code_execution (default true), mcp (up to 5 remote HTTPS MCP servers, each { name, url, allowed_tools }) }`. Each call is budgeted, metered and refunded if empty. |
| `plan_first` | boolean | no | Return a plan and wait for approval before any spend (default false). |
| `webhook` | object | no | `{ url (HTTPS), secret (optional, 16-256 chars) }`. Fired on terminal status. |
| `provider` | string | no | BYOK provider for planning and synthesis. |
| `model` | string | no | BYOK model override. |

```bash
curl -X POST https://api.alterlab.io/api/v1/research \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "How do vector databases handle filtered search?",
    "effort": "standard",
    "max_cost_microcents": 20000
  }'
```

```json
{
  "id": "9f1c2b7e-3a5d-4c8e-9b1a-2d6f7e8a9b0c",
  "status": "queued",
  "effort": "standard",
  "budget": {
    "max_cost_microcents": 20000,
    "max_tool_calls": 40,
    "max_runtime_seconds": 120,
    "max_depth": 2,
    "max_sources": 20
  },
  "cost": { "platform_microcents": 0, "model_microcents": 0 },
  "tool_calls": 0,
  "created_at": "2026-11-04T12:00:00Z"
}
```

Unknown request fields are rejected. The create call returns 400 `invalid_webhook_url` when the webhook URL fails validation.

## GET /api/v1/research

Lists your research jobs, newest first. Query parameters: `limit` (1-100, default 20) and `cursor` (the `next_cursor` from the previous page). The response is `{ "items": [...], "next_cursor": "..." | null }`. A malformed cursor returns 422 `invalid_cursor`.

## GET /api/v1/research/{id}

Fetch a job. Terminal jobs include `result`, `coverage`, `stop_reason` and the final cost.

```json
{
  "id": "9f1c2b7e-3a5d-4c8e-9b1a-2d6f7e8a9b0c",
  "status": "completed",
  "effort": "standard",
  "tool_calls": 31,
  "stop_reason": "answered",
  "cost": { "platform_microcents": 14300, "model_microcents": 0 },
  "result": { "...": "answer and citations" },
  "coverage": { "...": "what was and was not read" },
  "finished_at": "2026-11-04T12:01:12Z"
}
```

Unknown ids return 404 `research_not_found`. The `result` and `coverage` objects are free-form JSON; read them defensively.

## POST /api/v1/research/{id}/cancel

Cancels a job that has not finished. Returns the job with status `cancelled`. A job that already finished returns 409 `research_already_finished`.

## DELETE /api/v1/research/{id}

Permanently deletes a finished job together with its events and queued webhook deliveries. Returns 204 with no body. A job that is still active returns 409 `research_active`; cancel it first. Unknown ids return 404 `research_not_found`.

## POST /api/v1/research/{id}/rerun

Starts a new job from the stored request of an existing one and returns it with status 202, exactly like `POST /api/v1/research`. The body is optional: `{ "overrides": { ... } }` replaces top-level request fields, and a `null` value clears one. The stored request never holds the webhook, so pass `webhook` in `overrides` to attach one. The merged request is validated again: invalid overrides return 422 `invalid_rerun_overrides`. The new job is billed like any other submission.

```bash
curl -X POST https://api.alterlab.io/api/v1/research/JOB_ID/rerun \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"overrides": {"effort": "deep"}}'
```

## GET /api/v1/research/{id}/events

Server-sent events: `status`, `progress`, `thinking_summary`, `plan` and a final `research.<status>` event, after which the stream closes. Each event `id` is monotonic per job. Reconnect with the `Last-Event-ID` header (or `?last_event_id=`) to receive only the events you missed. Events are kept for the life of the job.

```bash
curl -N https://api.alterlab.io/api/v1/research/JOB_ID/events \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Last-Event-ID: 12"
```

## Plan Steering

With `plan_first: true` the job waits in `awaiting_approval` and nothing is spent until you approve.

- `GET /api/v1/research/{id}/plan`: read the plan.
- `PUT /api/v1/research/{id}/plan`: replace it with body `{ "plan": { ... } }`.
- `POST /api/v1/research/{id}/approve`: start the job; an optional `plan` in the body replaces the plan first.

Plan endpoints on a job that is not awaiting approval return 409 `research_not_awaiting_approval`; an approval that raced its expiry returns 409 `research_plan_expired`.

## Webhooks

On terminal status AlterLab POSTs the job to your HTTPS URL with these headers: `X-AlterLab-Event`, `X-AlterLab-Timestamp` and `X-AlterLab-Signature: sha256=<hex>`, where the signature is HMAC-SHA256 of `{timestamp}.{body}` with your secret. The secret is returned once, in the create response, unless you supplied your own.

```python
import hashlib, hmac

def verify(secret: str, timestamp: str, body: bytes, header: str) -> bool:
    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", header)
```

## Effort Tiers and Budgets

| Effort | Searches | Pages | Depth | Tool calls | Runtime cap | Cost cap | Typical |
|---|---|---|---|---|---|---|---|
| lite | 2 | 5 | 1 | 10 | 30 s | $0.005 | 5-30 s |
| standard | 6 | 20 | 2 | 40 | 120 s | $0.050 | 30-120 s |
| deep | 15 | 60 | 4 | 120 | 600 s | $0.100 | 2-10 min |
| exhaustive | 40 | 200 | 6 | 400 | 1800 s | $0.450 | 10-30 min |
| frontier | 120 | 500 | 10 | 1500 | 7200 s | $1.200 | 30 min-2 h |

Your own `max_*` values can only lower a tier cap. The cost cap covers platform spend only.

## Pricing

| Action | Cost | Notes |
|---|---|---|
| Search run by a job | $0.001 per search (1,000 microcents) | Same flat price as a unified search query |
| Page fetch or extraction | Scrape tier price of the URL | Priced per step; the job total is in `cost.platform_microcents` |
| Model tokens | Not billed by AlterLab | Run on your own key and billed by your provider |

New accounts get up to 5,000 free requests. See /pricing.

## Errors

| HTTP | error | Meaning |
|---|---|---|
| 400 | `byok_key_required` | No LLM key on the account. Nothing stored or charged. |
| 400 | `invalid_webhook_url` | Webhook URL failed validation. |
| 404 | `research_not_found` | Unknown job id for this account. |
| 409 | `research_already_finished` | Cancel on a job that already finished. |
| 409 | `research_not_awaiting_approval` | Plan read, edit or approve on a job that is not awaiting approval. |

## Examples

```python
import time
import requests

API = "https://api.alterlab.io/api/v1/research"
HEADERS = {"X-API-Key": "YOUR_API_KEY"}

job = requests.post(
    API,
    headers=HEADERS,
    json={"query": "How do vector databases handle filtered search?", "effort": "standard"},
).json()

while job["status"] in ("awaiting_approval", "queued", "running"):
    time.sleep(3)
    job = requests.get(f"{API}/{job['id']}", headers=HEADERS).json()

print(job["status"], job["cost"]["platform_microcents"])
print(job["result"])
```

```javascript
const API = "https://api.alterlab.io/api/v1/research";
const headers = { "X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json" };

let job = await (
  await fetch(API, {
    method: "POST",
    headers,
    body: JSON.stringify({ query: "How do vector databases handle filtered search?", effort: "standard" }),
  })
).json();

while (["awaiting_approval", "queued", "running"].includes(job.status)) {
  await new Promise((r) => setTimeout(r, 3000));
  job = await (await fetch(`${API}/${job.id}`, { headers })).json();
}
console.log(job.status, job.result);
```

## Python, Node and MCP

The SDKs wrap every endpoint above. Research runs on your own model key; without one the call returns `byok_key_required` and nothing is charged.

```python
from alterlab import AlterLab

client = AlterLab(api_key="YOUR_API_KEY")
job = await client.research(
    "Which EU countries ban fur farming?",
    effort="standard",
    max_cost_microcents=50_000,
)
job = await client.wait_for_research(job["id"])
print(job["status"], job["cost"], job["result"])

# plan_first jobs: client.get_research_plan(id), update_research_plan(id, plan), approve_research(id)
# progress: await client.research_events(id, last_event_id="12")
```

```javascript
import { AlterLab } from "alterlab";

const client = new AlterLab({ apiKey: "YOUR_API_KEY" });
const job = await client.research({
  query: "Which EU countries ban fur farming?",
  effort: "standard",
  max_cost_microcents: 50_000,
});
const done = await client.waitForResearch(job.id);
console.log(done.status, done.cost, done.result);

// also: getResearch, cancelResearch, researchEvents, getResearchPlan, updateResearchPlan, approveResearch
```

In the hosted MCP server, the `alterlab_research` tool starts a job (or fetches one with `job_id`), waits within the tool time budget and returns the job. A job still running is returned with its id so the model can call again; nothing is cancelled.
