# For AI agents

> A single self-contained page an autonomous agent can read to integrate the AI Search API end to end — auth, submit, poll, parse, errors, and budgets, with explicit URLs and full JSON shapes.

This page is written for machine consumption. It contains everything an autonomous coding agent needs to integrate the AI Search API end to end: authentication, submitting a search, polling for results, parsing the response, handling errors, and staying within budgets, with explicit URLs and complete JSON shapes. Everything here is plain HTTP.

For the entire documentation set as one file, fetch [`/llms-full.txt`](https://docs.aisearchapi.dev/llms-full.txt). The machine-readable contract is [`/openapi.json`](https://docs.aisearchapi.dev/openapi.json) (OpenAPI 3.1).

## Base URL and authentication

- Base URL: `https://api.aisearchapi.dev`
- Every request except the discovery and health endpoints requires: `Authorization: Bearer <API_KEY>`
- Requests with a JSON body require: `Content-Type: application/json`
- Keys look like `sk_live_...` or `sk_test_...`. Keep them server-side. New accounts start with 500 free credits.
- A **metered** (restricted) key adds live credit headers to its submits — see Step 6.

Two endpoints are **deliberately unauthenticated** so you can discover capabilities before you hold a key: `GET /v1/surfaces` and `GET /v1/regions`. `GET /v1/health` is also public.

## Step 0 — Discover what is live (no key needed)

```bash
curl https://api.aisearchapi.dev/v1/surfaces
```

Response:

```json
{
  "surfaces": [
    { "id": "chatgpt", "live": true },
    { "id": "claude", "live": true },
    { "id": "perplexity", "live": true },
    { "id": "gemini", "live": false },
    { "id": "copilot", "live": true },
    { "id": "google_ai_overview", "live": true },
    { "id": "google_ai_mode", "live": true },
    { "id": "google_search", "live": true },
    { "id": "google_news", "live": true }
  ],
  "schemaVersion": "2026-06-27"
}
```

Only request surfaces where `live` is `true`. A surface with `live: false` (currently `gemini`, phase 2) returns `422 UNSUPPORTED_METHOD_FOR_SURFACE`.

## Step 1 — Decide sync or async

There is one endpoint, `POST /v1/search`, with two response modes:

| Condition                                           | Mode  | Status | You get                                            |
| --------------------------------------------------- | ----- | ------ | -------------------------------------------------- |
| Exactly one surface, no `webhook`, no `?mode=async` | Sync  | `200`  | The terminal result inline, no polling             |
| Multiple surfaces, OR a `webhook`, OR `?mode=async` | Async | `202`  | A parent job id + child ids to poll (or a webhook) |

Force sync with `?mode=sync` (or header `Prefer: wait=30`). Force async with `?mode=async`. Add `?view=flat` to reduce a sync result to `{ surface, status, text, markdown, sources }`.

## Step 2 — Submit a search

`POST https://api.aisearchapi.dev/v1/search`

Request body fields (unknown top-level fields are **rejected** with `400 VALIDATION_FAILED` — send only these):

| Field                | Type              | Required | Notes                                                                                                                                                                                                                                                           |
| -------------------- | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `query`              | string            | yes      | The prompt. 1–2000 chars (over 2000 → `422 QUERY_TOO_LONG`). Alias: `prompt`.                                                                                                                                                                                   |
| `surfaces`           | string[]          | yes      | One or more live surface ids. Each produces one child per region.                                                                                                                                                                                               |
| `regions`            | object[]          | no       | `[{ country, state?, city?, language? }]`, max 10. `country` is ISO-3166 alpha-2. Omitted ⇒ one untargeted `GLOBAL` child per surface. A bare `"US"` string is sugar for `{ "country": "US" }`.                                                                 |
| `region` / `country` | object / string   | no       | Flat sugar for a single-element `regions`.                                                                                                                                                                                                                      |
| `include`            | object            | no       | `{ markdown?: boolean = true, html?: boolean = false }`. `markdown:false` trims `answer.markdown` (`answer.text` is always present). `html:true` adds the proof-of-page HTML URL — consumer-UI surfaces only. Unknown `include` keys → `400 VALIDATION_FAILED`. |
| `extract`            | boolean \| object | no       | Opt in to [Auto Extract](/guides/auto-extract). `true` extracts brands mentioned in the answer; `{ targets: [...] }` also reports pinned brands or domains when absent.                                                                                         |
| `webhook`            | object            | no       | `{ url, secret? }`. Setting it forces async. `url` must be public HTTPS (SSRF-guarded). Omit `secret` to sign with your account's managed secret.                                                                                                               |
| `idempotencyKey`     | string            | no       | A **body field** (not a header). Safe retries — see Step 6.                                                                                                                                                                                                     |

Minimal single-surface (sync) submit:

```bash
curl -X POST "https://api.aisearchapi.dev/v1/search?view=flat" \
  -H "Authorization: Bearer $AISEARCH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "query": "best crm for startups", "surfaces": ["chatgpt"], "country": "US" }'
```

Sync `200` with `?view=flat`:

```json
{
  "surface": "chatgpt",
  "status": "completed",
  "text": "For startups, the top CRMs are HubSpot, Attio and Pipedrive...",
  "markdown": "For startups, the top CRMs are **HubSpot**, **Attio** and **Pipedrive**..."
}
```

On a metered (restricted) key, the sync `200` also carries `X-Credits-Charged` and `X-Credits-Remaining` headers, and the full (non-flat) result includes a `credits` object — see Steps 4 and 6.

Multi-surface (async) submit returns `202`:

```json
{
  "jobId": "job_8t2q",
  "status": "processing",
  "children": ["job_8t2q.chatgpt.us", "job_8t2q.perplexity.us"],
  "credits": { "creditsToCharge": 8, "creditsCharged": 0 }
}
```

On the `202`, `credits.creditsCharged` is `0` until settlement; the parent rollup carries `creditsToCharge` only. The `X-Credits-Charged` / `X-Credits-Remaining` headers are present here too.

**Child id format:** exactly three dot-separated segments — `<jobId>.<surface>.<regionKey>` — where `regionKey` is the lowercased `country[:city][:language]` or `GLOBAL`. Never construct child ids yourself; use the ones returned.

## Step 3 — Poll for results (async)

`GET https://api.aisearchapi.dev/v1/jobs/<id>`

- A **parent** id (no dots) returns a rollup: `{ job: { id, status, children: [...] }, children: [{ id, surface, region, status }], credits: { creditsToCharge } }`.
- A **child** id (dotted) returns the full Envelope for one surface × region (including its `credits` object).

Poll the parent until `job.status` is terminal, then read each child. Terminal states — **stop on any of these, not just `completed`:**

```
completed | partial | failed | canceled | expired
```

Active (keep polling): `queued`, `running`, `processing`. A claimed-but-not-yet-persisted child returns `{ "job": { "id": ..., "status": "running" }, "children": [] }`.

Parent rollup rules: `completed` iff every child completed; `failed` iff every child failed; otherwise `partial`; `processing` while any child is still active.

Poll one parent to completion, then fetch children:

```python
import os, time, requests

H = {"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"}
TERMINAL = {"completed", "partial", "failed", "canceled", "expired"}

def wait_for(parent_id, interval=2.0, timeout=300.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        roll = requests.get(f"https://api.aisearchapi.dev/v1/jobs/{parent_id}", headers=H).json()
        if roll["job"]["status"] in TERMINAL:
            return roll
        time.sleep(interval)
    raise TimeoutError(parent_id)

roll = wait_for("job_8t2q")
for child in roll["children"]:
    env = requests.get(f"https://api.aisearchapi.dev/v1/jobs/{child['id']}", headers=H).json()
    print(child["surface"], child["region"], env["provenance"]["surfacePresent"])
```

## Step 4 — Parse the Envelope

A child resolves to the public **Envelope**: `job`, `provenance`, `answer`, `evidence`, plus optional `html` and `credits`. Full shape:

```json
{
  "job": {
    "id": "job_8t2q.chatgpt.us",
    "query": "best crm for startups",
    "surface": "chatgpt",
    "region": "us",
    "status": "completed",
    "warnings": [],
    "requestedAt": "2026-06-30T17:02:11Z",
    "completedAt": "2026-06-30T17:02:23Z"
  },
  "provenance": {
    "model": {
      "providerId": null,
      "observedLabel": "GPT-5",
      "inferred": true,
      "confidence": 0.8
    },
    "webSearch": { "enabled": true, "known": true },
    "triggerState": "search",
    "persona": "anonymous_default_model",
    "loginState": "logged_out",
    "surfacePresent": true,
    "region": { "requested": "us", "effective": "us" },
    "capturedAt": "2026-06-30T17:02:23Z",
    "callUuid": "call_9b3c1a8e...",
    "schemaVersion": "2026-06-27"
  },
  "answer": {
    "text": "For startups, the top CRMs are HubSpot, Attio and Pipedrive...",
    "markdown": "For startups, the top CRMs are **HubSpot**, **Attio** and **Pipedrive**...",
    "blocks": [{ "type": "paragraph", "text": "...", "referenceIds": [0] }]
  },
  "evidence": {
    "sources": [
      {
        "id": 0,
        "url": "https://www.hubspot.com/startups",
        "title": "HubSpot for Startups",
        "role": "cited",
        "cited": true,
        "charRanges": [[24, 31]],
        "quote": "HubSpot"
      }
    ],
    "fanOut": {
      "provenance": "observed",
      "queries": ["best crm for startups", "hubspot vs attio pricing"]
    },
    "mentions": null,
    "shopping": null,
    "ads": null
  },
  "credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
```

Field reference:

| Path                                     | Meaning                                                                                                                                                                                                                                    |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `job`                                    | `{ id, query, surface, region?, status, warnings[], requestedAt, completedAt }`.                                                                                                                                                           |
| `answer.text` / `answer.markdown`        | The rendered answer. `text` is always present; `markdown` is present unless you set `include.markdown:false`.                                                                                                                              |
| `answer.blocks[]`                        | The answer as typed blocks (`paragraph`/`heading`/`list`/`code`/`quote`), each `{ type, text, referenceIds[] }`. `referenceIds` point at `evidence.sources[].id`.                                                                          |
| `provenance.model`                       | `{ providerId, observedLabel, inferred, confidence }`.                                                                                                                                                                                     |
| `provenance.webSearch`                   | `{ enabled, known }` — whether the surface ran a web search.                                                                                                                                                                               |
| `provenance.triggerState`                | `search` (ran a live search) or `parametric` (answered from model weights).                                                                                                                                                                |
| `provenance.persona` / `.loginState`     | The persona and login state the capture ran under.                                                                                                                                                                                         |
| `provenance.surfacePresent`              | `false` ⇒ the surface returned nothing (see below).                                                                                                                                                                                        |
| `provenance.region.effective`            | The region the capture actually ran from. Attribute results to this, not `requested`.                                                                                                                                                      |
| `provenance.callUuid` / `.schemaVersion` | Capture correlation id and Envelope schema version. Provenance is lane-free: no method, acquisition, fidelity, engine, or driver detail.                                                                                                   |
| `evidence.sources[]`                     | Structured extraction (cloro parity, no extra credit). `{ id, url, title, role(cited\|retrieved\|related), cited, charRanges, quote? }`.                                                                                                   |
| `evidence.fanOut`                        | `{ provenance(observed\|inferred\|none), queries[] }` — the sub-queries the surface fanned out to.                                                                                                                                         |
| `evidence.mentions`                      | `null` unless the request set `extract`; when requested, contains BETA brand-visibility extraction at no extra credit cost. See [Auto Extract](/guides/auto-extract) for the full shape.                                                   |
| `evidence.shopping` / `.ads`             | Reserved placeholders — currently always `null`.                                                                                                                                                                                           |
| `html`                                   | Optional. Present **only** when you opt in (`include.html:true` on submit, or `?include=html` on poll) **and** the surface is a consumer-UI capture. A proof-of-page HTML URL, resolvable via `GET /v1/artifacts/{key}` with your API key. |
| `credits`                                | `{ creditsToCharge, creditsCharged }` — success-only billing.                                                                                                                                                                              |

When `html` is present the Envelope gains one field, e.g. `"html": "https://api.aisearchapi.dev/v1/artifacts/<key>"`.

**Absence is data.** If a surface returns nothing, the child still reaches `completed` with `provenance.surfacePresent: false`, an empty `answer`, and a `surface_absent` entry in `job.warnings`. This is a valid result, not an error, and it costs no credits. Do not treat it as a failure.

## Step 5 — Handle errors

Every error, on every endpoint, is this flat shape (HTTP status carries the status; it is not duplicated in the body):

```json
{
  "code": "VALIDATION_FAILED",
  "error": "query is required and must be a non-empty string (alias: prompt)",
  "request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
  "docs_url": "https://docs.aisearchapi.dev/guides/errors#validation_failed"
}
```

Branch on `code` (stable), never on `error` (human text, may change). `request_id` matches the `X-Request-Id` response header. Full catalog:

| Code                                                                           | HTTP | Retry?                             |
| ------------------------------------------------------------------------------ | ---- | ---------------------------------- |
| `AUTH_MISSING`, `AUTH_INVALID`                                                 | 401  | No — fix the key                   |
| `SCOPE_FORBIDDEN`                                                              | 403  | No                                 |
| `INSUFFICIENT_CREDITS`                                                         | 402  | No — top up; nothing was spawned   |
| `VALIDATION_FAILED`, `UNSUPPORTED_SURFACE`                                     | 400  | No — fix the request               |
| `UNSUPPORTED_METHOD_FOR_SURFACE`, `TOO_MANY_REGIONS`, `QUERY_TOO_LONG`         | 422  | No — fix the request               |
| `IDEMPOTENCY_CONFLICT`                                                         | 409  | No — new key or same body          |
| `JOB_NOT_FOUND`, `ARTIFACT_NOT_FOUND`                                          | 404  | No                                 |
| `METHOD_NOT_ALLOWED`                                                           | 405  | No                                 |
| `RATE_LIMIT_EXCEEDED`, `CONCURRENCY_LIMIT_EXCEEDED`, `QUEUE_CAPACITY_EXCEEDED` | 429  | Yes — honor `Retry-After`          |
| `ACQUISITION_FAILED`, `UPSTREAM_BAD_GATEWAY`                                   | 502  | Yes — backoff                      |
| `SURFACE_TIMEOUT`                                                              | 504  | Yes — retry or go async            |
| `INTERNAL_ERROR`                                                               | 500  | Yes — backoff, report `request_id` |

## Step 6 — Budgets, rate limits, and idempotency

**Credits.** Per-surface, charged only on a successful capture (empty/failed captures are free). Costs by surface:

<Snippet file="/snippets/surface-costs.mdx" />

Total spend for a search = Σ(surface cost) × regions. An insufficient balance is `402` and nothing runs. Read your ledger at `GET /v1/usage`.

**Credit visibility.** On a metered (restricted) key, every submit returns `X-Credits-Charged` (debited by this submit) and `X-Credits-Remaining` (balance after) on both the sync `200` and the async `202`. A `credits` object `{ creditsToCharge, creditsCharged }` appears on the sync result, the async `202` accept (`creditsCharged` `0` until settlement), each accepted batch item, the parent rollup (`creditsToCharge` only), each child job GET, and the webhook payload.

**Rate limits.** Three independent `429` gates: `RATE_LIMIT_EXCEEDED` (request rate), `CONCURRENCY_LIMIT_EXCEEDED` (sync captures in flight), `QUEUE_CAPACITY_EXCEEDED` (async queue full). Every `429` carries `Retry-After` (seconds) plus `X-RateLimit-Limit`/`-Remaining`/`-Reset`. Always honor `Retry-After`. Read `GET /v1/async/status` to pace against live headroom before a burst.

**Response headers to read:** `X-Request-Id` (correlation), `X-AISearch-Version` (schema version), `X-Credits-Charged` / `X-Credits-Remaining` (metered submits, sync + async), `X-RateLimit-*` (on submits + 429s), `X-Concurrency-*` (on submits), `Retry-After` (on 429), `Idempotent-Replayed: true` (on a replay).

**Idempotency.** Set `idempotencyKey` in the body. The same key + same body replays the original job (`202` + `Idempotent-Replayed: true`, no new work, no double billing). The same key + a different body is `409 IDEMPOTENCY_CONFLICT`. Use a stable derived key so a retry after an ambiguous failure is safe.

## Step 7 — Webhooks (optional, avoids polling)

Attach `webhook: { url, secret }` to a search (forces async). You receive one HTTP `POST` per child on its terminal state:

```json
{
  "id": "evt_call_9b3c1a8e...",
  "type": "job.completed",
  "createdAt": "2026-06-30T17:02:23Z",
  "job": {
    "id": "job_8t2q.chatgpt.us",
    "surface": "chatgpt",
    "region": "us",
    "status": "completed",
    "...": "..."
  },
  "result": {
    "job": {},
    "provenance": {},
    "answer": {},
    "evidence": {},
    "credits": {}
  }
}
```

`result` is the same Envelope as `GET /v1/jobs/<childId>`. Deliveries are **at-least-once** — dedupe on the event `id`.

**Signature (verify BEFORE JSON-parsing).** Every signed delivery carries two headers: `X-AISearch-Timestamp` (unix seconds) and `X-AISearch-Signature`. The signature is `HMAC-SHA256(secret, "${timestamp}.${body}")`, lowercase hex — the signed string is the timestamp, a literal `.`, then the **raw** request body. To verify: (1) capture the raw body bytes before parsing, (2) bound the timestamp skew — reject anything older than ~5 minutes, (3) recompute `HMAC-SHA256` over `"${timestamp}.${rawBody}"`, (4) compare with a timing-safe equality.

```python
import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str, max_skew: int = 300) -> bool:
    # 1. bound the timestamp skew (reject replays older than ~5 minutes)
    if abs(time.time() - int(timestamp)) > max_skew:
        return False
    # 2. recompute HMAC over `${timestamp}.${rawBody}`
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # 3. timing-safe compare
    return hmac.compare_digest(expected, signature or "")
```

Always keep polling (`GET /v1/jobs/:id`) as the fallback path — the result is persisted regardless of delivery.

## Step 8 — Use MCP instead of raw HTTP (optional)

If your agent prefers tool calling, connect it to the [MCP server](/mcp). It exposes the same search, job, usage, surface, and watch operations with the same authentication, credits, tenancy, and error semantics.

## Endpoint index

| Method   | Path                    | Auth     | Purpose                                                 |
| -------- | ----------------------- | -------- | ------------------------------------------------------- |
| `POST`   | `/v1/search`            | Bearer   | Submit a search (sync or async)                         |
| `POST`   | `/v1/search/{surface}`  | Bearer   | Single-surface alias (accepts `prompt`, flat `country`) |
| `POST`   | `/v1/search/batch`      | Bearer   | Up to 500 searches, per-item results                    |
| `GET`    | `/v1/jobs`              | Bearer   | List parent jobs (cursor-paginated)                     |
| `GET`    | `/v1/jobs/{id}`         | Bearer   | Parent rollup or child Envelope                         |
| `POST`   | `/v1/jobs/{id}/cancel`  | Bearer   | Cancel remaining children                               |
| `GET`    | `/v1/artifacts/{key}`   | Bearer   | Resolve an opt-in proof-of-page HTML artifact           |
| `GET`    | `/v1/usage`             | Bearer   | Usage ledger + balance                                  |
| `GET`    | `/v1/async/status`      | Bearer   | Live queue/concurrency snapshot                         |
| `GET`    | `/v1/webhooks/secrets`  | Bearer   | Managed signing-secret metadata                         |
| `POST`   | `/v1/watches`           | Bearer   | Create a scheduled query subscription                   |
| `GET`    | `/v1/watches`           | Bearer   | List watches (cursor-paginated)                         |
| `GET`    | `/v1/watches/{id}`      | Bearer   | Read a watch and its 10 most recent runs                |
| `PATCH`  | `/v1/watches/{id}`      | Bearer   | Update, pause, or resume a watch                        |
| `DELETE` | `/v1/watches/{id}`      | Bearer   | Soft-cancel a watch                                     |
| `GET`    | `/v1/watches/{id}/runs` | Bearer   | List a watch's run history (cursor-paginated)           |
| `GET`    | `/v1/surfaces`          | **none** | Live surface capability matrix                          |
| `GET`    | `/v1/regions`           | **none** | Geo targeting model                                     |
| `GET`    | `/v1/health`            | **none** | Liveness (`?probe=deep` for dependency probe)           |

Complete parameter and schema detail: [`/openapi.json`](https://docs.aisearchapi.dev/openapi.json). Whole corpus as one file: [`/llms-full.txt`](https://docs.aisearchapi.dev/llms-full.txt).

For tool-calling clients, the authenticated MCP endpoint is [`/mcp`](/mcp).
