# AI Search API — full documentation
> One API to query every major consumer AI search surface (ChatGPT, Claude, Perplexity, Copilot, Google AI Overview/Mode, Google Search, Google News) and get the answer users actually see, with provenance, as one stable, versioned Envelope. Captures are browser-first from the live apps, not sanitized model APIs. Honesty is a contract: absence-is-data, fail-loud, never a mock served as real.
This file concatenates every documentation page in navigation order. The machine-readable HTTP contract is https://docs.aisearchapi.dev/openapi.json.
---
# Introduction
Source: https://docs.aisearchapi.dev/introduction
> One API for every AI answer engine. Get what AI assistants actually tell your users — every engine, one JSON contract.
## What is the AI Search API?
The AI Search API is a single endpoint for querying the major consumer AI search engines and getting back **the answer users actually see**, as clean JSON, with provenance on exactly how it was captured.
Captures are **browser-first**: by default they come from a real browser driving the live AI apps, not the sanitized model API. So you get what a real person would read: the rendered answer and which model produced it.
The actual rendered response, as `text`, `markdown`, and structured blocks,
from the live app rather than a model endpoint.
How the answer was produced: which model, whether web search ran, the
effective region, and the schema version.
## Base URL and auth
```
https://api.aisearchapi.dev
```
Every request needs a bearer token and JSON content type:
```bash
Authorization: Bearer $AISEARCH_API_KEY
Content-Type: application/json
```
New accounts start with **500 free credits**, no card required. See [Authentication](/authentication) to get your key.
## One call, every surface
Send one request naming the surfaces you want. The API fans out — running each surface (and region) in parallel — and hands you the **same Envelope shape** for every one. Integrate the shape once, and every AI search engine looks the same to your code.
```bash cURL
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity", "claude"]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity', 'claude'],
}),
});
const job = await res.json();
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity", "claude"],
},
)
job = res.json()
```
A multi-surface request like this returns a `202` with a parent job and one child per surface × region. Each child id records the surface and the region key:
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.GLOBAL",
"job_8t2q.perplexity.GLOBAL",
"job_8t2q.claude.GLOBAL"
]
}
```
Read each child with `GET /v1/jobs/:id` to get its Envelope, or register a [webhook](/guides/webhooks) and skip polling entirely. A **single-surface** request with no webhook runs **synchronously by default** — one `200` with the finished result inline, no polling at all. See the [Quickstart](/quickstart).
## Why use it
Every AI search engine has a different UI, a different structure, and no clean way to read it programmatically. Building that yourself means N brittle integrations that break every time an app ships a redesign.
The AI Search API collapses that into **one contract**: one request, one response shape, every surface. You skip the per-provider integrations and the maintenance that comes with them, and get straight to the answers and the data behind them.
## Supported surfaces
| Surface | Enum value | Credits |
| ------------------ | -------------------- | ------- |
| ChatGPT | `chatgpt` | 5 |
| Claude (API) | `claude` | 3 |
| Perplexity | `perplexity` | 3 |
| Copilot | `copilot` | 5 |
| Google AI Overview | `google_ai_overview` | 5 |
| Google AI Mode | `google_ai_mode` | 4 |
| Google Search | `google_search` | 3 |
| Google News | `google_news` | 3 |
Credits are charged only on a successful capture — an empty or failed capture costs nothing. Google Search and Google News each cost **3 credits** per capture — see [Credits](/guides/credits).
**Gemini** (`gemini`) is a valid enum value but has **no live capture path
yet** — submitting it returns `422 UNSUPPORTED_METHOD_FOR_SURFACE`. It lands
in phase 2, together with Meta AI, DeepSeek, Amazon Rufus, and Grok. `GET
/v1/surfaces` returns the live capability matrix at any time.
## Next steps
Your first search in a few minutes — from key to Envelope.
Get an API key and send authenticated requests.
The canonical response shape: job, provenance, answer, and evidence — plus
optional html and credits.
Every endpoint, parameter, and field.
A single self-contained page to integrate end to end — built for machine
consumption.
---
# Quickstart
Source: https://docs.aisearchapi.dev/quickstart
> Go from zero to your first captured AI answer, as JSON, in one API call.
Query a real consumer AI search engine and get the answer users actually see, as JSON, with provenance on how it was captured. This guide gets you from an API key to a parsed [Envelope](/guides/output-formats) in one call.
Integrating with an AI agent or coding assistant? The [For AI
agents](/for-ai-agents) page is a single self-contained spec — auth, submit,
poll, parse, errors, budgets — with explicit URLs and full JSON shapes. Or
hand it the whole corpus at
[`/llms-full.txt`](https://docs.aisearchapi.dev/llms-full.txt).
Grab a key from the dashboard, then export it so the examples below just work.
1. Open the dashboard and go to **Settings → API Keys**.
2. Create a key and copy it (you only see the full value once).
3. Export it in your shell:
```bash
export AISEARCH_API_KEY="sk_live_..."
```
Verify connectivity with the unauthenticated health check:
```bash
curl https://api.aisearchapi.dev/v1/health
```
```json Response
{
"status": "ok",
"schemaVersion": "2026-06-27",
"bindings": { "artifacts": true, "edgeCache": true, "hyperdrive": true }
}
```
New accounts start with **500 free credits**, no card required. A capture is charged only on success — an empty or failed capture costs nothing. See [pricing](https://aisearchapi.dev/pricing).
Submit a `query` and one `surface`. A **single-surface request with no webhook runs synchronously by default**: the API holds the connection while the capture runs and returns the finished result in one `200`. Add `?view=flat` for the simplest possible shape.
```bash cURL
curl "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"]
}'
```
```javascript Node
const res = await fetch("https://api.aisearchapi.dev/v1/search?view=flat", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "best crm for startups",
surfaces: ["chatgpt"],
}),
});
const result = await res.json();
console.log(result.text); // the answer ChatGPT actually rendered
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search?view=flat",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt"],
},
)
print(res.json()["text"])
```
```json 200 OK (?view=flat)
{
"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**..."
}
```
Drop `?view=flat` and the same call returns the full result: the parent id, its rollup status, and the [Envelope](/guides/output-formats) per child:
```json 200 OK (default)
{
"jobId": "job_8t2q",
"status": "completed",
"children": [ { "job": { ... }, "provenance": { ... }, "answer": { ... }, "evidence": { ... }, "credits": { ... } } ]
}
```
Shape the payload with **`include`**. `include: { markdown: false }` drops `answer.markdown` (`answer.text` always stays); `include: { html: true }` adds an opt-in proof-of-page `html` URL for consumer-UI (scrape) surfaces. Unknown `include` keys return `400 VALIDATION_FAILED`.
```bash
# text-only (no markdown)
-d '{ "query": "best crm for startups", "surfaces": ["chatgpt"], "include": { "markdown": false } }'
# opt into the proof-of-page HTML URL (scrape surfaces only)
-d '{ "query": "best crm for startups", "surfaces": ["chatgpt"], "include": { "html": true } }'
```
Sync is the default only for the hello-world case: **one surface, no webhook**. Multi-surface requests, requests with a `webhook`, or `?mode=async` return `202` and run durably (next step). You can also force the inline path explicitly with `?mode=sync` or the header `Prefer: wait=30`.
For more surfaces or regions, use the durable path: it returns `202` immediately with a parent job and **one child per surface × region**. A child id has three dot-separated segments — `..`.
```bash
curl "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }]
}'
```
```json 202 Accepted
{
"jobId": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.perplexity.us"
]
}
```
Fetch a job with `GET /v1/jobs/:id`. A **parent** id (no dots) returns the rollup; a **child** id (dotted) returns the canonical Envelope. While a capture is still running a child returns `{ "job": { "id": ..., "status": "running" }, "children": [] }`. Poll until `job.status` reaches a terminal state — `completed`, `partial`, `failed`, `canceled`, or `expired` — and never past it.
```bash cURL
PARENT="job_8t2q"
while true; do
BODY=$(curl -s "https://api.aisearchapi.dev/v1/jobs/$PARENT" \
-H "Authorization: Bearer $AISEARCH_API_KEY")
STATUS=$(echo "$BODY" | jq -r '.job.status')
echo "status: $STATUS"
case "$STATUS" in
completed|partial|failed|canceled|expired)
# fetch each child's Envelope
echo "$BODY" | jq -r '.children[].id' | while read -r CHILD; do
curl -s "https://api.aisearchapi.dev/v1/jobs/$CHILD" \
-H "Authorization: Bearer $AISEARCH_API_KEY" | jq '.answer.text'
done
break ;;
esac
sleep 2
done
```
```javascript Node
const TERMINAL = ['completed', 'partial', 'failed', 'canceled', 'expired'];
const headers = { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` };
async function waitForParent(parentId) {
while (true) {
const res = await fetch(
`https://api.aisearchapi.dev/v1/jobs/${parentId}`,
{ headers },
);
const { job, children } = await res.json();
console.log('status:', job.status);
if (TERMINAL.includes(job.status)) return children;
await new Promise((r) => setTimeout(r, 2000));
}
}
const children = await waitForParent('job_8t2q');
for (const child of children) {
const env = await (
await fetch(`https://api.aisearchapi.dev/v1/jobs/${child.id}`, { headers })
).json();
console.log(child.surface, child.region, env.answer.text);
}
```
```python Python
import os, time, requests
TERMINAL = {"completed", "partial", "failed", "canceled", "expired"}
headers = {"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"}
def wait_for_parent(parent_id, interval=2.0):
while True:
body = requests.get(
f"https://api.aisearchapi.dev/v1/jobs/{parent_id}",
headers=headers,
).json()
print("status:", body["job"]["status"])
if body["job"]["status"] in TERMINAL:
return body["children"]
time.sleep(interval)
for child in wait_for_parent("job_8t2q"):
env = requests.get(
f"https://api.aisearchapi.dev/v1/jobs/{child['id']}",
headers=headers,
).json()
print(child["surface"], child["region"], env["answer"]["text"])
```
Polling is fine for a single surface. For fan-out across many surfaces and regions, prefer [webhooks](/guides/webhooks) — you get a POST on each child's terminal state instead of polling N jobs.
A completed child returns `job`, `provenance`, `answer`, `evidence`, and `credits` (plus an optional `html` proof-of-page URL when you opt in with `include.html: true`).
```json GET /v1/jobs/job_8t2q.chatgpt.us
{
"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": "c1a2b3c4-9d8e-4f70-8a12-6b5c4d3e2f10",
"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": "For startups, the top CRMs are HubSpot...", "referenceIds": [0] }
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.hubspot.com/products/crm",
"title": "HubSpot CRM",
"role": "cited",
"cited": true,
"charRanges": [[24, 31]],
"quote": "HubSpot"
}
],
"fanOut": { "provenance": "observed", "queries": ["best crm for startups"] },
"mentions": null,
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
```
### Key fields to read first
| Field | What it gives you |
| --- | --- |
| `answer.text` | The plain-text answer the user sees. |
| `answer.markdown` | The same answer with formatting; present unless you set `include.markdown: false`. |
| `answer.blocks[]` | The answer broken into structured blocks; each block's `referenceIds` point at `evidence.sources[].id`. |
| `evidence.sources[]` | Structured citations — `url`, `title`, and `role` (`cited`, `retrieved`, `related`). Included at no extra credit cost. |
| `evidence.mentions` | Brand-visibility extraction — opportunistic and forthcoming, currently always `null`. |
| `provenance.model` | Which model produced it, whether it was `inferred`, and a `confidence` score. |
| `provenance.surfacePresent` | `false` means the surface returned nothing (see below). |
| `credits` | `creditsToCharge` and `creditsCharged` — you're billed only on success. |
When a surface returns nothing, the job still reaches `completed` with `provenance.surfacePresent: false`, an empty `answer`, and a `surface_absent` entry in `job.warnings`. Absence is a valid result — the capture costs no credits.
## Safe retries with an idempotency key
Pass `idempotencyKey` **in the body** (it is not a header). Re-submitting the same key with the same body replays the original job — `202` with the header `Idempotent-Replayed: true`, no new work, no double billing. The same key with a _different_ body returns `409 IDEMPOTENCY_CONFLICT`.
```bash
curl "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"idempotencyKey": "crm-report-2026-06-30"
}'
```
## Per-surface alias
`POST /v1/search/:surface` targets one surface (sync by default, like any single-surface call) and accepts `prompt` (an alias of `query`) plus a flat `country`:
```bash
curl "https://api.aisearchapi.dev/v1/search/chatgpt?view=flat" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "prompt": "best crm for startups", "country": "US" }'
```
Sync mode resolves one surface at a time. For more than one surface — or when
you hit `429 CONCURRENCY_LIMIT_EXCEEDED` — use `?mode=async` with polling or
webhooks.
## Fan out across surfaces & regions
Add more surfaces and regions to a single search. The API creates **one child per surface × region**, and the parent tracks them all. The region key in each child id is the lowercased `country[:city][:language]`.
```bash
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity", "google_ai_overview"],
"regions": [
{ "country": "US" },
{ "country": "GB", "language": "en" }
]
}'
```
```json 202 Accepted
{
"jobId": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.chatgpt.gb:en",
"job_8t2q.perplexity.us",
"job_8t2q.perplexity.gb:en",
"job_8t2q.google_ai_overview.us",
"job_8t2q.google_ai_overview.gb:en"
]
}
```
Read the **parent** id (no dots) to see the roll-up:
```bash
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```json
{
"job": {
"id": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.chatgpt.us", "job_8t2q.perplexity.us"]
},
"children": [
{
"id": "job_8t2q.chatgpt.us",
"surface": "chatgpt",
"region": "us",
"status": "completed"
},
{
"id": "job_8t2q.perplexity.us",
"surface": "perplexity",
"region": "us",
"status": "running"
}
]
}
```
The parent is `completed` when all children complete, `failed` when all fail, and `partial` on a mix — `processing` while any child is still running.
## When something goes wrong
Every error, on every endpoint, is the same flat shape — a stable `code`, an actionable `error` message, the request's correlation id, and a deep link into the error catalog:
```json 400 Bad Request
{
"code": "UNSUPPORTED_SURFACE",
"error": "surface 'grok' is not supported; valid surfaces: chatgpt, claude, perplexity, gemini, copilot, google_ai_overview, google_ai_mode, google_search, google_news",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#unsupported_surface"
}
```
The `request_id` always matches the `X-Request-Id` response header (send your own `X-Request-Id` to thread a correlation id through).
## Next steps
Get a signed POST on each child's terminal state instead of polling.
Submit up to 500 searches in one request.
Codes, statuses, and the rate-limit headers to respect.
The canonical response shape, field by field.
---
# For AI agents
Source: https://docs.aisearchapi.dev/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 `
- 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 — `..` — 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/`
- 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/"`.
**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:
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/`. 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).
---
# MCP server
Source: https://docs.aisearchapi.dev/mcp
> Connect an MCP client to AI Search API and use search, job, usage, surface, and watch tools without writing raw HTTP calls.
The AI Search API MCP server exposes search, job, usage, surface, and watch operations as tools for agents. It uses **Streamable HTTP** at:
```text
https://api.aisearchapi.dev/mcp
```
The server implements protocol revision `2025-06-18` and is stateless: every request gets a fresh context, with no MCP session persistence between requests.
## Authentication
MCP uses the same API keys as the REST API. Send a restricted `sk_live_...` or `sk_test_...` key as a Bearer token:
```http
Authorization: Bearer sk_live_...
```
A missing or invalid key returns HTTP `401`, includes `WWW-Authenticate: Bearer`, and uses the standard flat JSON error shape:
```json 401
{
"code": "AUTH_INVALID",
"error": "missing or invalid API key; send Authorization: Bearer ",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#auth_invalid"
}
```
OAuth is on the roadmap, but is not available today. MCP clients must send an
API key.
## Setup
### Claude Code
```bash
claude mcp add --transport http aisearchapi https://api.aisearchapi.dev/mcp \
--header "Authorization: Bearer sk_live_..."
```
### Cursor
Add the server to `.cursor/mcp.json`. Cursor supports direct HTTP transport:
```json
{
"mcpServers": {
"aisearchapi": {
"url": "https://api.aisearchapi.dev/mcp",
"headers": {
"Authorization": "Bearer sk_live_..."
}
}
}
}
```
### Claude Desktop
Claude Desktop's configuration is stdio-oriented, so bridge to the remote server with the standard `mcp-remote` adapter package:
```json
{
"mcpServers": {
"aisearchapi": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.aisearchapi.dev/mcp",
"--header",
"Authorization: Bearer ${AISEARCH_API_KEY}"
]
}
}
}
```
If your Desktop build supports native remote HTTP MCP servers, point it directly at the MCP URL with the same `Authorization` header instead of using the bridge.
Replace the placeholder with your real `sk_live_...` or `sk_test_...` value.
Keep the key server-side and out of source control. Anyone who holds it can
spend your credits; revoke and replace it immediately if exposed.
## Tool catalog
| Tool | What it does | Cost / latency |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `search` | `{ query, surfaces?, country?, extract?, targets? }` runs a search. One surface uses the sync path; multiple surfaces poll briefly. Returns `{ surface, status, answerMarkdown, sources[], mentions, credits }` per surface. | Billed like `POST /v1/search`. A single-surface call takes ~30–60s; MCP clients should not apply a timeout under 90s. |
| `get_job` | `{ id }` returns the same Envelope as `GET /v1/jobs/:id`, compacted. | Free (read). |
| `list_jobs` | `{ limit?, status? }` returns your recent parent jobs. | Free (read). |
| `list_surfaces` | No args. Returns every surface, its credit cost, and whether it is live. | Free (read; unauthenticated on the REST side too). |
| `get_usage` | No args. Returns your credit balance and a recent usage rollup. | Free (read). |
| `create_watch` | Accepts the same body as `POST /v1/watches`. | Free for the watch itself; runs bill per surface when dispatched. |
| `list_watches` | `{ limit?, status? }` returns your watches. | Free (read). |
| `update_watch` | `{ id, ...fields }` applies the same update as `PATCH /v1/watches/:id`. | Free (read/write; no credit cost). |
| `delete_watch` | `{ id }` soft-cancels a watch. | Free. |
| `get_watch_runs` | `{ id, limit? }` returns a watch's run history. | Free (read). |
Every tool call proxies through the corresponding REST endpoint with the same authentication, credits, tenancy, and error semantics. The MCP layer only reshapes JSON to use fewer agent tokens; markdown answers are truncated to about 8,000 characters with a note when truncation occurs.
## Errors
Tool errors map to the same stable codes, flat error shape, and `docs_url` values as the REST API. See [Error handling](/guides/errors) for the complete catalog and retry guidance.
## Related
Integrate the same API directly over raw HTTP.
The request, response, and billing contract behind the search tool.
Schedule recurring searches exposed through the watch tools.
---
# Authentication
Source: https://docs.aisearchapi.dev/authentication
> Authenticate every AI Search API request with a Bearer token, keep keys server-side, and handle the two auth errors.
The AI Search API uses **Bearer token authentication**. Every request must include an `Authorization` header with your API key:
```
Authorization: Bearer
```
Requests that send or receive JSON should also set `Content-Type: application/json`.
## Get an API key
Create and manage keys from your dashboard at [aisearchapi.dev](https://aisearchapi.dev). New accounts start with **500 free credits** — no card required. Sign-up requires a **work email address** — personal and disposable providers (gmail, outlook, etc.) aren't accepted.
Go to [aisearchapi.dev](https://aisearchapi.dev) and sign in (or create an
account).
On first sign-in you'll be asked to create a team — every workspace on AI
Search API is a team, so keys, credits, and billing all live under it. Name
it and continue; you can invite teammates later from **Settings → Members**.
In your team workspace, open **Settings → API Keys** and generate a new key.
Copy it immediately — the full value is shown only once.
Keep the key in a server-side secret, for example `AISEARCH_API_KEY`. Never
commit it to source control.
## Authenticate a request
Send the key in the `Authorization` header on every call.
```bash cURL
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt"]
}'
```
```javascript Node (fetch)
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt'],
}),
});
const data = await res.json();
```
```python Python (requests)
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt"],
},
)
data = res.json()
```
## Keep keys server-side
Your API key is a secret. Anyone who holds it can spend your credits. **Never** ship it in a browser, mobile app, or any other client you don't control — bundled JavaScript, source maps, and network tabs are all readable by end users.
Call the API only from your backend, a serverless function, or another trusted server-side environment, and proxy requests from your frontend through it.
If a key is ever exposed, revoke it in the dashboard and issue a new one.
## Auth errors
Every error response uses the same flat envelope — a stable `code`, an actionable `error` message, the request's correlation id, and a deep link into the [error catalog](/guides/errors):
```json
{
"code": "AUTH_INVALID",
"error": "missing or invalid API key; send Authorization: Bearer ",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#auth_invalid"
}
```
There are two authentication-specific error codes, both returning `401`:
Returned by the core data-plane endpoints (`POST /v1/search`, `GET /v1/jobs`,
`/v1/usage`, `/v1/artifacts`) whenever the key is unusable — this covers a
**missing** `Authorization` header, a malformed header, and a well-formed key
that is wrong, revoked, or unknown. On these endpoints, a missing key and an
invalid key are indistinguishable.
Returned only by the account-management endpoints when no `Authorization`
header is sent. The core data-plane endpoints never emit this — they return
`AUTH_INVALID` instead.
If you get `AUTH_INVALID` with a key you believe is set, check that your HTTP
client actually forwards the header — some tools strip `Authorization` on
redirects, and empty environment variables silently produce `Bearer ` with no
token.
## No auth required for health
The liveness endpoint is public and does **not** require a key:
```bash
curl https://api.aisearchapi.dev/v1/health
```
```json
{
"status": "ok",
"schemaVersion": "2026-06-27",
"bindings": { "artifacts": true, "edgeCache": true, "hyperdrive": true }
}
```
Every other endpoint requires a valid Bearer token — with two **deliberate** exceptions. The discovery endpoints `GET /v1/surfaces` and `GET /v1/regions` are intentionally public, so an agent or client can enumerate the live capability matrix and region targeting **before** it has a key. Their data is public and safe. Every other `/v1/*` route returns `401 AUTH_INVALID` without a valid key.
Send a search and read the result, end to end.
---
# Making requests
Source: https://docs.aisearchapi.dev/guides/making-requests
> The anatomy of an AI Search API request and the two shapes a response can take.
Every AI Search API call follows the same shape: a JSON `POST` to the base URL, authenticated with your API key. You describe **what to ask** (a `query`) and **where to ask it** (one or more `surfaces`), and the API captures the answer — browser-first, from the live AI apps — then normalizes every result into one consistent [Envelope](/guides/output-formats).
## The essentials
Every request shares three constants:
- **Base URL** — `https://api.aisearchapi.dev`
- **Auth** — an `Authorization: Bearer ` header on every call
- **Format** — JSON in, JSON out (`Content-Type: application/json`)
Keep your API key server-side. Never ship it in client-side code or commit it
to source control. The examples below read it from an `AISEARCH_API_KEY`
environment variable.
## The request body
The core endpoint is `POST /v1/search`. At minimum it needs a `query` and at least one surface in `surfaces`.
What to ask — a non-empty string, up to 2000 characters (longer is `422
QUERY_TOO_LONG`). `prompt` is accepted as an alias.
Which AI apps to capture from. One or more of `chatgpt`, `claude`,
`perplexity`, `gemini`, `copilot`, `google_ai_overview`, `google_ai_mode`,
`google_search`, `google_news`. See [Surfaces](/guides/surfaces) for which are
live today (`gemini` currently has no v1 path and returns `422`).
Where to run each capture: `{ country, state?, city?, language? }`, with `country` as an ISO-3166 alpha-2 code. At most **10** per request. When omitted, each surface runs one untargeted (`GLOBAL`) capture. See [Regions](/guides/regions).
`{ url, secret? }` — get a signed POST when each capture finishes instead of polling. Setting a webhook forces the async path. See [Webhooks](/guides/webhooks).
Output-format flags: `{ markdown?: boolean = true, html?: boolean = false }`.
Set `markdown: false` to trim `answer.markdown` from the result
(`answer.text` is always present); set `html: true` to add a proof-of-page
HTML URL on consumer-UI surfaces. Unknown `include` keys are rejected with
`400 VALIDATION_FAILED`. See [Output formats](/guides/output-formats).
A key you supply to safely retry the same request without creating duplicate
work. A body field, not a header.
Send only the documented fields. **Unknown top-level fields are rejected**
with `400 VALIDATION_FAILED` (the message names the offender) — the API fails
loud rather than silently dropping a field that looks like a control.
## One request fans out
A single `POST /v1/search` produces **one child capture per surface × region**. Ask two surfaces across two regions and you get four children — each captured independently, each normalized to the same Envelope.
On the async path, the response is a **parent job** whose `children[]` are the individual captures:
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.chatgpt.us", "job_8t2q.claude.us"]
}
```
Each child id has three dot-separated segments — `job_..` — where the region key is the lowercased `country[:city][:language]` (or `GLOBAL` when untargeted). Fetch a child directly with [`GET /v1/jobs/:id`](/api-reference/get-job) to get its Envelope.
Every Envelope carries the normalized `answer` alongside **structured evidence** — the `sources[]` behind the answer, the `fanOut` queries the surface ran, and any brand `mentions` — at no extra credit cost. See [Output formats](/guides/output-formats) for the full shape.
## Two response shapes
The same endpoint can resolve two ways, depending on what you submit.
A request for **one surface with no webhook** runs synchronously by default:
the API holds the connection while the capture runs and returns **`200`**
with the finished result inline — the parent id, its rollup status, and the
full Envelope per child. You can force this path explicitly with
`?mode=sync` or the header `Prefer: wait=30`, and simplify the shape with
`?view=flat`.
Multiple surfaces, a `webhook`, or `?mode=async` use the durable path:
**`202`** immediately with a parent job in the `processing` state. The
captures run in the background; you learn the outcome by **polling** `GET
/v1/jobs/:id` or by receiving a **webhook**.
Both shapes report billing. Metered (restricted-key) submits return
`X-Credits-Charged` and `X-Credits-Remaining` headers on the sync `200` and
the async `202`, and a `credits` object (`{ creditsToCharge, creditsCharged }`)
rides along on the sync result and each child job. Billing is success-only.
See [Credits](/guides/credits).
### Synchronous vs. asynchronous
| | Synchronous | Asynchronous |
| ------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| When it applies | Default for one surface + no webhook; forced by `?mode=sync` / `Prefer: wait=30` | Multi-surface, a `webhook`, or `?mode=async` |
| Status code | `200` | `202` |
| Response | Result inline (Envelope per child) | Parent job + child ids |
| You get results by | The same HTTP response | Polling `GET /v1/jobs/:id` or a webhook |
| Best for | Interactive, single-surface lookups | Multi-surface / multi-region jobs, batch work, background pipelines |
The convenience alias `POST /v1/search/:surface` (for example
`/v1/search/chatgpt`) targets one surface — sync by default like any
single-surface call — and accepts `prompt` as an alias of `query` plus a flat
`country`. It's the quickest way to hit one surface.
## A minimal request
This asks ChatGPT and Claude the same question. Because it names two surfaces, it returns a `202` parent job.
```bash cURL
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best noise-cancelling headphones under $300",
"surfaces": ["chatgpt", "claude"]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best noise-cancelling headphones under $300',
surfaces: ['chatgpt', 'claude'],
}),
});
const job = await res.json();
console.log(job.jobId, job.children);
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best noise-cancelling headphones under $300",
"surfaces": ["chatgpt", "claude"],
},
)
job = res.json()
print(job["jobId"], job["children"])
```
Drop `"claude"` from `surfaces` and the same call becomes synchronous automatically — a `200` with the finished ChatGPT result inline.
## Next steps
Get one surface's result back in the same call.
Fan out, then poll the parent job for results.
Skip polling — get notified when captures finish.
---
# Synchronous requests
Source: https://docs.aisearchapi.dev/guides/synchronous
> Get a capture back inline in one blocking call, without polling or webhooks.
Synchronous mode returns the finished result directly in the HTTP response. You make one request, the call blocks for the capture duration, and you get a `200` with the parsed [Envelope](/guides/output-formats). No polling, no webhook.
**Sync is the default for the hello-world case**: a request with **one surface and no webhook** runs synchronously without any flag. Multi-surface requests, requests with a `webhook`, or `?mode=async` use the durable `202` path instead — see [asynchronous requests](/guides/asynchronous).
## Three ways to go synchronous
A `POST /v1/search` with exactly one surface and no `webhook` is synchronous
by default.
Add `?mode=sync` or send the header `Prefer: wait=30` to pin the inline
path.
`POST /v1/search/:surface` (e.g. `/v1/search/chatgpt`) accepts `prompt` (an
alias of `query`) and a flat `country`.
All paths return the same shape. The alias is just a shorter, single-surface
form of the same call.
## Request
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best noise-cancelling headphones under $300",
"surfaces": ["chatgpt"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best noise-cancelling headphones under $300',
surfaces: ['chatgpt'],
regions: [{ country: 'US' }],
}),
});
if (!res.ok) {
throw new Error(`${res.status} ${res.headers.get('Retry-After') ?? ''}`);
}
const result = await res.json();
const envelope = result.children[0];
console.log(envelope.answer.markdown);
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best noise-cancelling headphones under $300",
"surfaces": ["chatgpt"],
"regions": [{"country": "US"}],
},
)
res.raise_for_status()
envelope = res.json()["children"][0]
print(envelope["answer"]["markdown"])
```
The equivalent call via the alias — note `prompt` and flat `country`:
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search/chatgpt" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best noise-cancelling headphones under $300",
"country": "US"
}'
```
## Response
You get `200` with the parent id, its rollup status, and the full Envelope for each child — the same four-section Envelope a child returns from [`GET /v1/jobs/:id`](/api-reference/get-job):
```json
{
"jobId": "job_8t2q",
"status": "completed",
"children": [
{
"job": {
"id": "job_8t2q.chatgpt.us",
"query": "best noise-cancelling headphones under $300",
"surface": "chatgpt",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-30T17:02:11.000Z",
"completedAt": "2026-06-30T17:02:23.000Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "gpt-5-3",
"inferred": true,
"confidence": 0.6
},
"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:23.000Z",
"callUuid": "call_e9e13810ec46f8bbb814497766d1d05c",
"schemaVersion": "2026-06-27"
},
"answer": {
"text": "For under $300, the standouts are the Sony WH-1000XM5 and Bose QuietComfort ...",
"markdown": "For under $300, the standouts are the **Sony WH-1000XM5** and **Bose QuietComfort** ...",
"blocks": [
{
"type": "paragraph",
"text": "For under $300, the standouts are ...",
"referenceIds": [0, 1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.sony.com",
"title": "Sony WH-1000XM5",
"role": "cited",
"cited": true,
"charRanges": [[36, 51]]
},
{
"id": 1,
"url": "https://www.bose.com",
"title": "Bose QuietComfort",
"role": "cited",
"cited": true,
"charRanges": [[57, 74]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best noise cancelling headphones under 300 2026"]
},
"mentions": null,
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
]
}
```
The parent id for this submission. `status` is the rollup across children
(`completed`, `partial`, or `failed`).
One full Envelope per child. Each has four sections: `job`, `provenance`,
`answer`, and `evidence` (plus optional `html` and `credits`). When
`provenance.surfacePresent` is `false` and a `surface_absent` warning is
present, the surface returned nothing. The capture still completes with an
empty answer and costs no credits.
Read `answer.markdown` for display. Add `?view=flat` if all you want is `{ surface, status, text, markdown, sources }`.
## Built-in retry on transient failures
A sync call blocks on a single live capture, so a transient upstream hiccup — a
timeout or a `5xx` from the surface — would otherwise surface immediately as an
error. To keep the default single-surface path reliable, the capture step gets a
**small bounded server-side retry** on transient failures before the response
returns, within the request's wait budget. Only genuinely transient failures are
retried; a deliberate stop (a captcha or login wall) or a validation error is
returned right away, never retried.
This is a **small** budget scoped to the sync wait. Asynchronous jobs get the
full **durable** retry budget of the background Workflow instead — up to 8
bounded steps — so for large fan-outs or flaky surfaces, prefer
[async](/guides/asynchronous). Retries never double-bill: a capture is charged
only once, on success.
## Errors specific to sync
Because the call blocks on a live capture, two error paths matter more here than in async.
Your key has a **sync concurrency budget** — the number of blocking captures
allowed at once. When too many sync calls are already inflight, new ones get
`429 CONCURRENCY_LIMIT_EXCEEDED`. Wait for the `Retry-After` window, then
retry — or switch to `?mode=async`. The response also carries
`X-Concurrency-Limit`, `X-Concurrency-Running`, and `X-Concurrency-Queued`
so you can back off precisely. See [concurrency & rate
limits](/guides/concurrency).
If the surface doesn't respond within the capture window, you get `504
SURFACE_TIMEOUT`. This is a transient upstream timeout — retry the request.
No credits are charged for the failed capture, since captures are billed
only on success.
All `429`s also carry `Retry-After` plus `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, and `X-RateLimit-Reset`. Honor `Retry-After` before
retrying.
## When not to use sync
Do not use synchronous mode for fan-out. A sync call captures **one surface ×
region** and holds the connection for its entire duration — issuing many in
parallel will exhaust your sync concurrency budget and stall behind slow
captures. For multiple surfaces or regions, submit one async `POST /v1/search`
and receive results via [webhooks](/guides/webhooks) or by polling children.
See [asynchronous requests](/guides/asynchronous).
## Next steps
Fan out across surfaces and regions without blocking.
Understand the sync budget, rate limits, and the headers to back off on.
---
# Asynchronous requests
Source: https://docs.aisearchapi.dev/guides/asynchronous
> How POST /v1/search fans out into a parent job and one child per surface × region, the lifecycle states, and how to read results by polling or webhook.
Every search you submit becomes a **job**. A request that spans multiple AI surfaces or carries a `webhook` runs asynchronously: you get an immediate acknowledgment, and the actual captures run durably in the background. (A single-surface request with no webhook runs [synchronously by default](/guides/synchronous) — add `?mode=async` to force the durable path for it too.)
## The async model
An async `POST /v1/search` returns `202 Accepted` right away with a **parent job**. The parent fans out into one **child** per `surface × region` — that's the unit of work that actually runs a capture and produces an [Envelope](/guides/output-formats).
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }, { "country": "GB" }]
}'
```
```js Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }, { country: 'GB' }],
}),
});
const job = await res.json();
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}, {"country": "GB"}],
},
)
job = res.json()
```
The `202` response names the parent and lists its children — here, 2 surfaces × 2 regions = 4 children:
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.chatgpt.gb",
"job_8t2q.perplexity.us",
"job_8t2q.perplexity.gb"
]
}
```
The response also carries `X-Request-Id`, `X-AISearch-Version`, and the live
`X-RateLimit-*` / `X-Concurrency-*` admission headers. For a single surface
where you'd rather block and get the result inline, that's the [sync
default](/guides/synchronous) (or force it with `?mode=sync` / `Prefer:
wait=30`).
## Parent vs child ids
The shape of a job id tells you what you'll get back when you read it.
Resolves to the fan-out summary: `{ job, children[] }`. Use it to see overall status and enumerate children.
Shaped `job_..`: three segments, the surface and the lowercased region key (`country[:city][:language]`, or `GLOBAL` when untargeted). Resolves to the canonical [Envelope](/guides/output-formats), the answer and its provenance, for exactly one surface in one region.
Both are read through the same endpoint, `GET /v1/jobs/:id`. The id you pass decides which shape comes back. Never construct child ids by hand — use the ids the API returns.
## Lifecycle states
| State | Kind | Meaning |
| ------------ | -------- | --------------------------------------------------- |
| `queued` | Active | Accepted, waiting to run. |
| `running` | Active | A child's capture is in progress. |
| `processing` | Active | Parent only — children are still in flight. |
| `completed` | Terminal | Finished successfully. |
| `partial` | Terminal | Parent only — some children completed, some failed. |
| `failed` | Terminal | Did not produce a result. |
| `canceled` | Terminal | Stopped before completion. |
| `expired` | Terminal | Aged out before running. |
A polling loop must stop on **any** terminal state — not just `completed`.
Treat `completed`, `partial`, `failed`, `canceled`, and `expired` as "done."
### Parent rollup rules
A parent's status is derived from its children:
- **`completed`** — every child completed.
- **`failed`** — every child failed.
- **`partial`** — a mix of completed and failed children.
While any child is still in flight, the parent stays `processing`.
A child can complete even when the surface returned nothing. In that case the
Envelope has `provenance.surfacePresent: false`, an empty `answer`, and a
`surface_absent` warning — the job is still `completed`, and an empty capture
is not charged. See [The Envelope](/guides/output-formats).
## How to read results
You have two ways to collect results after the `202`.
`GET /v1/jobs/job_8t2q` returns the current parent status and every child's
status. Stop as soon as the parent reaches a terminal state.
For every child that reached `completed` (or `partial`'s completed
children), `GET /v1/jobs/job_8t2q.chatgpt.us` to fetch its full Envelope.
A parent `GET` looks like this:
```json
{
"job": {
"id": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.chatgpt.gb",
"job_8t2q.perplexity.us",
"job_8t2q.perplexity.gb"
]
},
"children": [
{
"id": "job_8t2q.chatgpt.us",
"surface": "chatgpt",
"region": "us",
"status": "completed"
},
{
"id": "job_8t2q.chatgpt.gb",
"surface": "chatgpt",
"region": "gb",
"status": "completed"
},
{
"id": "job_8t2q.perplexity.us",
"surface": "perplexity",
"region": "us",
"status": "completed"
},
{
"id": "job_8t2q.perplexity.gb",
"surface": "perplexity",
"region": "gb",
"status": "running"
}
]
}
```
Then read a finished child:
```bash cURL
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```js Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const envelope = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
envelope = res.json()
```
### A minimal poll loop
```js Node
async function waitForJob(parentId) {
const terminal = ['completed', 'partial', 'failed', 'canceled', 'expired'];
while (true) {
const res = await fetch(`https://api.aisearchapi.dev/v1/jobs/${parentId}`, {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const { job, children } = await res.json();
if (terminal.includes(job.status)) return children;
await new Promise((r) => setTimeout(r, 2000));
}
}
```
For fan-out — many surfaces, many regions — prefer **webhooks** over polling. Register `webhook: { url, secret }` on the search and you'll receive an HMAC-signed POST as each child reaches its terminal state, with the full Envelope in the payload. It's fewer requests and lower latency than a poll loop. See [Webhooks](/guides/webhooks).
The canonical shape a child job resolves to.
Get pushed each child's result instead of polling.
---
# Webhooks
Source: https://docs.aisearchapi.dev/guides/webhooks
> Register a signed webhook on a search and receive the full Envelope when each child job reaches a terminal state — no polling required.
Webhooks are the recommended way to consume results from fan-out searches. Instead of polling `GET /v1/jobs/:id`, you register a `webhook` on a search and we deliver the complete [Envelope](/guides/output-formats) to your endpoint when each child job reaches a terminal state.
One child is created per **surface × region**. You receive one webhook
delivery per child, each carrying that child's full Envelope. Setting a
webhook forces the async (`202`) path, even for a single surface.
## Register a webhook
Add a `webhook` object to any `POST /v1/search` body.
The endpoint that receives deliveries. The destination is SSRF-guarded: only
`http`/`https` schemes are allowed, and private, loopback, link-local, and
metadata addresses are rejected at submission (and re-checked at delivery).
Use a public HTTPS URL.
The shared secret used to sign each delivery with HMAC-SHA256. If you omit it,
deliveries are signed with your account's active **managed signing secret**
(`whsec_…`, created in the dashboard). Store the secret somewhere your
receiver can read it, and never expose it client-side.
```bash cURL
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"webhook": {
"url": "https://example.com/hooks/aisearch",
"secret": "whsec_your_signing_secret"
}
}'
```
```js Node (fetch)
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }],
webhook: {
url: 'https://example.com/hooks/aisearch',
secret: 'whsec_your_signing_secret',
},
}),
});
const job = await res.json();
```
```python Python (requests)
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}],
"webhook": {
"url": "https://example.com/hooks/aisearch",
"secret": "whsec_your_signing_secret",
},
},
)
job = res.json()
```
Managed signing secrets are created and rotated in the **dashboard**. The API
exposes a read-only metadata list at `GET /v1/webhooks/secrets` (never the
secret values) so you can check which secrets are active.
## Delivery payload
Each delivery is an HTTP `POST` with a JSON body shaped like this:
```json Delivery body
{
"id": "evt_call_9b3c1a8e4f2d47c6a1b0e8d2c4f6a8b0",
"type": "job.completed",
"createdAt": "2026-06-30T17:02:23Z",
"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"
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 },
"result": {
"job": { "id": "job_8t2q.chatgpt.us", "...": "same job header as above" },
"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_9b3c1a8e4f2d47c6a1b0e8d2c4f6a8b0",
"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, 1] }]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.hubspot.com/startups",
"title": "HubSpot for Startups",
"role": "cited",
"cited": true,
"charRanges": [[0, 42]]
},
{
"id": 1,
"url": "https://attio.com",
"title": "Attio",
"role": "cited",
"cited": true,
"charRanges": [[44, 49]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best crm for startups"]
},
"mentions": {
"confidence": 0.92,
"items": [
{
"brand": "HubSpot",
"domain": "hubspot.com",
"mentioned": true,
"position": 1,
"sentiment": "positive",
"linked": true,
"target": false,
"sourceIds": [0],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
},
{
"brand": "Attio",
"domain": "attio.com",
"mentioned": true,
"position": 2,
"sentiment": "positive",
"linked": true,
"target": true,
"sourceIds": [1],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
}
]
},
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
}
```
Event id, derived from the child's deterministic call id (`evt_call_…`). A
re-delivery of the same child result reuses the same id — deduplicate on it.
`job.` — the child's terminal status, e.g. `job.completed`, `job.partial`, or `job.failed`.
ISO-8601 timestamp of when the event was generated.
The child's job header: `id`, `query`, `surface`, `region`, `status`,
`warnings`, `requestedAt`, and `completedAt`.
Success-only billing for this child: `{ creditsToCharge, creditsCharged }`.
Present on the top-level payload and again inside `result`.
The full [Envelope](/guides/output-formats) for this child — `job`,
`provenance`, `answer`, `evidence`, an optional `html` proof-of-page URL (only
when the submit opted in with `include.html: true` on a consumer-UI surface),
and `credits` — the same body returned by `GET /v1/jobs/:childId`.
A child can complete with nothing to report: if the surface returned no
answer, the Envelope carries `provenance.surfacePresent: false`, a
`surface_absent` warning, and an empty `answer`. The delivery `type` is still
`job.completed`. Handle this as a normal, successful outcome.
## Verify the signature
Every signed delivery carries **two** headers:
Unix time in **seconds** at which the delivery was signed. Use it to reject
stale or replayed deliveries.
`HMAC-SHA256(secret, "${timestamp}.${body}")`, encoded as lowercase hex.
The signed string is **not** the body alone: it is the timestamp, a literal
`.`, then the exact raw request body bytes — `${timestamp}.${body}`. Binding the
timestamp into the signature is what lets you detect replays.
Capture the **raw body bytes before parsing JSON**, then recompute the HMAC
over `${timestamp}.${rawBody}`. Re-serializing the parsed object (key
reordering, whitespace) changes the bytes and breaks verification. Bound the
timestamp skew — reject anything older than **~5 minutes** — and always
compare with a timing-safe function.
To verify a delivery:
Grab `X-AISearch-Timestamp` and `X-AISearch-Signature`. Reject the request
if either is missing.
Reject if `timestamp` is not a number or differs from your current clock by
more than ~300 seconds. This is your replay defense.
Compute `HMAC-SHA256(secret, "${timestamp}.${rawBody}")` as lowercase hex
and compare it against `X-AISearch-Signature` with a timing-safe equality.
### A worked example
With this exact secret, timestamp, and body, the signed string is
`${timestamp}.${body}` and you must compute this exact signature — use it to
unit-test your verifier:
```text
secret whsec_example_2f8a7c1e
timestamp 1751303911
raw body {"id":"evt_call_9b3c1a8e","type":"job.completed"}
signed string 1751303911.{"id":"evt_call_9b3c1a8e","type":"job.completed"}
signature ead8bf045896b636c9bcf01d1d897b54b31bdaa0df301249d28d596da2ea6e8f
```
```bash Reproduce it
printf '%s' '1751303911.{"id":"evt_call_9b3c1a8e","type":"job.completed"}' \
| openssl dgst -sha256 -hmac "whsec_example_2f8a7c1e" -hex
# (stdin)= ead8bf045896b636c9bcf01d1d897b54b31bdaa0df301249d28d596da2ea6e8f
```
Sign the **exact received bytes**. In real deliveries the body is a full event
carrying the Envelope, not this two-field sample — any re-serialization
changes the hex and fails verification.
Verify the signature yourself in a few lines. Prepend `${timestamp}.` to the raw
request bytes, recompute the HMAC, and compare it against the
`X-AISearch-Signature` header:
```js Node (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
const SECRET = process.env.AISEARCH_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300; // reject deliveries older than ~5 minutes
function verify(rawBody, timestamp, signature, secret) {
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
// Bound the clock skew to defeat replays.
if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return false;
// Sign `${timestamp}.${body}` over the exact received bytes.
const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected = createHmac("sha256", secret).update(signed).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
// Capture the raw bytes — do NOT use express.json() on this route.
app.post(
"/hooks/aisearch",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.get("X-AISearch-Timestamp");
const signature = req.get("X-AISearch-Signature");
if (!verify(req.body, timestamp, signature, SECRET)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge fast, then process out of band. Dedupe on event.id.
res.status(200).send("ok");
queueForProcessing(event); // e.g. push to a job queue
}
);
app.listen(3000);
````
```python Python (Flask)
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["AISEARCH_WEBHOOK_SECRET"].encode()
MAX_SKEW_SECONDS = 300 # reject deliveries older than ~5 minutes
def verify(raw_body: bytes, timestamp: str | None, signature: str | None) -> bool:
if timestamp is None or signature is None:
return False
try:
ts = int(timestamp)
except ValueError:
return False
# Bound the clock skew to defeat replays.
if abs(time.time() - ts) > MAX_SKEW_SECONDS:
return False
# Sign `${timestamp}.${body}` over the exact received bytes.
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/hooks/aisearch")
def aisearch_webhook():
# request.get_data() returns the exact raw bytes.
raw = request.get_data()
timestamp = request.headers.get("X-AISearch-Timestamp")
signature = request.headers.get("X-AISearch-Signature")
if not verify(raw, timestamp, signature):
abort(401)
event = request.get_json()
# Acknowledge fast, then process out of band. Dedupe on event["id"].
queue_for_processing(event)
return "ok", 200
````
## Delivery semantics
Respond as soon as you have verified and accepted the delivery. Do the heavy
work — storing the Envelope, updating your records — asynchronously. A
non-`2xx` response (or a timeout) counts as a failed attempt and is retried.
Deliveries are pushed from a durable workflow step, so the same event `id`
can arrive more than once — after a retry, or if your `2xx` was lost on the
wire. **Deduplicate on the event `id`** and make your handler idempotent so
a repeat delivery is a no-op.
A failed attempt (network error, non-`2xx`, or timeout) is re-run by the
durable engine with **exponential backoff** across several attempts. Because
retries reuse the same event `id`, your dedupe key covers them
automatically.
The result is always persisted regardless of delivery. If every attempt is
exhausted (for example your endpoint stayed down), reconcile by reading `GET
/v1/jobs/:childId` — the webhook payload's `result` is exactly that body,
including its `credits`.
The delivery destination is validated for SSRF on every attempt: only
`http`/`https` URLs are permitted, and requests to private, loopback, and
link-local addresses are refused. Point your webhook at a publicly reachable
HTTPS endpoint.
## Watch run deliveries
A [Watch](/guides/watches) with a `webhookUrl` receives the same signed
delivery for every surface × region on each dispatched run. Event ids,
deduplication, retries, signature verification, and payload semantics are
identical to any other async job.
## Related
The full result shape delivered in `result`.
When jobs reach the terminal states that trigger deliveries.
Submit a search and attach a webhook.
Schedule searches with the same signed delivery semantics.
---
# Error handling
Source: https://docs.aisearchapi.dev/guides/errors
> The AI Search API error model — one flat envelope with a stable code, an actionable message, a request id, and a docs link — plus the full code catalog and how to back off.
Every error the API returns shares one flat shape, so you can handle them with a single code path. This page covers that shape, the full list of codes, and how to back off cleanly when you hit a limit.
## The error model
Every error response — on every endpoint — has the same envelope:
```json Error envelope
{
"code": "UNSUPPORTED_SURFACE",
"error": "surface 'grok' is not supported; valid surfaces: chatgpt, claude, perplexity, gemini, copilot, google_ai_overview, google_ai_mode, google_search, google_news",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#unsupported_surface"
}
```
A stable, machine-readable identifier. Branch on this — never on `error`.
A human-readable, **actionable** explanation. Validation errors name the
offending field and the allowed values. Safe to log; may change over time.
The correlation id for this request. Always matches the `X-Request-Id`
response header. Include it in support requests. You can also send your own
`X-Request-Id` header (8–128 chars of `[A-Za-z0-9._-]`) and the API will echo
it back.
A deep link to the failing code's section of this page.
Optional — present on some `VALIDATION_FAILED` responses with per-field
issues.
The HTTP status carries the status; it is not duplicated in the body. Every response — success or error — also carries `X-Request-Id` and `X-AISearch-Version` headers.
## Error codes
### AUTH_INVALID
`401` — The API key is missing, malformed, revoked, or unknown. On the core data-plane endpoints (`POST /v1/search`, `GET /v1/jobs`, `/v1/usage`, `/v1/artifacts`), a missing `Authorization` header and an invalid key are indistinguishable — both return this. Add `Authorization: Bearer `.
### AUTH_MISSING
`401` — No `Authorization` header was sent. Emitted only by the account-management endpoints; the core data-plane endpoints return `AUTH_INVALID` for a missing key instead.
### SCOPE_FORBIDDEN
`403` — The API key is not scoped for the requested method. The message names the methods the key allows.
### INSUFFICIENT_CREDITS
`402` — Not enough credits for the request (or, on batch, for the whole batch — the reservation is atomic, so nothing is spawned).
### VALIDATION_FAILED
`400` — A request field is invalid. The message names the offending field and the allowed values (e.g. a malformed `regions[i].country`). **Unknown top-level fields are rejected, not ignored** — the API fails loud rather than silently dropping something that looks like a control, and the message names the unexpected field(s). Send only the documented fields (`query`/`prompt`, `surfaces`, `regions`/`region`/`country`, `include`, `webhook`, `idempotencyKey`). This also fires for **unknown `include` keys** — the only accepted keys are `include.markdown` and `include.html`; anything else is rejected here rather than silently dropped.
### UNSUPPORTED_SURFACE
`400` — A value in `surfaces` isn't a supported surface. The message lists every valid surface. See [surfaces](/guides/surfaces).
### UNSUPPORTED_METHOD_FOR_SURFACE
`422` — The surface has no live v1 capture path yet. The message names what IS supported. `GET /v1/surfaces` is the live capability matrix.
### REGION_UNAVAILABLE
`422` — **Reserved; not currently emitted.** The canonical submit path accepts **any** valid ISO-3166 country and validates `country` for **format only** — it never gates a region per surface, so this code does not fire today. A malformed `country` is a `VALIDATION_FAILED` (`400`) instead. The code is held in reserve for a future live per-surface region gate; don't write handling that depends on it firing now.
### TOO_MANY_REGIONS
`422` — More than 10 `regions` in one request. Each region multiplies billed children; split the request instead.
### QUERY_TOO_LONG
`422` — The `query` (or its `prompt` alias) is over **2000 characters**. Shorten the prompt, or split the work across several requests.
### IDEMPOTENCY_CONFLICT
`409` — The same `idempotencyKey` was reused with a different body. Use a new key or resend the original body.
### JOB_NOT_FOUND
`404` — The job id doesn't exist or isn't yours (reads are owner-scoped; another tenant's id is a `404`, never a `403`).
### WATCH_NOT_FOUND
`404` — No watch (or watch run) exists with the given id, or it belongs to another tenant (reads and writes are owner-scoped — another tenant's id is a `404`, never a `403`).
### WATCH_LIMIT_EXCEEDED
`403` — Creating this watch would exceed the plan's active-watch ceiling. The message names the ceiling and the plan. Pause or cancel an existing watch, or upgrade. See [Watches](/guides/watches#plan-limits).
### WATCH_INTERVAL_TOO_SHORT
`422` — The requested `schedule` is below the plan's minimum interval. The message names the floor and the plan. See [Watches](/guides/watches#plan-limits).
### ARTIFACT_NOT_FOUND
`404` — The artifact key doesn't exist or isn't yours.
### NOT_FOUND
`404` — The requested path doesn't exist on this API. Check the URL against the [API reference](/api-reference/search) — every data-plane route lives under `/v1/`.
### METHOD_NOT_ALLOWED
`405` — Wrong HTTP method for the endpoint (e.g. `POST /v1/health`).
### RATE_LIMIT_EXCEEDED
`429` — You've exceeded your request rate. Honor `Retry-After` and back off.
### CONCURRENCY_LIMIT_EXCEEDED
`429` — Too many in-flight sync requests at once. Retry after a short delay, reduce parallelism, or use `?mode=async`.
### QUEUE_CAPACITY_EXCEEDED
`429` — The async job queue is temporarily saturated. Back off with jitter and retry.
### REQUEST_CANCELED
`499` — The request was canceled before completion.
### ACQUISITION_FAILED
`502` — The capture failed upstream after admission.
### EMPTY_ANSWER
`502` — An AI-assistant surface (chatgpt, claude, perplexity, gemini, copilot) reported a rendered answer but produced no extractable text. We refuse to bill an empty completion, so the capture is failed and the credits are refunded. Retry, or submit async and poll the job. (Google Search/News and AI Overview/Mode surfaces are unaffected — an empty result there is genuine absence, returned as `completed` with a `surface_absent` warning.)
### UPSTREAM_BAD_GATEWAY
`502` — An upstream provider returned an unusable response.
### DRIVER_UNAVAILABLE
`503` — The capture lane for the requested surface is unavailable (we fail loud rather than serve a mock as real). Retry with backoff, or submit with `?mode=async` and poll the job.
### SUBMISSION_FAILED
`503` — The request passed validation but couldn't be durably enqueued. No job was created (the credit reservation is released) — retry with backoff.
### WEBHOOK_SECRET_UNAVAILABLE
`503` — Your account's managed webhook signing secret couldn't be resolved right now. Retry with backoff, or send an inline `webhook.secret` on the request instead.
### SURFACE_TIMEOUT
`504` — A sync capture didn't complete in time. Retry, or submit async and poll the job.
### INTERNAL_ERROR
`500` — Unexpected server error. Retry with backoff; report the `request_id` if it persists.
Full reference:
| Code | HTTP | When it happens |
| -------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------- |
| `AUTH_INVALID` | 401 | Key is missing, malformed, revoked, or unknown (core data-plane endpoints return this for a missing key too). |
| `AUTH_MISSING` | 401 | No `Authorization` header (account-management endpoints only). |
| `SCOPE_FORBIDDEN` | 403 | Key not scoped for the requested method. |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits; nothing spawned. |
| `VALIDATION_FAILED` | 400 | A field failed validation; message names field + allowed values. |
| `UNSUPPORTED_SURFACE` | 400 | A `surfaces` value isn't supported; message lists valid surfaces. |
| `UNSUPPORTED_METHOD_FOR_SURFACE` | 422 | The surface has no live v1 capture path yet; message names what IS supported. |
| `REGION_UNAVAILABLE` | 422 | Reserved; not currently emitted (country validation is format-only). |
| `TOO_MANY_REGIONS` | 422 | More than 10 regions in one request. |
| `QUERY_TOO_LONG` | 422 | `query` (or `prompt`) is over 2000 characters. |
| `IDEMPOTENCY_CONFLICT` | 409 | Same `idempotencyKey`, different body. |
| `JOB_NOT_FOUND` | 404 | Unknown (or another tenant's) job id. |
| `WATCH_NOT_FOUND` | 404 | Unknown (or another tenant's) watch or watch-run id. |
| `WATCH_LIMIT_EXCEEDED` | 403 | Creating a watch would exceed the plan's active-watch ceiling. |
| `WATCH_INTERVAL_TOO_SHORT` | 422 | Requested watch schedule is below the plan's minimum interval. |
| `ARTIFACT_NOT_FOUND` | 404 | Unknown (or another tenant's) artifact key. |
| `NOT_FOUND` | 404 | Unknown API path. |
| `METHOD_NOT_ALLOWED` | 405 | Wrong HTTP method. |
| `RATE_LIMIT_EXCEEDED` | 429 | Request rate exceeded. |
| `CONCURRENCY_LIMIT_EXCEEDED` | 429 | Too many concurrent sync requests. |
| `QUEUE_CAPACITY_EXCEEDED` | 429 | Job queue temporarily saturated. |
| `REQUEST_CANCELED` | 499 | Request canceled before completion. |
| `ACQUISITION_FAILED` | 502 | Upstream capture failure. |
| `EMPTY_ANSWER` | 502 | Assistant surface rendered no extractable answer; capture failed and credits refunded. |
| `UPSTREAM_BAD_GATEWAY` | 502 | Upstream provider unusable. |
| `DRIVER_UNAVAILABLE` | 503 | Capture lane unavailable; retry or use `?mode=async`. |
| `SUBMISSION_FAILED` | 503 | Passed validation but couldn't be durably enqueued; retry. |
| `WEBHOOK_SECRET_UNAVAILABLE` | 503 | Managed webhook secret couldn't be resolved; retry or send an inline `webhook.secret`. |
| `SURFACE_TIMEOUT` | 504 | Sync capture didn't finish in time. |
| `INTERNAL_ERROR` | 500 | Unexpected server error. |
**Retryable classes.** The `429` (`RATE_LIMIT_EXCEEDED`,
`CONCURRENCY_LIMIT_EXCEEDED`, `QUEUE_CAPACITY_EXCEEDED`), `502`
(`ACQUISITION_FAILED`, `UPSTREAM_BAD_GATEWAY`), `503` (`DRIVER_UNAVAILABLE`,
`SUBMISSION_FAILED`, `WEBHOOK_SECRET_UNAVAILABLE`), `504` (`SURFACE_TIMEOUT`),
and `500` (`INTERNAL_ERROR`) codes are transient — retry them with exponential
backoff and jitter (honor `Retry-After` when present). Every other code above
is terminal for that request: fix the input, credits, scope, or id and resend.
## Rate limiting & concurrency
All three limit conditions return HTTP `429`. Every `429` carries the headers you need to back off precisely — sourced from the live admission bucket for your key, never fabricated:
How long to wait before retrying. Always honor this value.
Your ceiling for the current window.
Requests left in the current window.
When the window resets and `Remaining` refills.
Successful submits (and 4xx rejections on the submit path) carry the same `X-RateLimit-*` headers, plus `X-Concurrency-Limit` / `X-Concurrency-Running` / `X-Concurrency-Queued`, so you can pace yourself before you hit the wall.
The three flavors call for slightly different responses:
- **`RATE_LIMIT_EXCEEDED`** — you're sending too fast. Wait `Retry-After`, then resume. Watch `X-RateLimit-Remaining` to pace yourself.
- **`CONCURRENCY_LIMIT_EXCEEDED`** — too many sync requests are open at once. Reduce parallelism, or switch to `?mode=async` and let jobs fan out server-side.
- **`QUEUE_CAPACITY_EXCEEDED`** — the queue is briefly full. Back off with jitter and retry; this clears on its own.
### Backing off
Retry `429` and `5xx` responses with exponential backoff, capped, with jitter. When `Retry-After` is present, prefer it over your computed delay.
```bash cURL
# Retry on 429/5xx, honoring Retry-After
url="https://api.aisearchapi.dev/v1/search?mode=async"
for attempt in 1 2 3 4 5; do
resp=$(curl -s -w "\n%{http_code}" -X POST "$url" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-D /tmp/headers.txt \
-d '{"query":"best crm for startups","surfaces":["chatgpt"],"idempotencyKey":"crm-startups-2026-06-30"}')
code=$(printf '%s' "$resp" | tail -n1)
[ "$code" -lt 429 ] && { printf '%s\n' "$resp" | sed '$d'; break; }
retry=$(grep -i '^retry-after:' /tmp/headers.txt | tr -d '\r' | awk '{print $2}')
sleep "${retry:-$((2 ** attempt))}"
done
```
```javascript Node (fetch)
async function withRetry(fn, max = 5) {
for (let attempt = 0; ; attempt++) {
const res = await fn();
if (res.status < 429 || attempt === max) return res;
const header = res.headers.get('retry-after');
const backoff = Math.min(2 ** attempt, 30) + Math.random();
const waitMs = (header ? Number(header) : backoff) * 1000;
await new Promise((r) => setTimeout(r, waitMs));
}
}
const res = await withRetry(() =>
fetch('https://api.aisearchapi.dev/v1/search?mode=async', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt'],
idempotencyKey: 'crm-startups-2026-06-30',
}),
}),
);
```
```python Python (requests)
import os, random, time, requests
def with_retry(request, max_attempts=5):
for attempt in range(max_attempts + 1):
res = request()
if res.status_code < 429 or attempt == max_attempts:
return res
header = res.headers.get("Retry-After")
backoff = min(2 ** attempt, 30) + random.random()
time.sleep(float(header) if header else backoff)
return res
res = with_retry(lambda: requests.post(
"https://api.aisearchapi.dev/v1/search?mode=async",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"idempotencyKey": "crm-startups-2026-06-30",
},
))
```
Always honor `Retry-After`, and set an
[`idempotencyKey`](/api-reference/search) on writes. If a retry lands after
the original request already succeeded, the same key + same body replays the
original job (`202` + `Idempotent-Replayed: true`) instead of starting a new
one — so retries stay safe and never double-charge credits.
A different body under a previously used `idempotencyKey` returns `409
IDEMPOTENCY_CONFLICT`. Generate a fresh key whenever the request payload
changes.
## What isn't an error
A surface returning nothing is not an error. The job still reaches `completed` with `provenance.surfacePresent: false`, an empty `answer`, and a `surface_absent` warning — and an empty capture costs no credits. Handle it as data, not as a failure. See [The Envelope](/guides/output-formats) for the full shape.
Terminal states, polling, and when to stop.
Skip polling entirely for fan-out jobs.
---
# Concurrency & rate limits
Source: https://docs.aisearchapi.dev/guides/concurrency
> How per-key limits work, the 429s they raise, and how to stay within them with backoff and webhooks.
Every API key has three independent limits. Understanding how they interact makes it easy to run at high throughput without tripping errors.
## The three limits
How many API calls you can make per unit of time.
How many synchronous captures can run inflight at once.
How much queued async work your key can hold, waiting to run.
They apply to different parts of the flow:
- **Request rate** gates every submit you send, regardless of mode. Send too fast and calls are rejected before any work starts.
- **Sync concurrency budget** applies to synchronous captures — the single-surface default, [`?mode=sync`](/guides/synchronous), the `Prefer: wait=30` header, and the `POST /v1/search/:surface` alias. Each inflight sync capture consumes one unit of the budget until the [Envelope](/guides/output-formats) returns.
- **Async admission queue** applies to [asynchronous](/guides/asynchronous) jobs — multi-surface submits, submits with a `webhook`, and `?mode=async`. Each submission is admitted into a queue with a fixed depth; children run as capacity frees up.
Async is the throughput path. Because work sits in the admission queue and
drains as capacity opens, you can submit large batches without holding open
connections. Prefer async + [webhooks](/guides/webhooks) for volume.
## Concurrency by plan
{/* Mirrors PLAN_LIMITS in packages/contracts/src/plan-limits.ts — the single
source of truth the API's admission gate reads. If you change the ladder
there, update this table to match. */}
Every key's limits are set by its plan. The sync concurrency budget is the one
that scales by plan — request rate stays flat at 1,000 req/s on every tier.
| Plan | Sync concurrency | Request rate | Async queue |
| ---------- | ---------------- | ------------ | ----------- |
| Free | 1 | 1,000 req/s | 100 |
| Starter | 50 | 1,000 req/s | 1,000 |
| Growth | 75 | 1,000 req/s | 2,000 |
| Scale | 100 | 1,000 req/s | 4,000 |
| Enterprise | 135+ (custom) | 1,000 req/s | 10,000 |
Limits are per API key, and a key's plan is your account's plan — not
per-request. A restricted key with no active subscription is limited at the
Free tier. Upgrading your plan raises the sync concurrency budget and async
queue capacity immediately; the request-rate ceiling doesn't change. See
[pricing](https://aisearchapi.dev/pricing) to compare plans.
## The 429 codes
When you hit a limit, the response is `429` with a machine-readable `code`. Each fires for a different reason.
| Code | Fires when |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `RATE_LIMIT_EXCEEDED` | You exceeded the request rate for your key. Slow the pace of calls. |
| `CONCURRENCY_LIMIT_EXCEEDED` | Too many synchronous captures are inflight at once — the sync concurrency budget is full. |
| `QUEUE_CAPACITY_EXCEEDED` | The async admission queue is full; there is no room to admit more work right now. |
See [Errors](/guides/errors) for the full error shape and other codes.
## Rate-limit headers
All `429` responses carry a `Retry-After` header (seconds to wait) plus the standard rate-limit trio:
Seconds to wait before retrying. Always honor this value.
The request-rate ceiling for your key in the current window.
Requests left in the current window.
When the window resets (epoch seconds).
Successful submits (and 4xx rejections on the submit path) carry the same `X-RateLimit-*` trio, plus concurrency headers so you can see budget pressure before you get a `429`:
Your sync concurrency budget.
Sync captures currently inflight.
Async work currently waiting in the admission queue.
## A live inflight picture
`GET /v1/async/status` returns a real-time snapshot for your key: admission queue depth and capacity, sync concurrency budget in use, and inflight children grouped by region. Poll it when you want to pace submissions against actual headroom rather than reacting to `429`s.
```bash cURL curl https://api.aisearchapi.dev/v1/async/status \ -H
"Authorization: Bearer $AISEARCH_API_KEY" ```
See the [async status reference](/api-reference/async-status) for the full response.
## Recommended backoff strategy
On any `429`, wait at least the number of seconds in `Retry-After` before
retrying. This is the single most important rule — it aligns your retries
with when capacity actually frees up.
If a limit persists, grow the wait between attempts (for example 1s, 2s, 4s,
8s) and add a little randomness so batched clients don't retry in lockstep.
Cap the number of attempts.
Set the `idempotencyKey` body field on submissions. If a retry lands after
the original was already accepted, the same job is returned instead of
creating a duplicate — so retries can't double-charge credits.
Instead of polling `GET /v1/jobs/:id` in a tight loop (which burns your
request rate), submit async with a webhook and let completions come to you.
Reserve polling for occasional reconciliation.
Tight poll loops are the most common cause of self-inflicted
`RATE_LIMIT_EXCEEDED`. If you must poll, space it out and back off — or switch
to [webhooks](/guides/webhooks).
## Node retry-with-backoff
A minimal client that honors `Retry-After`, falls back to exponential backoff, and reuses an `idempotencyKey` so retries are safe:
```javascript retry.js
const API_KEY = process.env.AISEARCH_API_KEY;
async function submitWithRetry(body, { maxAttempts = 5 } = {}) {
// Reuse one key across all retries of this submission.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch("https://api.aisearchapi.dev/v1/search", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, idempotencyKey }),
});
if (res.status !== 429) return res; // success or a non-retryable error
// Honor Retry-After when present; otherwise exponential backoff with jitter.
const retryAfter = Number(res.headers.get("Retry-After"));
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: (2 ** attempt) * 1000 + Math.random() * 250;
await new Promise((r) => setTimeout(r, backoff));
}
throw new Error("Exhausted retries after repeated 429 responses");
}
const res = await submitWithRetry({
query: "best noise-cancelling headphones under $300",
surfaces: ["chatgpt", "perplexity"],
regions: [{ country: "US" }],
});
const job = await res.json();
console.log(job.jobId, job.status);
```
The same pattern works for the [synchronous](/guides/synchronous) endpoints — just retry on `CONCURRENCY_LIMIT_EXCEEDED` the same way you retry on rate limits.
## Related
Live queue depth, budget, and inflight children for your key.
Every error code and response shape, including the 429s.
Get completions pushed to you instead of polling.
The high-throughput submission path.
```
---
# Surfaces
Source: https://docs.aisearchapi.dev/guides/surfaces
> What each consumer AI surface is for, what it costs per capture, and the structured evidence it returns — the exact enum values, and what's live versus phase 2.
A **surface** is a consumer AI search engine. You name one or more surfaces in a search request, and the API captures the answer each one actually shows a user — browser-first, from the live app — then returns it as a canonical [Envelope](/guides/output-formats). This page is the capability and pricing overview: what each surface is for, what it costs, and which [structured-evidence](#structured-evidence-on-every-surface) layers it exposes.
## What each surface is for
Eight surfaces are requestable today. Use the exact `surfaces` enum value in your request body. Every live surface returns [`evidence.sources`](#structured-evidence-on-every-surface); the table also flags where **fan-out queries** are directly observable and where **brand mentions** are available through opt-in [Auto Extract](/guides/auto-extract).
| Surface | `surfaces` value | Best for | Fan-out | Mentions |
| ------------------ | -------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------- |
| ChatGPT | `chatgpt` | The default consumer AI answer — what the largest AI audience sees for your query | Observed — ChatGPT exposes its sub-queries | Opt-in (BETA) |
| Claude | `claude` | Anthropic's assistant — careful, well-structured long-form responses | None | Opt-in (BETA) |
| Perplexity | `perplexity` | The answer engine built on live web search — dense, numbered citations | None | Opt-in (BETA) |
| Copilot | `copilot` | Microsoft's Bing-grounded assistant — the Windows and enterprise audience | None | Opt-in (BETA) |
| Google AI Overview | `google_ai_overview` | The AI summary above Google's results — highest-visibility surface for search share of voice | None | Opt-in (BETA) |
| Google AI Mode | `google_ai_mode` | Google's conversational AI Mode — exploratory, follow-up search | None | Opt-in (BETA) |
| Google Search | `google_search` | The classic results page — organic ranking and featured snippets | n/a | Opt-in (BETA) |
| Google News | `google_news` | Google News results — timeliness and publisher visibility | n/a | Opt-in (BETA) |
```json Request
{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity", "google_ai_overview"]
}
```
**Fan-out** column: _Observed_ means the surface reveals its own search
sub-queries, so `evidence.fanOut.provenance` is `observed` — today only
ChatGPT exposes them; _None_ means the surface doesn't reveal sub-queries, so
own-fleet captures report `provenance: "none"` with an empty `queries` array;
_n/a_ is a results page that _is_ the search. **Mentions**: set the `extract`
request field to run [Auto Extract](/guides/auto-extract) on any live surface.
Without that opt-in, `evidence.mentions` remains `null`. Auto Extract is BETA
and costs 0 additional credits today.
Send any value not in the enum and the request fails with `400
UNSUPPORTED_SURFACE` (the message lists every valid value). The `surfaces`
array must contain at least one value. **`gemini` is a valid enum value but
has no live capture path yet** — submitting it returns `422
UNSUPPORTED_METHOD_FOR_SURFACE` (see [Phase 2](#phase-2--roadmap) below).
## Cost per capture
Each successful capture is billed at that surface's credit rate. Billing is **success-only** — an empty or failed capture (`provenance.surfacePresent: false`) costs nothing. This table is generated from the code source of truth:
Costs are per **child** — a search across N surfaces and M regions is N × M captures, each billed at its surface's rate. See [Credits](/guides/credits) for how debits, headers, and the `credits` object work.
## Structured evidence on every surface
Every surface returns the same structured-extraction layers alongside the answer — at **no extra credit cost** (this is cloro parity, folded into the base capture price):
`evidence.sources[]` — the pages behind the answer, each with a `role`
(`cited`, `retrieved`, or `related`), URL, title, and the `charRanges` it
supports. Present on **every** surface. `answer.blocks[].referenceIds` point
back at these ids.
`evidence.fanOut.queries` — the sub-queries the surface ran to build its
answer, exposed only where the surface itself reveals them (today, ChatGPT).
`provenance` is `observed` there; own-fleet surfaces report `none` with an
empty `queries` array.
`evidence.mentions` — opt-in [Auto Extract](/guides/auto-extract) derived
from the answer text, including position, sentiment, links, and supporting
source ids. Set `extract` to populate it; otherwise it remains `null`. Auto
Extract is BETA and costs 0 additional credits today.
`evidence.shopping` and `evidence.ads` are reserved placeholders and are
currently always `null`. See [The Envelope](/guides/output-formats) for the
full evidence shape.
## Geo targeting at no extra charge
Every live surface supports **country** targeting, and supported surfaces add
**state and city** precision — request a capture as it appears in `California`
or `San Francisco`, not just the US. Geo targeting is included in the surface's
base capture rate: **there is no per-region surcharge**, unlike cloro, which
bills geo targeting as an add-on. See [Regional availability](/guides/regions)
for the `regions` array, the supported shapes, and requested-vs-effective
region reporting.
## Phase 2 — roadmap
These are interface-stubbed or planned, **not requestable today** (submitting them returns `422`; unknown enum values return `400`):
- **Gemini** (`gemini`) — a valid enum value with no v1 capture path yet. Requesting it returns `422 UNSUPPORTED_METHOD_FOR_SURFACE` rather than a stand-in that isn't the real Gemini experience. See [Gemini](/api-reference/surfaces/gemini) for details.
- **New surfaces** — Meta AI, DeepSeek, Amazon Rufus, and Grok. When they land, you request them by adding their value to `surfaces` — no integration changes, no new response format.
## Discover capabilities programmatically
The live capability matrix is queryable — no docs required:
```bash
curl https://api.aisearchapi.dev/v1/surfaces
```
That returns each surface's `id` and whether it's `live` (requestable), plus a `schemaVersion` stamp — it does **not** carry per-capture credit cost. The [cost table](#cost-per-capture) above is the source of truth for pricing.
The canonical shape — job, provenance, answer, and evidence — every surface
returns.
What each surface costs per capture, and how billing is metered.
---
# Regional availability
Source: https://docs.aisearchapi.dev/guides/regions
> Target captures by country, state, city, language, and Google geotarget locality, and understand requested vs. effective regions.
Every capture runs in a specific region. The AI apps we capture from tailor their answers to where the request appears to come from, so a query about "best running shoes" returns a different answer in the US than it does in Germany or Japan. The `regions` array lets you target that geography.
## The regions array
`regions` is an array of region objects — at most **10** per request (more returns `422 TOO_MANY_REGIONS`). Each object describes one place to capture from. When you omit `regions` entirely, each surface runs **one untargeted capture** — its child id carries the region key `GLOBAL`.
```json
"regions": [{ "country": "US" }]
```
ISO-3166 alpha-2 country code (e.g. `US`, `GB`, `DE`, `JP`). This is the only required field in a region object. A bare string like `"US"` (or a flat `country` field) is accepted as sugar for `{ "country": "US" }`.
Optional sub-national code or name (e.g. `NY`, `California`) to narrow
targeting within the country.
Optional city name to narrow targeting further (e.g. `London`, `San
Francisco`). Useful for locale-sensitive queries like local businesses or
regional pricing.
Optional language hint for the capture (e.g. `en`, `de`, `ja`). When omitted,
the surface uses its own default for the country.
Optional **Google Ads geotarget canonical name** (e.g. `"Buffalo,New
York,United States"`) that pins the Google surfaces (`google_search`,
`google_news`, `google_ai_overview`, `google_ai_mode`) to a precise locality
via a `uule` parameter — one of ~100,000 named cities, ZIPs, and boroughs from
[Google's public geotargets
list](https://developers.google.com/google-ads/api/data/geotargets). It layers
**on top of** the residential exit above (`country`/`state`/`city`), giving
you named-locality targeting independent of the exit IP. Max 63 UTF-8 bytes.
Applied only to Google surfaces; ignored by the others. If the name can't be
encoded, no locality is applied and a
[`location_not_applied`](/guides/output-formats#job) warning is returned
rather than a wrong locality. See [Local rank tracking & geo
targeting](/guides/local-rank-tracking) for the full three-layer model.
### Country coverage
The v1 residential exit network has the broadest, best-verified coverage in:
`US`, `GB`, `DE`, `FR`, `CA`, `AU`, `IN`, `JP`, `BR`
`country` accepts **any** valid ISO 3166-1 alpha-2 code — this list is where coverage is strongest, not an allowlist, so a valid country outside it is **not** rejected. The capture still runs; [`provenance.region.effective`](#requested-vs-effective-region) always reports where it actually ran. The only country-shape error is a malformed code (not two letters), which is a `400 VALIDATION_FAILED`. `GET /v1/regions` returns the targeting shape and popular countries programmatically.
## One child per surface × region
The most important rule: **the API creates one child job for every combination of surface and region.** Surfaces and regions multiply.
If you request two surfaces across two regions, you get four children:
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best noise-cancelling headphones",
"surfaces": ["chatgpt", "perplexity"],
"regions": [
{ "country": "US" },
{ "country": "GB", "city": "London", "language": "en" }
]
}'
```
```python Python
import os, requests
resp = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
json={
"query": "best noise-cancelling headphones",
"surfaces": ["chatgpt", "perplexity"],
"regions": [
{"country": "US"},
{"country": "GB", "city": "London", "language": "en"},
],
},
)
print(resp.json())
```
```javascript JavaScript
const resp = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best noise-cancelling headphones',
surfaces: ['chatgpt', 'perplexity'],
regions: [
{ country: 'US' },
{ country: 'GB', city: 'London', language: 'en' },
],
}),
});
console.log(await resp.json());
```
The parent response is a `202` with one child id per pairing:
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.chatgpt.gb:london:en",
"job_8t2q.perplexity.us",
"job_8t2q.perplexity.gb:london:en"
]
}
```
Child ids have three dot-separated segments — `job_..`, where the region key is the lowercased `country[:city][:language]` (or `GLOBAL` for an untargeted capture). The acquisition method is deliberately **not** in the id. Fetch any child with `GET /v1/jobs/:id` to receive its Envelope, or poll the parent for the rollup. See [Asynchronous jobs](/guides/asynchronous) for the full polling flow.
Each capture is billed separately. `surfaces × regions` is exactly how many
captures you pay for. Two surfaces across two regions is **four** captures,
not two. See [Credits & billing](/guides/credits) before fanning out
across many regions.
## Requested vs. effective region
We always try to honor the exact region you asked for. When a surface can't be captured from precisely that location, we capture from the closest workable location instead and tell you — rather than silently returning something from the wrong place.
Every Envelope reports both values under `provenance.region`:
The region you asked for. `null` for an untargeted capture.
The region the capture actually ran from. When it matches `requested`, the
region was honored exactly. When it differs, the surface answered from
`effective` instead.
For a capture that was honored exactly:
```json
{
"provenance": {
"region": {
"requested": "gb:london:en",
"effective": "gb:london:en"
}
}
}
```
When only the city could not be honored, `effective` falls back to the broader region:
```json
{
"provenance": {
"region": {
"requested": "gb:inverness:en",
"effective": "gb:en"
}
}
}
```
Always read `provenance.region.effective` — not your original request — when
you attribute a result to a geography. Comparing the two fields tells you
whether a locale-sensitive answer can be trusted at city precision.
## Choosing regions
List one region object per market you care about. Because every surface runs in every region, you get a clean matrix — the same parser handles `job_.chatgpt.us` and `job_.chatgpt.de` identically.
Add the `language` field to capture the same country in different languages (e.g. `{ "country": "DE", "language": "de" }` and `{ "country": "DE", "language": "en" }`). Each is a separate capture and a separate child.
For queries with strong local intent — nearby businesses, regional availability, local pricing — set `city` (and optionally `state`). If the city can't be honored, `effective` shows you the fallback so you know the precision you actually got.
## Next steps
See how regions multiply captures and what each surface costs.
Full request and response schema for POST /v1/search.
---
# Local rank tracking & geo targeting
Source: https://docs.aisearchapi.dev/guides/local-rank-tracking
> Three independent layers of geo targeting — the country exit, the state/city residential exit, and Google geotarget localities via uule — and how to combine them for local rank tracking.
AI answers are local. "best dentist near me", "coffee shop open now", or "plumber
prices" return a different answer in Buffalo than in Brooklyn — because the
surface localizes to where the request appears to come from. This guide covers
the three **independent** layers you can stack to control that geography, and how
to use them for local rank tracking.
## Three layers, from broad to precise
Each layer targets a different mechanism, and they compose. You can use one, two,
or all three on the same region object.
`country` places the capture behind a residential exit IP in that country.
This is the only required field in a region object and the coarsest layer.
```json
{ "country": "US" }
```
`state` and `city` narrow the residential exit to a sub-national region and a
city, so the request egresses from an IP that actually resolves to that
locality. This applies to every **browser-captured** surface.
```json
{ "country": "US", "state": "New York", "city": "Buffalo" }
```
`location` is a **Google Ads geotarget canonical name** applied as a `uule`
parameter on the four Google surfaces (`google_search`, `google_news`,
`google_ai_overview`, `google_ai_mode`). It pins the answer to one of
~100,000 named localities — cities, ZIPs, boroughs — **independent of the
exit IP**. Names come from [Google's public geotargets
list](https://developers.google.com/google-ads/api/data/geotargets).
```json
{ "country": "US", "location": "Buffalo,New York,United States" }
```
These layers are independent. The residential exit (`country`/`state`/`city`)
works on every browser-captured surface; the `location` geotarget only affects
the Google surfaces, where it stacks **on top of** the exit IP. On a
non-Google surface, `location` is simply ignored.
## A local rank-tracking request
Combine all three to capture what a searcher in a specific locality sees across
the Google surfaces. Because every surface × region pair is a separate child, one
request captures the same query across as many localities as you list (up to 10
regions per request).
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best coffee shop near me",
"surfaces": ["google_ai_overview", "google_search"],
"regions": [
{
"country": "US",
"state": "New York",
"city": "Buffalo",
"location": "Buffalo,New York,United States"
},
{
"country": "US",
"state": "New York",
"city": "New York",
"location": "Brooklyn,New York,United States"
}
]
}'
```
```python Python
import os, requests
resp = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
json={
"query": "best coffee shop near me",
"surfaces": ["google_ai_overview", "google_search"],
"regions": [
{
"country": "US",
"state": "New York",
"city": "Buffalo",
"location": "Buffalo,New York,United States",
},
{
"country": "US",
"state": "New York",
"city": "New York",
"location": "Brooklyn,New York,United States",
},
],
},
)
print(resp.json())
```
```javascript JavaScript
const resp = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best coffee shop near me',
surfaces: ['google_ai_overview', 'google_search'],
regions: [
{
country: 'US',
state: 'New York',
city: 'Buffalo',
location: 'Buffalo,New York,United States',
},
{
country: 'US',
state: 'New York',
city: 'New York',
location: 'Brooklyn,New York,United States',
},
],
}),
});
console.log(await resp.json());
```
Each `surface × region` pair is billed separately. Two surfaces across two
localities is **four** captures. See [Credits & billing](/guides/credits)
before fanning out across many localities.
## Verifying what was localized
Every Envelope reports what geo was actually applied under `provenance.region`,
so you can trust the result rather than assume it. Alongside the residential
`requested`/`effective` region keys, two fields cover the `location` geotarget:
The Google geotarget canonical name you asked for (or `null` when you sent no
`location`).
The name a `uule` was **actually** applied for. It is `null` — paired with a
[`location_not_applied`](/guides/output-formats#job) warning — whenever the
name could not be encoded, so a wrong locality is never claimed.
```json
{
"provenance": {
"region": {
"requested": "us:buffalo",
"effective": "us:buffalo",
"requestedLocation": "Buffalo,New York,United States",
"effectiveLocation": "Buffalo,New York,United States"
}
}
}
```
Always read `effectiveLocation`, not your request, when you attribute a result
to a locality. If it differs from `requestedLocation` (or is `null`), the
precise locality was not applied — check for a `location_not_applied` warning.
## Choosing the right layer
Use `country` (optionally `state`/`city`) alone. The residential exit is
enough when you care about country- or region-level differences, and it
works across every browser-captured surface, not just Google.
Add `location` with the Google geotarget canonical name for the exact
locality. This is the layer that gives you ZIP- and borough-level precision
on the Google surfaces, independent of which residential IP the capture
egressed from.
Set the residential `state`/`city` **and** the `location` geotarget
together. The IP resolves to the city while the `uule` pins the Google
answer to the named locality — the closest match to what a local searcher
actually sees.
## Next steps
The full `regions` array reference — every field, country coverage, and
requested-vs-effective semantics.
Where `provenance.region` and the `location_not_applied` warning live in the
Envelope.
---
# Credits & billing
Source: https://docs.aisearchapi.dev/guides/credits
> How credits work in the AI Search API: per-surface cost, when you're charged, free starting balance, and where to read your usage ledger.
A **credit** is the billing unit for captures: a single surface, in a single region, with every field in the [Envelope](/guides/output-formats) included. There are no add-ons and no per-field charges. The answer (`text`, `markdown`, `blocks`), the lane-free `provenance`, and the full structured `evidence` — cited and retrieved sources, the query fan-out, and brand mentions — all come with the capture at no per-field cost.
## What a capture costs
Each surface has a flat credit cost per successful capture:
Every field is included in the price. A capture that returns a long, richly
formatted answer with deep structured evidence costs exactly the same as one
that returns a short answer. Google Search (`google_search`) and Google News
(`google_news`) are both 3 credits per capture. The table above is generated
from the code source of truth — see
[aisearchapi.dev/pricing](https://aisearchapi.dev/pricing) for plans and
per-credit rates.
## You only pay for successful captures
Credits are charged on a **successful capture** only.
- An **empty** capture is free. When a surface returns nothing, the child job still completes with `provenance.surfacePresent: false` and an empty `answer` — and it costs nothing.
- A **failed** capture is free. If a child ends in `failed` (for example, a `SURFACE_TIMEOUT`), you are not charged for it.
Submissions are gated by an **atomic credit reservation** over the whole
planned fan-out. If your balance can't cover the request (or, on
[batch](/api-reference/batch), the whole batch), it's rejected with `402
INSUFFICIENT_CREDITS` and **nothing is spawned** — the reservation is
all-or-nothing, so a request can never drive your balance negative.
## Fan-out math
One search fans out to **one child per surface × region**. The total spend for a search is:
```
credits = sum over surfaces of (surface cost) × (number of regions)
```
For a request across N surfaces and M regions, you spend up to **N × M captures** — minus any that come back empty or failed, which cost nothing.
A search for `["chatgpt"]` in `[{ "country": "US" }]` is a single capture — **5 credits** on success.
A search for `["chatgpt", "claude", "perplexity"]` in one region is three captures: 5 + 3 + 3 = **11 credits** on success.
A search for `["chatgpt", "perplexity"]` across `[{ "country": "US" }, { "country": "GB" }]` fans out to four children: (5 + 3) × 2 = **16 credits** if all four succeed. Any child that returns empty or fails is not charged.
## Auto Extract and Watches
[Auto Extract](/guides/auto-extract) is free during BETA. Its credit cost is
`0`, so requesting `extract` never changes `creditsToCharge` or
`creditsCharged`. Pricing will change at GA.
Creating a [Watch](/guides/watches) is also free. Each **dispatched run** bills
like a normal async search: Σ(surface cost) × regions, with the same
success-only settlement. Empty or failed captures are refunded, and skipped
runs — including runs skipped for insufficient credits — are never billed.
## Free credits to start
New accounts start with **500 free credits** — no card required. That's enough to try every live surface across several regions before you pick a plan. See [aisearchapi.dev/pricing](https://aisearchapi.dev/pricing) for plans and rates (Starter, Growth, Scale, and custom Enterprise volume).
## Cost visibility
Every submit tells you what it cost, both in response headers and in a `credits` object on the payload — so you can reconcile spend without a second round-trip to the usage ledger.
**Response headers.** Metered (restricted-key) submits return two headers on both the sync `200` and the async `202`:
- `X-Credits-Charged` — credits this submit debited.
- `X-Credits-Remaining` — your balance after the debit.
**The `credits` object.** `{ creditsToCharge, creditsCharged }` accompanies billing throughout a request's lifecycle:
| Where | `creditsToCharge` | `creditsCharged` |
| ------------------------------------------------ | ----------------- | ----------------------------- |
| Sync result | Planned spend | Actual spend (success-only) |
| Async `202` accept | Planned spend | `0` until settlement |
| Each accepted [batch](/api-reference/batch) item | Planned spend | Settles per item |
| Parent job rollup | Planned spend | — (rollup shows planned only) |
| Each child job `GET` | Planned spend | Actual spend on that child |
| [Webhook](/guides/webhooks) payload | Planned spend | Actual spend at settlement |
`creditsCharged` reflects **success-only billing** — a child that comes back
empty or `failed` settles at `0`, even though it appeared in the planned
`creditsToCharge`. Reconcile against `creditsCharged` (or the
`X-Credits-Charged` header) for what you actually paid.
## Read your ledger
`GET /v1/usage` returns your plan, a usage rollup grouped by `(surface, region)`, and your current balance.
```bash cURL
curl https://api.aisearchapi.dev/v1/usage \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node (fetch)
const res = await fetch('https://api.aisearchapi.dev/v1/usage', {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const usage = await res.json();
```
```python Python (requests)
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/usage",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
usage = res.json()
```
```json
{
"plan": "free",
"usage": {
"byGroup": [
{
"surface": "chatgpt",
"region": "us",
"callCount": 42,
"creditsCharged": 210
},
{
"surface": "perplexity",
"region": "us",
"callCount": 18,
"creditsCharged": 54
}
],
"totals": { "callCount": 60, "creditsCharged": 264 }
},
"balance": { "plan": "free", "credits": 236, "unlimited": false }
}
```
Accounting is per child job. One search across N surfaces × M regions counts
as N × M entries in the ledger, each attributed to its exact `(surface,
region)` group. See the [usage reference](/api-reference/usage) for every
field.
Compare plans and per-credit rates.
Everything a capture returns — all included in the credit cost.
---
# Output formats
Source: https://docs.aisearchapi.dev/guides/output-formats
> The single canonical response shape returned for every surface: job, provenance, answer, and evidence — plus the optional html and credits — as JSON.
The **Envelope** is the canonical result for one surface × region. Every surface returns the exact same shape, so you write your parsing logic once and it works for ChatGPT, Claude, Perplexity, Copilot, Google AI Overview, Google AI Mode, Google Search, and Google News alike.
You read an Envelope by fetching a **child** job — a dotted, three-segment id like `job_8t2q.chatgpt.us`:
```bash cURL
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const envelope = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
envelope = res.json()
```
A **parent** id has no dots (`job_8t2q`) and returns the list of children. A **child** id is dotted (`job_..`) and returns the Envelope shown here. See [Jobs & polling](/api-reference/get-job) for how the two relate. Sync responses carry the same Envelope per child inline; `?view=flat` projects each one down to `{ surface, status, text, markdown, sources }` — see [The flat projection](#the-flat-projection).
## The four sections
An Envelope always has these four top-level sections, plus two optional add-ons:
| Section | Always present | What it holds |
| ------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `job` | Yes | Identity and status of this capture: the query, surface, region, timestamps, and warnings. |
| `provenance` | Yes | The lane-free record of how the answer was produced: which model, whether web search ran, trigger state, persona, and effective region. |
| `answer` | Yes | The answer the user saw, as plain text, as markdown, and as structured blocks. |
| `evidence` | Yes | Structured extraction: the cited/retrieved sources, the query fan-out, and derived brand mentions. |
| `html` | Opt-in | A proof-of-page HTML URL — present only when you request `include.html: true` on a consumer-UI (scrape) surface. |
| `credits` | Metered | Per-capture credit accounting (`creditsToCharge`, `creditsCharged`) under success-only billing. |
The full structured `evidence` — cited sources, the query fan-out, and brand mentions — is included at **no extra credit cost**. There is no per-field charge; a capture bills the flat per-surface price whether it returns a one-line answer or deep evidence.
## Full example
```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": "c1a7e4b5-d48f-1a3c-6e8d-0b2a4f6c8e12",
"schemaVersion": "2026-06-01"
},
"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": "For startups, the top CRMs are...",
"referenceIds": [1, 2]
}
]
},
"evidence": {
"sources": [
{
"id": 1,
"url": "https://hubspot.com/startups",
"title": "HubSpot for Startups",
"role": "cited",
"cited": true,
"charRanges": [[34, 41]],
"quote": "HubSpot offers a free CRM tier that scales with your team."
},
{
"id": 2,
"url": "https://attio.com",
"title": "Attio — CRM for the modern team",
"role": "cited",
"cited": true,
"charRanges": [[43, 48]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best crm for startups", "hubspot vs attio vs pipedrive"]
},
"mentions": {
"confidence": 0.82,
"items": [
{
"brand": "HubSpot",
"domain": "hubspot.com",
"mentioned": true,
"position": 1,
"sentiment": "positive",
"linked": true,
"target": false,
"sourceIds": [1],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
},
{
"brand": "Attio",
"domain": "attio.com",
"mentioned": true,
"position": 2,
"sentiment": "neutral",
"linked": true,
"target": true,
"sourceIds": [2],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
}
]
},
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
```
## job
The child id for this capture — `job_..`.
The prompt that was submitted.
The surface this Envelope came from (`chatgpt`, `claude`, `perplexity`,
`gemini`, `copilot`, `google_ai_overview`, `google_ai_mode`, `google_search`,
`google_news`).
The canonical region key this capture ran in — lowercased
`country[:city][:language]`, or `GLOBAL` when untargeted.
Lifecycle state — one of `queued`, `running`, `completed`, `partial`,
`failed`. Active: `queued`, `running`. Terminal: `completed`, `partial`,
`failed`. A child Envelope is only meaningful once `status` is terminal — a
poll loop must stop on **any** terminal state, and also stop if a job is
reported `canceled` or `expired`. See [Job lifecycle](/guides/asynchronous).
Non-fatal notes about the CAPTURE (a property of the answer you received) — the
answer is still valid. This is a closed allowlist; the API never exposes
internal acquisition-lane or routing detail here. Known values:
- `surface_absent` — the surface rendered no answer; accompanies
`provenance.surfacePresent: false` (absence is data, the job still completes).
- `partial_capture` — the page was captured but a region or section was cut
off; the returned answer may be incomplete.
- `geo_not_applied` — the requested region could not be applied to this
capture; compare against `provenance.region` (requested vs effective).
- `location_not_applied` — a requested `region.location` (a Google geotarget
canonical name) could not be uule-encoded, so no locality was applied and
`provenance.region.effectiveLocation` was left `null`. We never apply a wrong
uule (it would silently mislocalize) — honest absence instead.
- `extraction_failed` — Auto Extract was requested, but the extraction lane
failed, was unavailable, or exhausted its time budget; `evidence.mentions` is
`null`. The capture itself still completes normally.
- `extraction_skipped` — Auto Extract was requested, but an empty answer or
`surface_absent` left nothing to extract; `evidence.mentions` is `null`. The
capture itself still completes normally.
ISO-8601 timestamp when the capture was requested.
ISO-8601 timestamp when the capture reached a terminal state.
## provenance
`provenance` is the **lane-free** record of how the answer was produced. It tells you which model spoke, whether the surface searched the web, and the exact region and login context the capture ran under — everything you need to judge a result, and nothing about our internal plumbing.
Which model produced the answer.
The verified official model id, when known (e.g. `gpt-4o-2024-08-06`). `null` when only a UI label was observed.
The model label observed on the surface (e.g. `GPT-5`).
`false` when the label was read directly; `true` when it was inferred.
Confidence in the model identification, `0`–`1`.
Whether the surface ran a web search for this answer. `enabled` is the
observed value (`null` when unknown); `known` is `true` when that value is
certain.
How the answer was produced: `search` when the surface consulted the web, or
`parametric` when it answered from the model's own weights without searching.
The account context the capture ran under — `anonymous_default_model`,
`anonymous_authenticated`, or `consented_personalized`.
Whether the capture was signed in on the surface: `logged_in`, `logged_out`,
or `unknown`.
`true` when the surface returned an answer. `false` means the surface returned
nothing — see [When a surface returns
nothing](#when-a-surface-returns-nothing).
`requested` is the region key you asked for; `effective` is the region the
capture actually ran from. Both are strings (or `null` for an untargeted
capture).
For Google-surface locality (`region.location`), two more fields let you verify
what was localized: `requestedLocation` is the geotarget canonical name you
asked for (or `null`), and `effectiveLocation` is the name a `uule` was actually
applied for (or `null`). `effectiveLocation` is `null` — paired with a
`location_not_applied` warning — whenever the name could not be encoded, so a
wrong locality is never claimed.
ISO-8601 timestamp of the capture itself.
A stable id for this individual capture run — useful for correlating a result
with your own logs.
The Envelope schema version this result was produced under. Treat unknown
provenance fields as additive.
`provenance` is deliberately **lane-free**. It does **not** expose how we
acquired the capture — no `method`, `acquisition`, `fidelity`, `engine`,
`routing`, or driver-version fields. The acquisition lane is internal and
never surfaced; what you get is a clean, portable description of the result
itself.
## answer
The answer as plain text — the words the user saw, stripped of formatting.
**Always present.**
The same answer as markdown, preserving bold, lists, and other formatting.
Present for every answer **unless** you set [`include.markdown:
false`](#the-include-request-field) on the request to trim it. When present
you can render it directly.
The answer broken into structured blocks.
The block kind: `paragraph`, `heading`, `list`, `code`, or `quote`.
The block's text content.
The citation reference numbers this block cites, in the order the surface
presented them. Each value points at an
[`evidence.sources[].id`](#evidence) — join on it to resolve a block's
citations to their source URLs.
## evidence
`evidence` is the **structured extraction** for the capture — cloro parity, included at no extra credit cost. It carries the sources behind the answer, the internal search queries the surface fanned out, and a derived brand-visibility layer. Every field is always present; `mentions` may be `null`, and `shopping` / `ads` are reserved placeholders that are currently always `null`.
The cited and retrieved sources for the answer. `answer.blocks[].referenceIds`
point at these by `id`.
Stable citation id for this source within the capture — the value a
block's `referenceIds` and a mention's `sourceIds` refer to.
The source URL.
The source's title.
How the source relates to the answer: `cited` (referenced in the answer),
`retrieved` (fetched during the search but not cited), or `related`.
`true` when the answer actually cited this source.
The character spans in `answer.text` that this source supports — an array
of `[start, end]` pairs — so you can highlight exactly which words a
citation backs.
The verbatim snippet the surface quoted from this source, when it exposed
one. Optional.
The internal search queries the surface used to answer, when observable.
How the queries were determined: `observed` (read directly off the
surface), `inferred`, or `none` (no fan-out visible).
The search queries the surface ran, in order.
The derived brand-visibility layer. It is `null` unless the request set
[`extract`](/guides/auto-extract), or when extraction failed or was skipped.
Confidence in the mention extraction, `0`–`1`.
One entry per extracted brand, including pinned targets that were absent.
Canonical brand or product name as the surface displayed it.
Primary domain when inferable; otherwise `null`.
`false` only for a pinned target absent from the answer.
One-based order of first mention; `null` when `mentioned` is `false`.
`positive`, `neutral`, or `negative`, derived from answer context;
`null` when `mentioned` is `false`.
`true` when a cited source URL's registrable domain matches the
brand's domain.
`true` when the row exists because the brand or domain was pinned in
`extract.targets`.
The `evidence.sources[].id` values backing this mention.
Verbatim first-mention context, at most 240 characters; `null` when
`mentioned` is `false`.
Reserved for structured shopping cards. **Currently always `null`** — it
exists today so adding it later is not a breaking change.
Reserved for structured ads. **Currently always `null`**, for the same reason.
## html
The optional `html` field is a **proof-of-page** URL — a rendered snapshot of the exact page the answer was captured from, so you can show a receipt of what a real user saw.
A URL you resolve with `GET /v1/artifacts/{key}`, authenticated with your API
key. Present **only** when both are true:
- You opt in — set `include.html: true` on the submit body, or add
`?include=html` when you poll.
- The capture is a **consumer-UI (scrape) surface** — the ones where there is
a real page to snapshot. Model-API-style captures have no page and omit
`html` even when requested.
When those conditions aren't met, the field is simply absent.
`html` is the only capture snapshot published to customers, and it's strictly
opt-in. Fetch it the same way you fetch any artifact — see [Fetch an
artifact](/api-reference/artifacts).
## credits
On metered (restricted-key) requests each capture reports its cost inline under success-only billing.
The reserved/estimated cost — the flat per-surface price for this capture.
The finalized cost after settlement: the full price on a successful capture,
and `0` for an absent or failed capture (which is never charged). It reads `0`
while a job is still processing.
A `credits` object appears on the sync result, on each accepted batch item, on
each child-job GET, and in the webhook payload; the parent-job rollup carries
`creditsToCharge` only. Metered submits also return `X-Credits-Charged` and
`X-Credits-Remaining` headers on the sync `200` and the async `202`. See
[Credits & billing](/guides/credits) for how costs add up across a fan-out.
## The `include` request field
The submit body accepts an optional `include` object that shapes the **response only** — it never changes how a capture is acquired.
```json
{
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"include": { "markdown": true, "html": false }
}
```
Keep `answer.markdown` in the response. Set `false` to trim it and slim the
payload — `answer.text` is always present regardless.
Add the [proof-of-page `html`](#html) URL. Honored on consumer-UI (scrape)
surfaces only.
Unknown `include` keys are rejected with `400 VALIDATION_FAILED`. Send only
`markdown` and `html`.
## The flat projection
Add `?view=flat` to a sync request (or a poll) and each Envelope is projected down to the compact fields a simple consumer needs:
```json ?view=flat (one child)
{
"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**...",
"sources": [
{
"id": 1,
"url": "https://hubspot.com/startups",
"title": "HubSpot for Startups",
"role": "cited",
"cited": true,
"charRanges": [[34, 41]]
}
]
}
```
The flat shape is `{ surface, status, text, markdown, sources }` — the answer's cited/retrieved sources ride along even here. A single child returns the flat object directly; several children return `{ "results": [...] }`. The flat view **always** returns a `markdown` field, falling back to `text` when no distinct markdown exists — `include.markdown: false` only trims `answer.markdown` in the rich Envelope, never the flat projection.
## When a surface returns nothing
Absence is data. Sometimes a surface produces no answer for a query — that is a real, useful signal, and the capture reports it plainly instead of failing:
- The child job still reaches `completed`; it does **not** fail.
- `provenance.surfacePresent` is `false`.
- `job.warnings` includes a `surface_absent` warning.
- `answer` is empty.
An empty capture like this is **free** — `credits.creditsCharged` is `0`, because you are only charged on a successful capture. See [Credits & billing](/guides/credits).
How parent and child jobs relate, and how to read results.
Every state a job can be in, and when to stop polling.
Resolve the opt-in proof-of-page `html` URL.
What a capture costs — evidence and all — and when you're charged.
---
# Auto Extract
Source: https://docs.aisearchapi.dev/guides/auto-extract
> Extract brand mentions from captured answer text and pin targets so presence and absence are both reported explicitly.
Auto Extract derives brand mentions from the **answer text only**. It can report
brands the answer happens to mention and pin specific brands or domains so an
absent target is returned explicitly rather than omitted. It never fabricates
an extraction.
Auto Extract is **BETA** and costs `0` credits today
(`EXTRACTION_CREDIT_COST = 0`). Requesting it does not change
`creditsToCharge` or `creditsCharged`. Pricing will change at GA.
## Request extraction
Add `extract` to `POST /v1/search`, `POST /v1/search/:surface`, or any item in
`POST /v1/search/batch`.
`true` is identical to `{}`: extract brands the answer mentions, with no
pinned targets. `false`, or omitting the field, disables extraction and leaves
`evidence.mentions` as `null`.
Brand names or bare domains to report whether present or absent. At most
**10** entries; each must be non-empty and at most **100 characters**.
Passing a string, number, array, empty target, target longer than 100
characters, or more than 10 targets returns `400 VALIDATION_FAILED`. Unknown
top-level fields are also rejected; send only documented fields.
## Mention fields
When extraction succeeds, `evidence.mentions` contains `confidence` (`0`–`1`)
and an `items` array.
Canonical brand or product name as the surface displayed it.
Primary domain when inferable; otherwise `null`.
`false` only for a requested target that is absent from the answer.
One-based order of first mention; `null` when `mentioned` is `false`.
`positive`, `neutral`, or `negative`, derived from answer context only; `null`
when `mentioned` is `false`.
`true` when a cited source URL's registrable domain matches the brand's
domain.
`true` when the row exists because the brand or domain was pinned through
`extract.targets`.
Real `evidence.sources[].id` values backing the mention.
Verbatim first-mention context, at most 240 characters; `null` when
`mentioned` is `false`.
## Honesty behavior
Pinned-target absence is data. A target that never appears still gets a row:
```json
{
"brand": "Salesforce",
"domain": null,
"mentioned": false,
"position": null,
"sentiment": null,
"linked": false,
"target": true,
"sourceIds": [],
"snippet": null
}
```
- Brands are deduplicated case-insensitively.
- `linked` uses registrable-domain matching, not substring matching.
`hubspot.com` matches `www.hubspot.com` and its subdomains, but not
`nothubspot.com`.
- `sourceIds` contains only ids that exist in `evidence.sources[]`. A
hallucinated citation id is dropped, never invented.
## Warnings
Extraction can degrade without failing the capture. In both cases below, the
job completes normally and `evidence.mentions` is `null`:
Extraction was requested, but its lane failed, was unavailable, or exhausted
its time budget. No extraction is fabricated.
Extraction was requested, but an empty answer or `surface_absent` left nothing
to extract.
## Example
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"extract": { "targets": ["HubSpot", "attio.com"] }
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt'],
extract: { targets: ['HubSpot', 'attio.com'] },
}),
});
const result = await res.json();
console.log(result.children[0].evidence.mentions);
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"extract": {"targets": ["HubSpot", "attio.com"]},
},
)
mentions = res.json()["children"][0]["evidence"]["mentions"]
print(mentions)
```
```json Response excerpt
{
"evidence": {
"sources": [
{
"id": 1,
"url": "https://hubspot.com/startups",
"title": "HubSpot for Startups",
"role": "cited",
"cited": true,
"charRanges": [[34, 41]]
},
{
"id": 2,
"url": "https://attio.com",
"title": "Attio — CRM for the modern team",
"role": "cited",
"cited": true,
"charRanges": [[43, 48]]
}
],
"mentions": {
"confidence": 0.82,
"items": [
{
"brand": "HubSpot",
"domain": "hubspot.com",
"mentioned": true,
"position": 1,
"sentiment": "positive",
"linked": true,
"target": true,
"sourceIds": [1],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
},
{
"brand": "Attio",
"domain": "attio.com",
"mentioned": true,
"position": 2,
"sentiment": "neutral",
"linked": true,
"target": true,
"sourceIds": [2],
"snippet": "Attio is a newer entrant with a flexible, Notion-like data model."
},
{
"brand": "Pipedrive",
"domain": "pipedrive.com",
"mentioned": true,
"position": 3,
"sentiment": "neutral",
"linked": false,
"target": false,
"sourceIds": [],
"snippet": "For startups, the top CRMs are HubSpot, Attio and Pipedrive..."
}
]
}
}
}
```
If a pinned target is absent, its row uses `mentioned: false`, `position: null`,
`sentiment: null`, `linked: false`, `sourceIds: []`, and `snippet: null`.
[Watches](/guides/watches) carry the same `extract` configuration and reapply
it on every scheduled run.
## Related
Request Auto Extract on a search.
Read the full `evidence.mentions` response shape.
Reapply extraction on every scheduled run.
Handle validation errors and warning degradation.
---
# Watches
Source: https://docs.aisearchapi.dev/guides/watches
> Run saved AI-search queries on a schedule for continuous visibility monitoring without maintaining your own cron job.
A **Watch** re-runs a saved `(query, surfaces, regions, extract)` on a cadence. Add a `webhookUrl` to receive each dispatched run exactly like any other async search.
Use watches for continuous GEO and rank monitoring without operating a scheduler on your side.
Create watches three equivalent ways — same validation, plan limits, and billing on every path:
- **REST** — [`POST /v1/watches`](/api-reference/watches/create) and the rest of the [watch endpoints](/api-reference/watches/list).
- **MCP** — the `create_watch` / `list_watches` / `update_watch` / `delete_watch` tools on the [hosted MCP server](/mcp), built for agents.
- **Dashboard** — the Watches page in your [workspace](https://aisearchapi.dev/home).
Creating a watch is free. Each **dispatched run** bills per surface × region
exactly like a normal async search, with the same success-only refunds for
empty or failed captures. A skipped run costs nothing. See
[Credits](/guides/credits).
## Schedules
Use a named token for common cadences:
```json
{ "schedule": { "every": "1d" } }
```
Allowed tokens are `15m`, `30m`, `1h`, `6h`, `12h`, `1d`, and `7d`. For an off-grid cadence, send a positive integer:
```json
{ "schedule": { "intervalMinutes": 90 } }
```
The public Watch always echoes both fields:
```json
{ "schedule": { "every": "1h", "intervalMinutes": 90 } }
```
For an off-grid interval, `every` is a display convenience using the nearest token. `intervalMinutes` is the source of truth.
## Plan limits
Each plan sets an active-watch ceiling and a minimum interval.
| Plan | Max active watches | Minimum interval |
| ------------ | ------------------ | ---------------- |
| `free` | 2 | 1440 min (daily) |
| `starter` | 10 | 60 min (hourly) |
| `growth` | 25 | 30 min |
| `scale` | 100 | 15 min |
| `enterprise` | 500 | 15 min |
An interval below the plan floor returns `422 WATCH_INTERVAL_TOO_SHORT`; the message names the floor and plan. Creating past the active-watch ceiling returns `403 WATCH_LIMIT_EXCEEDED`; the message names the ceiling and plan.
## Lifecycle
A watch is always in one of three states:
| Status | Meaning |
| ---------- | ------------------------------------------------------ |
| `active` | Scheduled ticks can dispatch new search jobs. |
| `paused` | Scheduling is suspended until the watch is resumed. |
| `canceled` | Terminal soft-delete state; no new runs are scheduled. |
Pause or resume with [`PATCH /v1/watches/:id`](/api-reference/watches/update). A manual pause sets `pausedReason: "user"`. Resuming a paused watch clears `pausedReason` and sets `nextRunAt` to `now + intervalMinutes`.
Use [`DELETE /v1/watches/:id`](/api-reference/watches/delete) to cancel. Cancellation preserves the watch row and run history.
### Automatic pause
If three scheduled runs in a row are skipped for insufficient credits, the watch becomes `paused` with `pausedReason: "insufficient_credits"`. A successful dispatch resets the consecutive-failure counter to zero.
Auto-pause prevents an underfunded watch from producing an unbounded stream of
skipped ticks. After restoring credits, resume it explicitly; the next run is
scheduled from the resume time.
## Run history
Every scheduled tick that reaches a watch creates one run-history row. Read the ten newest runs with [`GET /v1/watches/:id`](/api-reference/watches/get), or page through the full history with [`GET /v1/watches/:id/runs`](/api-reference/watches/runs).
| Status | Meaning |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `dispatched` | A real search job was created and `jobId` is set. Poll it or consume its webhook delivery. |
| `skipped_credits` | Balance was insufficient at dispatch time; nothing was billed. Three consecutive occurrences auto-pause the watch. |
| `skipped_limit` | An account or plan ceiling prevented dispatch; nothing was billed. |
| `failed` | The scheduled attempt itself failed to submit. A normal captured-but-empty or failed job remains `dispatched` with a `jobId`. |
For `dispatched`, read `jobId` with [`GET /v1/jobs/:id`](/api-reference/get-job) exactly like any other async parent job. If the watch has a `webhookUrl`, each child delivery uses the same signature, retry, and dedupe semantics described in [Webhooks](/guides/webhooks).
## Create and inspect a watch
Create a daily watch, then read it to see `lastRunAt`, `lastJobId`, and the newest run records as ticks dispatch.
```bash cURL
WATCH_ID=$(curl -s -X POST https://api.aisearchapi.dev/v1/watches \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"extract": { "targets": ["HubSpot", "attio.com"] },
"schedule": { "every": "1d" }
}' | jq -r '.watch.id')
curl "https://api.aisearchapi.dev/v1/watches/$WATCH_ID" \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const created = await fetch('https://api.aisearchapi.dev/v1/watches', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }],
extract: { targets: ['HubSpot', 'attio.com'] },
schedule: { every: '1d' },
}),
});
const { watch } = await created.json();
const inspected = await fetch(
`https://api.aisearchapi.dev/v1/watches/${watch.id}`,
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
console.log(await inspected.json());
```
```python Python
import os, requests
headers = {"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"}
created = requests.post(
"https://api.aisearchapi.dev/v1/watches",
headers={**headers, "Content-Type": "application/json"},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}],
"extract": {"targets": ["HubSpot", "attio.com"]},
"schedule": {"every": "1d"},
},
)
watch_id = created.json()["watch"]["id"]
inspected = requests.get(
f"https://api.aisearchapi.dev/v1/watches/{watch_id}",
headers=headers,
)
print(inspected.json())
```
## Related
Request fields, schedules, and creation errors.
Read configuration and the ten newest runs.
Pause, resume, or change the cadence and delivery.
Page through complete run history.
Verify the signed deliveries from dispatched runs.
Per-surface, success-only billing for each dispatch.
---
# Create a search
Source: https://docs.aisearchapi.dev/api-reference/search
> Submit a query to one or more AI search surfaces with POST /v1/search — sync by default for a single surface, durable async fan-out for everything else.
Submit a query to one or more AI search surfaces.
- **One surface, no webhook** (the hello-world case) runs **synchronously by default**: the call returns the terminal [Envelope](/guides/output-formats) in one `200`.
- **Multiple surfaces, a `webhook`, or `?mode=async`** use the durable path: `202` immediately with a parent job and one child per surface × region. Poll [`GET /v1/jobs/:id`](/api-reference/get-job) or register a [webhook](/guides/webhooks).
```http
POST https://api.aisearchapi.dev/v1/search
```
Every request needs `Authorization: Bearer ` and `Content-Type: application/json`.
## Query parameters
`sync` forces the bounded inline path; `async` forces the durable `202` + poll
path. Omitted: a single-surface no-webhook request runs sync, everything else
async. The header `Prefer: wait=30` is equivalent to `?mode=sync`.
`flat` projects each Envelope of a sync response to the compact flat shape (`surface`, `status`, `text`, `markdown`, `sources`). One child returns the flat object directly; several children return `{ "results": [...] }`.
## Body
The prompt to run. This is the exact text sent to each surface. `prompt` is
accepted as an alias. Non-empty, up to **2000 characters** — a longer value is
rejected with `422 QUERY_TOO_LONG`.
One or more surfaces to capture. Each surface produces one child job per region.
Allowed values: `chatgpt`, `claude`, `perplexity`, `gemini`, `copilot`, `google_ai_overview`, `google_ai_mode`, `google_search`, `google_news`.
Where to run each surface — at most **10** per request. One child is created per surface × region. Omitted: one untargeted (`GLOBAL`) child per surface. A bare string like `"US"` is accepted as sugar for `{ "country": "US" }`.
ISO-3166 alpha-2 country code, e.g. `US`, `GB`, `DE`.
Optional city to localize the capture.
Optional BCP-47 language hint for the surface.
Receive a signed POST on each child's terminal state instead of polling. Setting a webhook forces the async path. Recommended for fan-out.
Public http(s) endpoint to notify. SSRF-guarded — loopback/private/link-local/metadata targets are rejected at submission.
Shared secret used to HMAC-sign the request body. Omit to sign with your account's active managed secret. See [Webhooks](/guides/webhooks).
Safe-retry key. This is a **body field**, not a header. Reusing the same key
with the same body replays the original job (`202` + `Idempotent-Replayed:
true`); reusing it with a different body returns `409 IDEMPOTENCY_CONFLICT`.
Opt in to [Auto Extract](/guides/auto-extract). `true` is identical to `{}`:
extract brands the answer mentions without pinning targets. `false`, or
omitting the field, disables extraction and leaves `evidence.mentions` as
`null`. Auto Extract is **BETA** and adds `0` credits today; pricing will
change at GA.
Up to **10** brand names or bare domains, each non-empty and at most **100
characters**. Pinned targets are reported whether present or absent.
**Unknown top-level fields are rejected**, not silently dropped. Sending a
field this endpoint doesn't accept (a typo, or a control it doesn't support)
returns `400 VALIDATION_FAILED` naming the offender. Send only the fields
above — the API fails loud so a field that looks like a control never no-ops
in silence.
## Request
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }],
}),
});
const job = await res.json();
console.log(res.status, job); // 202 (two surfaces -> async)
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}],
},
)
print(res.status_code, res.json()) # 202 (two surfaces -> async)
```
## Response `202 Accepted` (async)
The parent job id has **no dots**. Each entry in `children` is a dotted **3-segment** child id — `..` — where the region key is the lowercased `country[:city][:language]` (or `GLOBAL` when untargeted).
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.chatgpt.us", "job_8t2q.perplexity.us"]
}
```
The parent job id (dotless). Read it with `GET /v1/jobs/job_8t2q` to see the
roll-up status and per-child state.
Always `processing` on submit. Terminal states are `completed`, `partial`,
`failed`, `canceled`, `expired`.
One dotted child id per surface × region. Reading a child id returns its
[Envelope](/guides/output-formats).
A poll loop should stop on **any** terminal state — prefer
[webhooks](/guides/webhooks) over polling for fan-out.
## Response `200 OK` (sync)
A sync call returns the parent id, its rollup status, and the full Envelope per child:
```json
{
"jobId": "job_8t2q",
"status": "completed",
"children": [
{
"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-3",
"inferred": true,
"confidence": 0.6
},
"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_e9e13810ec46f8bbb814497766d1d05c",
"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": "For startups...",
"referenceIds": [0, 1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.hubspot.com",
"title": "HubSpot",
"role": "cited",
"cited": true,
"charRanges": [[27, 34]]
},
{
"id": 1,
"url": "https://attio.com",
"title": "Attio",
"role": "cited",
"cited": true,
"charRanges": [[36, 41]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best crm for startups 2026"]
},
"mentions": null,
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
]
}
```
`evidence.mentions` populates only when the request sets `extract`. See [Auto
Extract](/guides/auto-extract) for the full shape, pinned-target absence
behavior, and examples.
With `?view=flat` the same call returns just `{ surface, status, text, markdown, sources }`.
Sync mode holds the connection open until the capture finishes and is subject
to `429 CONCURRENCY_LIMIT_EXCEEDED`. For more than one surface, prefer the
async default.
If a surface returns nothing, the job still completes:
`provenance.surfacePresent` is `false`, `job.warnings` carries a
`surface_absent` warning, and `answer` is empty. An empty capture costs no
credits.
## Per-surface alias
`POST /v1/search/:surface` targets one surface — sync by default like any single-surface call. It accepts two convenience fields:
- `prompt` — alias of `query`.
- `country` — flat sugar for `regions: [{ "country": "..." }]`.
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search/chatgpt?view=flat" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best crm for startups",
"country": "US"
}'
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/search/chatgpt?view=flat',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt: 'best crm for startups', country: 'US' }),
},
);
const flat = await res.json();
console.log(res.status, flat.markdown); // 200
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search/chatgpt?view=flat",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={"prompt": "best crm for startups", "country": "US"},
)
print(res.status_code, res.json()["markdown"]) # 200
```
## Idempotency
Include `idempotencyKey` in the body to make retries safe.
| Scenario | Result |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------- |
| First request with a key | The job is created normally. |
| Same key + **same** body | `202` with header `Idempotent-Replayed: true` — the original job is replayed, no new work, no double billing. |
| Same key + **different** body | `409 IDEMPOTENCY_CONFLICT`. |
```bash
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt"],
"idempotencyKey": "crm-report-2026-06-30"
}'
```
## Errors
Every error uses the flat shape `{ "code", "error", "request_id", "docs_url", "details"? }`. Validation messages name the offending field **and** the allowed values.
```json 400
{
"code": "UNSUPPORTED_SURFACE",
"error": "surface 'grok' is not supported; valid surfaces: chatgpt, claude, perplexity, gemini, copilot, google_ai_overview, google_ai_mode, google_search, google_news",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#unsupported_surface"
}
```
| Code | Status | When |
| -------------------------------- | ------ | ---------------------------------------------------------------------------------- |
| `AUTH_INVALID` | 401 | Missing, bad, or revoked API key. |
| `VALIDATION_FAILED` | 400 | A field failed validation — the message names the field and allowed values. |
| `UNSUPPORTED_SURFACE` | 400 | A value in `surfaces` (or the alias `:surface`) isn't a surface. |
| `INSUFFICIENT_CREDITS` | 402 | Not enough credits; nothing is spawned. |
| `IDEMPOTENCY_CONFLICT` | 409 | Same `idempotencyKey` reused with a different body. |
| `UNSUPPORTED_METHOD_FOR_SURFACE` | 422 | The surface has no live v1 capture path yet — the message names what IS supported. |
| `TOO_MANY_REGIONS` | 422 | More than 10 regions in one request. |
| `QUERY_TOO_LONG` | 422 | `query` (or `prompt`) is over 2000 characters. |
| `RATE_LIMIT_EXCEEDED` | 429 | Account request rate exceeded. |
| `CONCURRENCY_LIMIT_EXCEEDED` | 429 | Too many in-flight sync requests. |
| `QUEUE_CAPACITY_EXCEEDED` | 429 | Async queue is full. |
429 responses carry `Retry-After` alongside `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. See [Errors](/guides/errors) for the full catalog.
## Response headers
| Header | On | Meaning |
| ---------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `X-Request-Id` | every response | Correlation id; matches `request_id` in error bodies. Send your own to thread it through. |
| `X-AISearch-Version` | every response | Envelope schema version that served the request. |
| `X-RateLimit-Limit` / `-Remaining` / `-Reset` | submits + 429 | Live admission-bucket state for your key. |
| `Retry-After` | 429 | Seconds to wait before retrying. |
| `X-Concurrency-Limit` / `-Running` / `-Queued` | submits | Sync concurrency state for your key. |
| `Idempotent-Replayed` | replays | `true` when an idempotent re-submit replayed the original job. |
Poll a parent or fetch a single child's Envelope.
Every field in the canonical per-surface result.
Extract brand mentions and pin targets for present-or-absent reporting.
---
# Batch search
Source: https://docs.aisearchapi.dev/api-reference/batch
> Submit up to 500 searches in a single request with POST /v1/search/batch. Each item is validated independently and fans out into its own job.
Submit many searches in one request. `POST /v1/search/batch` accepts up to **500 items**, where each item is a full [search body](/api-reference/search). Items are validated **independently**: a validation failure on one item never blocks the others.
Batch is a convenience for submission, not a different execution model. Each
accepted item behaves exactly like an async standalone `POST /v1/search` — it
becomes a parent job that fans out into one child per surface × region, and
each child is billed and completed on its own.
## Request
Array of 1–500 [search bodies](/api-reference/search). Each element is
validated independently and supports the standard search body: `query` (or
`prompt`), `surfaces`, `regions`, `webhook`, and a per-item `idempotencyKey`
(see [Idempotency](#idempotency) below).
```bash cURL
curl https://api.aisearchapi.dev/v1/search/batch \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }]
},
{
"query": "cheapest ev in europe 2026",
"surfaces": ["google_ai_overview"],
"regions": [{ "country": "DE", "language": "de" }]
},
{
"query": "",
"surfaces": ["claude"]
}
]
}'
```
```js Node (fetch)
const res = await fetch('https://api.aisearchapi.dev/v1/search/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: [
{
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }],
},
{
query: 'cheapest ev in europe 2026',
surfaces: ['google_ai_overview'],
regions: [{ country: 'DE', language: 'de' }],
},
{ query: '', surfaces: ['claude'] },
],
}),
});
const { items } = await res.json();
for (const item of items) {
if (item.status === 'accepted') {
console.log(item.index, '->', item.jobId, item.children);
} else {
console.warn(item.index, 'rejected:', item.code, item.error);
}
}
```
```python Python (requests)
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search/batch",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"items": [
{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}],
},
{
"query": "cheapest ev in europe 2026",
"surfaces": ["google_ai_overview"],
"regions": [{"country": "DE", "language": "de"}],
},
{"query": "", "surfaces": ["claude"]},
]
},
)
for item in res.json()["items"]:
if item["status"] == "accepted":
print(item["index"], "->", item["jobId"], item["children"])
else:
print(item["index"], "rejected:", item["code"], item["error"])
```
## Response
Returns `200` with an `items` array that mirrors the request order. Each result carries the original `index` and is either **accepted** (with the created parent `jobId` and its child ids) or **rejected** (with the stable error `code` and message). A rejected item does not affect any other item in the batch.
```json Response
{
"items": [
{
"index": 0,
"status": "accepted",
"jobId": "job_8t2q",
"children": ["job_8t2q.chatgpt.us", "job_8t2q.perplexity.us"]
},
{
"index": 1,
"status": "accepted",
"jobId": "job_5k9d",
"children": ["job_5k9d.google_ai_overview.de:de"]
},
{
"index": 2,
"status": "rejected",
"code": "VALIDATION_FAILED",
"error": "query is required and must be a non-empty string (alias: prompt)"
}
]
}
```
One result per submitted item, in request order.
Zero-based position of this item in the request `items` array.
`accepted` or `rejected`.
Present when `status` is `accepted`. The created parent job id (on a **replay**, the existing parent the item's `idempotencyKey` already maps to). Poll [`GET /v1/jobs/:id`](/api-reference/get-job) or use a webhook to read results.
Present and `true` only when this item's `idempotencyKey` replayed an existing parent (same key, same body). A replayed item is **not re-billed** and spawns **no new work** — its `jobId` points at the original parent. Omitted otherwise.
Present when `status` is `accepted`. One dotted child id per surface × region (`job_..`).
Present when `status` is `rejected`. The stable error code (e.g. `VALIDATION_FAILED`, `UNSUPPORTED_SURFACE`, `UNSUPPORTED_METHOD_FOR_SURFACE`, or `IDEMPOTENCY_CONFLICT` when an item reuses an `idempotencyKey` with a different body). See [Errors](/guides/errors).
Present when `status` is `rejected`. The human-readable message naming the offending field and allowed values.
## Batch-level errors
Some conditions reject the **whole batch** before anything is spawned — as a standard flat error envelope, not per-item results:
| Code | Status | When |
| ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_FAILED` | 400 | `items` is missing, empty, or has more than 500 entries. |
| `INSUFFICIENT_CREDITS` | 402 | Your balance can't cover the batch's total planned children. The credit reservation is **atomic over the whole batch** — an insufficient balance rejects everything and nothing is spawned. |
| `RATE_LIMIT_EXCEEDED` | 429 | The submit-rate gate fired. Honor `Retry-After`. |
## Fan-out and billing
Each accepted item becomes its **own parent job** with **one child per surface × region**. Credits are charged per child on a successful capture — an empty or failed capture costs nothing.
In the example above, item 0 (`chatgpt` + `perplexity`, one region) creates two children, and item 1 (`google_ai_overview`, one region) creates one child. Item 2 was rejected and created nothing.
Batch is always asynchronous — there is no `mode=sync` for batch. Prefer
[webhooks](/guides/webhooks) over polling when submitting large batches so you
are notified as each child reaches a terminal state.
## Idempotency
Each batch item is an independent job, so a per-item `idempotencyKey` behaves exactly like it does on [`POST /v1/search`](/api-reference/search) — applied to that one item. Give each item its own key (a stable id from your side, e.g. a run id) and safely retry the whole batch after an ambiguous failure:
- **Same key, same body** → the item is **accepted with `replayed: true`** and its original `jobId`. It is **not re-billed** and spawns **no new work** — poll that `jobId` for the already-running (or finished) results.
- **Same key, different body** → the item is **rejected** with `IDEMPOTENCY_CONFLICT` (a per-item rejection — sibling items are unaffected). Use a fresh key whenever the payload changes.
- **New key** → runs normally as a fresh submission.
```json Response — item 0 retried (replay), item 1 fresh
{
"items": [
{
"index": 0,
"status": "accepted",
"jobId": "job_8t2q",
"replayed": true,
"children": ["job_8t2q.chatgpt.us", "job_8t2q.perplexity.us"]
},
{
"index": 1,
"status": "accepted",
"jobId": "job_5k9d",
"children": ["job_5k9d.google_ai_overview.de:de"]
}
]
}
```
Retrying a whole batch where every item carries a stable `idempotencyKey` is
safe: already-accepted items replay (no double-charge, no duplicate jobs) and
only the genuinely-new items run.
## Related
The single-search endpoint and full request body.
Poll a parent or read a child Envelope.
Get notified as each child completes.
Error codes returned on rejected items.
---
# Get a job
Source: https://docs.aisearchapi.dev/api-reference/get-job
> Read a search job by id — poll a parent for its children, or fetch a child to get the canonical Envelope for one surface.
`GET /v1/jobs/:id` reads the current state of a job. There are two kinds of id, and they return two different shapes.
A job id. **Parent** ids have no dots (`job_8t2q`). **Child** ids are dotted,
with three segments (`job_8t2q.chatgpt.us`).
## Parent vs. child ids
When you submit a search, you get back a parent job and one child per `surface × region`.
- A **parent** id (`job_8t2q`) returns a rollup: the parent's status plus a list of its children. Use it to track fan-out progress.
- A **child** id (`job_8t2q.chatgpt.us`) returns the canonical [Envelope](/guides/output-formats), the answer and its provenance for exactly one surface in one region.
Child ids follow the form `job_..` — the region key is the lowercased `country[:city][:language]` (or `GLOBAL` when untargeted). You get them from the `children` array on the submit response (`202`) or from a parent read.
## Parent response
```bash cURL
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```js Node
const res = await fetch('https://api.aisearchapi.dev/v1/jobs/job_8t2q', {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const job = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
job = res.json()
```
```json Parent
{
"job": {
"id": "job_8t2q",
"status": "processing",
"children": [
"job_8t2q.chatgpt.us",
"job_8t2q.claude.us",
"job_8t2q.perplexity.us"
]
},
"children": [
{
"id": "job_8t2q.chatgpt.us",
"surface": "chatgpt",
"region": "us",
"status": "completed"
},
{
"id": "job_8t2q.claude.us",
"surface": "claude",
"region": "us",
"status": "processing"
},
{
"id": "job_8t2q.perplexity.us",
"surface": "perplexity",
"region": "us",
"status": "queued"
}
]
}
```
The parent job id.
The rolled-up job status. See [status values](#status-values).
One entry per `surface × region`.
The child id — fetch it to read the Envelope.
The surface for this child.
The canonical region key (e.g. `us`, `gb:en`, `GLOBAL`).
This child's status.
## Child response (the Envelope)
Fetch a child id to get the full result for one surface.
```bash cURL
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```js Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const envelope = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
envelope = res.json()
```
```json Envelope
{
"job": {
"id": "job_8t2q.chatgpt.us",
"query": "best CRM for startups",
"surface": "chatgpt",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-27T00:00:00.000Z",
"completedAt": "2026-06-27T00:00:03.120Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "gpt-5-3",
"inferred": true,
"confidence": 0.6
},
"webSearch": { "enabled": true, "known": true },
"triggerState": "search",
"persona": "anonymous_default_model",
"loginState": "logged_out",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-27T00:00:03.000Z",
"callUuid": "call_e9e13810ec46f8bbb814497766d1d05c",
"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": "For startups...", "referenceIds": [0, 1] }
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.hubspot.com",
"title": "HubSpot",
"role": "cited",
"cited": true,
"charRanges": [[27, 34]]
},
{
"id": 1,
"url": "https://attio.com",
"title": "Attio",
"role": "cited",
"cited": true,
"charRanges": [[36, 41]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best crm for startups 2026"]
},
"mentions": null,
"shopping": null,
"ads": null
},
"credits": { "creditsToCharge": 5, "creditsCharged": 5 }
}
```
The Envelope has four sections: `job`, `provenance`, `answer`, and `evidence` (plus optional `html` and `credits`). See [The Envelope](/guides/output-formats) for a full field-by-field reference.
If a surface returns nothing, the child still finishes as `completed` with
`provenance.surfacePresent: false`, a `surface_absent` warning, and an empty
`answer`. An empty capture costs no credits.
## Status values
A job is always in exactly one status.
The job is still running. Keep polling, or wait for a webhook. A child that is claimed but not yet persisted returns `{ "job": { "id": ..., "status": "running" }, "children": [] }`.
The job is done. **Stop polling** on any of these.
For a parent, the rollup is derived from its children: `completed` when all children completed, `failed` when all failed, and `partial` on a mix.
A poll loop must stop on **any** terminal state — not just `completed`.
Treating `partial`, `failed`, `canceled`, or `expired` as "keep waiting" will
loop forever.
## Polling loop
Poll the parent until it reaches a terminal state, then read each child.
```bash Poll a parent until terminal
JOB_ID="job_8t2q"
while true; do
BODY=$(curl -s "https://api.aisearchapi.dev/v1/jobs/$JOB_ID" \
-H "Authorization: Bearer $AISEARCH_API_KEY")
STATUS=$(echo "$BODY" | jq -r '.job.status')
echo "status: $STATUS"
case "$STATUS" in
completed|partial|failed|canceled|expired)
echo "$BODY" | jq -r '.children[].id' | while read -r CHILD; do
curl -s "https://api.aisearchapi.dev/v1/jobs/$CHILD" \
-H "Authorization: Bearer $AISEARCH_API_KEY" | jq '.answer.text'
done
break
;;
esac
sleep 2
done
```
For fan-out across many surfaces or regions, prefer
[webhooks](/guides/webhooks) over polling. You get a signed POST the moment
each child reaches a terminal state — no loop, no wasted requests, and each
child's Envelope is delivered inline.
## Errors
No job exists for the given id, or it has expired. Check the id — remember
that parent ids have no dots and child ids do.
```json 404
{
"code": "JOB_NOT_FOUND",
"error": "no job with id 'job_8t2q.chatgpt.us'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#job_not_found"
}
```
See the full [error reference](/guides/errors) for every code and the shared error shape.
Start a job with `POST /v1/search`.
Every field in a child result.
---
# List jobs
Source: https://docs.aisearchapi.dev/api-reference/list-jobs
> Page through your parent search jobs, newest first, with cursor pagination and optional state / surface filters.
`GET /v1/jobs` returns your **parent** jobs, newest first, with cursor pagination. Reads are owner-scoped: a restricted `sk_*` key sees only its own jobs.
This endpoint lists **parents** only. To read a single parent's rollup or one
child's [Envelope](/guides/output-formats), use [`GET
/v1/jobs/:id`](/api-reference/get-job).
## Query parameters
Page size, `1`–`100`.
Filter by parent lifecycle state: `queued`, `processing`, `completed`,
`partial`, `failed`, `canceled`, or `expired`.
Filter to parents that include a child on this surface (e.g. `chatgpt`).
Opaque cursor from a previous page's `nextCursor`. Omit for the first page.
## Request
```bash cURL
curl "https://api.aisearchapi.dev/v1/jobs?limit=20&state=completed" \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/jobs?limit=20&state=completed',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const page = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs",
params={"limit": 20, "state": "completed"},
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
page = res.json()
```
## Response
```json 200 OK
{
"items": [
{
"id": "job_8t2q",
"status": "completed",
"prompt": "best crm for startups",
"createdAt": "2026-06-30T17:02:11Z"
},
{
"id": "job_5k9d",
"status": "partial",
"prompt": "cheapest ev in europe 2026",
"createdAt": "2026-06-30T16:41:02Z"
}
],
"nextCursor": "eyJvZmZzZXQiOjIwfQ"
}
```
One entry per parent job, newest first.
The parent job id (dotless). Read it with [`GET
/v1/jobs/:id`](/api-reference/get-job) for its rollup and children.
The parent rollup state.
The submitted query.
ISO-8601 submission timestamp.
Pass as `?cursor=` to fetch the next page. `null` on the last page.
## Paginate to the end
```javascript Node
async function* allJobs() {
let cursor;
do {
const url = new URL('https://api.aisearchapi.dev/v1/jobs');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const page = await res.json();
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
for await (const job of allJobs()) console.log(job.id, job.status);
```
```python Python
import os, requests
def all_jobs():
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
res = requests.get(
"https://api.aisearchapi.dev/v1/jobs",
params=params,
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
page = res.json()
yield from page["items"]
cursor = page["nextCursor"]
if not cursor:
break
for job in all_jobs():
print(job["id"], job["status"])
```
Read a parent rollup or a single child Envelope.
Stop a parent's remaining children.
---
# Cancel a job
Source: https://docs.aisearchapi.dev/api-reference/cancel-job
> Stop a parent job’s remaining non-terminal children. Already-finished children keep their result.
`POST /v1/jobs/:id/cancel` cancels a parent job's remaining **non-terminal** children. Children that already reached a terminal state (`completed`, `partial`, `failed`) keep their outcome and their artifacts — cancellation only stops work that hasn't finished.
The **parent** job id (dotless, e.g. `job_8t2q`).
## Request
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/jobs/job_8t2q/cancel \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/jobs/job_8t2q/cancel', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const { job } = await res.json();
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q/cancel",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
job = res.json()["job"]
```
## Response
```json 200 OK
{
"job": {
"id": "job_8t2q",
"status": "canceled"
}
}
```
The parent job id.
The parent's status **after** cancellation. If some children had already
completed, the rollup can be `partial` or `completed` rather than `canceled` —
cancellation never rewrites a finished child's result.
Cancellation is a request to stop, not a rollback. A capture already in flight
may still finish; a successful capture is still billed. Only work that hadn't
started is guaranteed not to run (or be charged).
## Errors
No parent job exists for that id, or it belongs to another tenant (reads and
writes are owner-scoped — another tenant's id is a `404`, never a `403`).
```json 404
{
"code": "JOB_NOT_FOUND",
"error": "no job with id 'job_8t2q'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#job_not_found"
}
```
Read the parent rollup and per-child status.
Terminal states and when work can still be canceled.
---
# Fetch the proof-of-page HTML
Source: https://docs.aisearchapi.dev/api-reference/artifacts
> Stream the opt-in proof-of-page HTML for a consumer-UI capture, referenced by an Envelope’s top-level html URL.
`GET /v1/artifacts/:key` streams the **proof-of-page HTML**: a rendered snapshot of the exact consumer-UI page an answer was captured from, so you can show a receipt of what a real user saw. It is **opt-in** — the snapshot is published only when the request set `include.html: true`, and only for **consumer-UI (scrape) surfaces** (the ones with a real page to snapshot). When it exists, the Envelope carries a top-level `html` field: a pre-built link to this endpoint. There is no separate key to assemble — you follow that `html` URL. The internal raw capture blob is never served here.
This endpoint is **private**. It requires your API key, and a restricted
`sk_*` key can only read artifacts stamped with its own owner. A mismatch is a
`404`, never a `403` (no existence leak across tenants). Never expose these
URLs client-side.
The artifact key, **URL-encoded**. You never build this by hand — the
top-level `html` field in an Envelope is already a full URL that points at
this endpoint with the key encoded for you.
## Request
Follow the top-level `html` URL from an Envelope directly. It is present only when you opted in with `include.html: true` on the request and the surface was a consumer-UI capture:
```bash cURL
# The html URL comes straight from the Envelope
curl -L "https://api.aisearchapi.dev/v1/artifacts/job_8t2q.chatgpt.us/call_e9e13810ec46f8bbb814497766d1d05c/page.html" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-o page.html
```
```javascript Node
// Read the Envelope, then fetch the proof-of-page HTML.
const job = await (
await fetch('https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us', {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
})
).json();
// `html` is only present when you opted in (include.html: true) on a scrape surface.
if (job.html) {
const res = await fetch(job.html, {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const html = await res.text();
}
```
```python Python
import os, requests
headers = {"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"}
job = requests.get(
"https://api.aisearchapi.dev/v1/jobs/job_8t2q.chatgpt.us",
headers=headers,
).json()
# `html` is only present when you opted in (include.html: true) on a scrape surface.
html_url = job.get("html")
if html_url:
res = requests.get(html_url, headers=headers)
html = res.text
```
Opt in at request time — set `include.html: true` on the submit body, or add
`?include=html` when you poll. If the field is absent, the surface wasn't a
consumer-UI capture or you didn't opt in, and there is nothing to fetch.
## Response
`200 OK` streams the HTML snapshot with the **stored content type** and an `ETag`:
| Artifact | Typical content type |
| ---------------------- | -------------------- |
| Proof-of-page snapshot | `text/html` |
Artifacts are retained durably, **not** expired at 24 hours, so the `html` URL keeps resolving for as long as you need it.
## Errors
Missing or invalid API key.
The key doesn't exist, or it belongs to another tenant.
```json 404
{
"code": "ARTIFACT_NOT_FOUND",
"error": "no artifact for key 'job_8t2q.chatgpt.us/call_e9e13810ec46f8bbb814497766d1d05c/page.html'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#artifact_not_found"
}
```
Where the opt-in `html` proof-of-page URL comes from.
Read a completed job's Envelope.
---
# Usage
Source: https://docs.aisearchapi.dev/api-reference/usage
> Read your usage ledger and remaining credit balance with GET /v1/usage.
`GET /v1/usage` returns your plan, a usage rollup grouped by `(surface, region)` covering the last 30 days (UTC-day aligned), and your remaining credit balance. The daily `series` spans a wider 90-day trend window. It's a read-only snapshot — nothing here mutates state.
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/usage \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```js Node (fetch)
const res = await fetch('https://api.aisearchapi.dev/v1/usage', {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const usage = await res.json();
```
```python Python (requests)
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/usage",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
usage = res.json()
```
This endpoint takes no parameters. It requires a valid `Authorization: Bearer ` header like every authenticated route.
## Response
```json
{
"plan": "free",
"usage": {
"byGroup": [
{
"surface": "chatgpt",
"region": "us",
"callCount": 540,
"creditsCharged": 2700
},
{
"surface": "claude",
"region": "us",
"callCount": 388,
"creditsCharged": 2328
},
{
"surface": "perplexity",
"region": "GLOBAL",
"callCount": 262,
"creditsCharged": 786
}
],
"totals": { "callCount": 1190, "creditsCharged": 5814 }
},
"balance": {
"plan": "free",
"credits": 236,
"unlimited": false
}
}
```
Your current plan identifier.
Your usage ledger, rolled up by the never-blended `(surface, region)` key.
One entry per distinct `(surface, region)` combination you have run. `surface` is the surface enum value, `region` the canonical region key (lowercased `country[:city][:language]`, or `GLOBAL` for untargeted). `callCount` is the number of child captures; `creditsCharged` the credits billed for them.
`{ callCount, creditsCharged }` summed across every group.
Your remaining credit balance.
Plan identifier for the balance, matching the top-level `plan`.
Credits remaining. `null` until your first submit seeds the balance (new accounts are seeded with 500 credits on first use).
`true` only on unmetered (internal) access, where `credits` is `null` and captures don't draw down a balance. `false` on customer keys.
## Per-child accounting
Usage is counted per **child capture**, not per request. A single `POST /v1/search` fans out into one child per surface × region, and each child is recorded independently against its exact `(surface, region)` group.
A search across **N surfaces** and **M regions** produces **N × M** child captures. For example, `surfaces: ["chatgpt", "claude", "perplexity"]` with `regions: [{ "country": "US" }, { "country": "GB" }]` is 3 × 2 = 6 captures, adding 6 to `totals.callCount` across six groups.
## Credits vs. calls
`callCount` counts captures; `creditsCharged` tracks what they cost. They're related but not identical: a capture only draws down credits when it succeeds. An empty capture (`surfacePresent: false`) or a failed one is not billed.
Per-capture credit costs vary by surface. See [Credits & pricing](/guides/credits) for the full table.
Internal (first-party) keys can scope this read to a single tenant with the
`?owner=` query parameter or the `X-AISearch-On-Behalf-Of` header; the
enforced owner is echoed back in the body (`owner`) and the `X-AISearch-Owner`
header. Customer (`sk_*`) keys are always scoped to their own account — an
override is ignored.
What each capture costs and how billing works.
How parent and child jobs move through terminal states.
---
# Async status
Source: https://docs.aisearchapi.dev/api-reference/async-status
> Read a live inflight snapshot for your API key so you can pace submissions and avoid 429s.
`GET /v1/async/status` returns a **live snapshot** of everything currently in flight for your API key: how deep your admission queue is versus its capacity, how much of your sync concurrency budget is in use, and your inflight children grouped by region. It takes **no parameters** — it always reports the current state of the key making the request.
Use it as a pacing signal. Before you fire a large batch of captures, read this endpoint to see how much headroom you have, then throttle your submissions to stay under your limits instead of discovering them through `429` responses.
This is a read-only snapshot and never counts against your credits. It
reflects state at the moment of the request and can change immediately as work
is admitted and completed.
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/async/status \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```ts TypeScript
const res = await fetch('https://api.aisearchapi.dev/v1/async/status', {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
},
});
const status = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/async/status",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
status = res.json()
```
## Response
```json 200 OK
{
"queue": {
"depth": 12,
"capacity": 200
},
"concurrency": {
"limit": 8,
"running": 3,
"queued": 12
},
"inflight": {
"byRegion": {
"us": 18,
"gb": 6,
"GLOBAL": 3
}
}
}
```
The async admission queue for your key. New async submissions wait here before their children begin processing.
Number of submissions currently waiting in the admission queue.
Maximum the queue can hold. When `depth` reaches `capacity`, new submissions are rejected with `429 QUEUE_CAPACITY_EXCEEDED`.
Your synchronous concurrency budget — the number of sync captures (the single-surface default, `?mode=sync`, or `Prefer: wait=30`) that can run at the same time.
Maximum sync captures that may run concurrently for your key.
Sync captures running right now. When `running` reaches `limit`, further sync requests return `429 CONCURRENCY_LIMIT_EXCEEDED`.
Async work currently waiting in the admission queue (mirrors `queue.depth`).
Your children currently active (queued or running), counted in one owner-scoped pass.
Inflight child counts keyed by canonical region key (lowercased `country[:city][:language]`, or `GLOBAL` for untargeted). Regions with nothing in flight are omitted.
## Pace submissions to avoid 429s
Every `429` your key can receive maps to a field in this snapshot, so you can preflight against it:
| You want to avoid | Watch | Headroom formula |
| ---------------------------- | ------------------------------------------ | ------------------ |
| `QUEUE_CAPACITY_EXCEEDED` | `queue` | `capacity - depth` |
| `CONCURRENCY_LIMIT_EXCEEDED` | `concurrency` | `limit - running` |
| `RATE_LIMIT_EXCEEDED` | request rate (see `X-RateLimit-*` headers) | — |
Poll `GET /v1/async/status` right before submitting a large set of captures.
For async work, keep submissions under `queue.capacity - queue.depth`. For
sync work, keep concurrent calls under `concurrency.limit -
concurrency.running`.
Submit up to your headroom, let some children drain, and re-read the
snapshot before the next wave rather than retrying blindly.
Prefer async submission for large batches. A single `POST /v1/search` fans out
into one child per surface × region and only occupies the admission queue,
leaving your sync concurrency budget free for latency-sensitive, one-surface
calls.
This snapshot is advisory, not a reservation. Between reading it and
submitting, other requests on the same key can consume headroom. Always still
handle `429` responses by honoring the `Retry-After` header.
## Related
How the rate limit, sync concurrency budget, and admission queue fit
together — and how to back off cleanly.
Submit a parent job, fan out across surfaces and regions, and poll children
as they complete.
---
# List webhook secrets
Source: https://docs.aisearchapi.dev/api-reference/webhook-secrets
> Read owner-scoped metadata for your managed webhook signing secrets — never the secret value itself.
`GET /v1/webhooks/secrets` returns owner-scoped, **read-only** metadata for your managed webhook signing secrets (`whsec_…`). It lists which secrets exist and their status — it **never** returns the secret value.
Managed signing secrets are **created and rotated in the
[dashboard](https://aisearchapi.dev/home)**, not through the API — the API
treats them as read-only. Use this endpoint to check which secret is active
before you rely on it to verify a [webhook](/guides/webhooks).
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/webhooks/secrets \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/webhooks/secrets', {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const { secrets } = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/webhooks/secrets",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
secrets = res.json()["secrets"]
```
## Response
```json 200 OK
{
"secrets": [
{
"id": "whsec_1a2b3c",
"status": "active",
"createdAt": "2026-06-20T09:00:00Z",
"expiresAt": null
},
{
"id": "whsec_9z8y7x",
"status": "retiring",
"createdAt": "2026-05-01T09:00:00Z",
"expiresAt": "2026-07-01T09:00:00Z"
}
]
}
```
Your managed signing secrets, metadata only.
The secret's identifier (`whsec_…`) — not the signing value.
`active` (currently signing new deliveries) or `retiring` (still valid for
verification during an overlap window, being phased out).
ISO-8601 creation timestamp.
When a retiring secret stops being accepted; `null` for an active secret
with no scheduled expiry.
During a rotation both an `active` and a `retiring` secret can be valid at
once, so verify incoming deliveries against **every** non-expired secret until
the old one's `expiresAt` passes. See [Webhooks](/guides/webhooks) for the
signature-verification flow.
Register a webhook and verify its HMAC-SHA256 signature.
The result shape delivered to your webhook.
---
# List surfaces
Source: https://docs.aisearchapi.dev/api-reference/list-surfaces
> Public, unauthenticated discovery of every surface and whether it is requestable right now — the live capability matrix.
`GET /v1/surfaces` returns the **live capability matrix**: every surface and whether it's requestable today. It's the source of truth for what you can capture — read it at runtime instead of hard-coding a surface list.
**This endpoint is deliberately unauthenticated.** `GET /v1/surfaces` and
[`GET /v1/regions`](/api-reference/list-regions) are the only `/v1` routes
that take no `Authorization` header, so an agent or client can enumerate
what's live **before** it has a key. The data is public and safe; every other
`/v1` route requires a valid Bearer token.
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/surfaces
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/surfaces');
const { surfaces } = await res.json();
const live = surfaces.filter((s) => s.live).map((s) => s.id);
```
```python Python
import requests
data = requests.get("https://api.aisearchapi.dev/v1/surfaces").json()
live = [s["id"] for s in data["surfaces"] if s["live"]]
```
## Response
```json 200 OK
{
"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"
}
```
Every surface in the enum with its current status.
The `surfaces` enum value to send in a search request.
`true` when the surface is requestable today. `false` means a request for it
returns `422 UNSUPPORTED_METHOD_FOR_SURFACE` (e.g. `gemini` — see
[Surfaces](/guides/surfaces)).
The current schema version, matching the `X-AISearch-Version` header.
Drive your integration off `live` rather than a hard-coded list. When a
roadmap surface (like `gemini`) goes live, your code picks it up with no
change — the request body and response Envelope stay identical.
Each surface, its enum value, and what's live vs. phase 2.
The other public discovery endpoint: geo targeting.
---
# Describe regions
Source: https://docs.aisearchapi.dev/api-reference/list-regions
> Public, unauthenticated discovery of how geo targeting works — the region object shape, examples, and popular countries.
`GET /v1/regions` describes how **geo targeting** works: the shape of a region object, worked examples, and the popular countries the exit network covers. Like [`GET /v1/surfaces`](/api-reference/list-surfaces), it's public discovery — read it to learn the targeting model without needing a key.
**Deliberately unauthenticated**, like `GET /v1/surfaces`. Every other `/v1`
route requires `Authorization: Bearer `; these two discovery routes do
not, so a client can learn the capability and targeting model up front.
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/regions
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/regions');
const regions = await res.json();
```
```python Python
import requests
regions = requests.get("https://api.aisearchapi.dev/v1/regions").json()
```
## Response
```json 200 OK
{
"targeting": {
"country": "ISO 3166-1 alpha-2 country code (required)",
"state": "Optional sub-national code or name",
"city": "Optional city name",
"language": "Optional BCP-47 language hint"
},
"examples": [
{ "country": "US" },
{ "country": "GB", "city": "London", "language": "en" },
{ "country": "DE", "language": "de" }
],
"popularCountries": ["US", "GB", "DE", "FR", "CA", "AU", "IN", "JP", "BR"]
}
```
The fields a region object accepts. `country` is required; `state`, `city`,
and `language` narrow the capture further.
Ready-to-use region objects covering common targeting patterns.
Commonly targeted countries where the v1 exit network has the strongest
coverage. `country` accepts any valid ISO 3166-1 alpha-2 code — this list is a
convenience, not an allowlist, so a country outside it is **not** rejected.
A region multiplies fan-out: one search across N surfaces and M regions
creates N × M billed children. See [Regional availability](/guides/regions)
for the full targeting model, including `requested` vs. `effective` region.
Targeting, fan-out math, and requested vs. effective region.
The other public discovery endpoint: the live surface matrix.
---
# Health
Source: https://docs.aisearchapi.dev/api-reference/health
> Unauthenticated liveness probe for the AI Search API — check uptime and read the current schema version.
`GET /v1/health` is an unauthenticated liveness probe. Use it for uptime checks and to read the API's current schema version. It has two modes: a cheap **shallow** check by default, and a **deep** dependency probe with `?probe=deep`.
No `Authorization` header required. (The discovery endpoints `GET
/v1/surfaces` and `GET /v1/regions` are also public; everything else needs a
key.)
## Shallow check (default)
The default check confirms the Worker is running and its storage bindings are wired. It does **not** touch the dependencies — it's cheap enough to hit on a tight interval.
```bash cURL
curl https://api.aisearchapi.dev/v1/health
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/health');
const health = await res.json();
console.log(health.status); // "ok"
```
```python Python
import requests
res = requests.get("https://api.aisearchapi.dev/v1/health")
print(res.json()["status"]) # "ok"
```
```json 200 OK
{
"status": "ok",
"schemaVersion": "2026-06-27",
"bindings": { "artifacts": true, "edgeCache": true, "hyperdrive": true }
}
```
Liveness indicator: `ok` or `degraded`. On the shallow check, `ok` means every
binding is present; a missing binding is `degraded` with HTTP `503`.
The current envelope/response schema version (a date string, e.g.
`2026-06-27`). This same value is returned as the `X-AISearch-Version` header
on **every** response, so you can pin or detect schema changes without calling
this endpoint.
Whether the API's storage bindings are wired: `artifacts` (durable artifact
store), `edgeCache`, and `hyperdrive` (database connectivity). All `true` in a
healthy deployment.
## Deep probe (`?probe=deep`)
Add `?probe=deep` to run a **live round-trip against every critical dependency** — a `SELECT 1` over Hyperdrive→Postgres, a `SELECT 1` over D1 (`edgeCache`), and an R2 `list` (`artifacts`) — each under a hard ~2-second timeout. The response shape is identical, but each `bindings` boolean now reflects a real round-trip, not just binding presence. If **any** dependency fails or times out, `status` flips to `degraded` and the HTTP code becomes **`503`**.
```bash cURL
curl -i "https://api.aisearchapi.dev/v1/health?probe=deep"
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/health?probe=deep');
const health = await res.json();
console.log(res.status, health.status); // 200 "ok" — or 503 "degraded"
```
```python Python
import requests
res = requests.get("https://api.aisearchapi.dev/v1/health?probe=deep")
print(res.status_code, res.json()["status"]) # 200 "ok" or 503 "degraded"
```
```json 503 Service Unavailable (a dependency is down)
{
"status": "degraded",
"schemaVersion": "2026-06-27",
"bindings": { "artifacts": true, "edgeCache": true, "hyperdrive": false }
}
```
**Point production uptime monitors at `?probe=deep`, not the shallow check.**
The shallow check can report `ok` while Postgres is unreachable — it only
verifies the binding exists. The deep probe actually exercises each
dependency, so a `200` with `status: "ok"` from it means the API can genuinely
serve work. Alert on any `503` or `status: "degraded"`.
Deep-probe results are cached briefly in-process, so a burst of monitor hits
(or a retry storm) can't pile up dependency connections. Because it takes no
auth, you can probe it from anywhere without exposing your API key.
---
# Create a watch
Source: https://docs.aisearchapi.dev/api-reference/watches/create
> Save a query and schedule recurring AI-surface captures without running your own cron job.
`POST /v1/watches` creates an active watch that re-runs a saved query on a cadence.
```http
POST https://api.aisearchapi.dev/v1/watches
```
Every request needs `Authorization: Bearer ` and `Content-Type: application/json`.
Creating a watch is **free**. Credits are reserved only when a scheduled run
dispatches, using the same per-surface, success-only billing as an async
search. A skipped run costs nothing.
Watches can also be created from the dashboard Watches page or by an agent
through the `create_watch` tool on the [MCP server](/mcp) — every path hits
this same endpoint with identical validation, plan limits, and billing.
## Tenant scope
Restricted (`sk_*`) keys always create watches for their own owner. Internal keys can act on behalf of one tenant for reads **and writes** by sending `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
Internal keys only. Owner id to enforce for this write. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Body
Optional display name. Omitted watches use an empty string.
The exact prompt to run on every tick. Non-empty, up to **2000 characters**.
This endpoint does not accept the `prompt` alias.
One or more surfaces. Allowed values: `chatgpt`, `claude`, `perplexity`,
`gemini`, `copilot`, `google_ai_overview`, `google_ai_mode`, `google_search`,
`google_news`.
Up to **10** regions. Omitted: one untargeted (`GLOBAL`) run per surface.
ISO-3166 alpha-2 country code, e.g. `US`.
Optional state or first-level administrative area.
Optional city.
Optional BCP-47 language hint.
Auto Extract configuration re-applied on every dispatched run. `true` and
`{}` extract opportunistically; `false` or omission disables extraction.
Up to 10 pinned brand names or bare domains, each non-empty and at most
100 characters. Pinned targets are reported whether present or absent.
Public HTTPS endpoint that receives each dispatched run's signed deliveries.
Private, loopback, link-local, and metadata destinations are rejected.
Cadence in one of two forms. The interval must meet your plan's floor.
One of `15m`, `30m`, `1h`, `6h`, `12h`, `1d`, or `7d`.
Positive integer for an off-grid cadence. Send either this field or
`every`, not both.
## Request
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/watches \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"schedule": { "every": "1d" }
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/watches', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['chatgpt', 'perplexity'],
regions: [{ country: 'US' }],
schedule: { every: '1d' },
}),
});
const { watch } = await res.json();
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/watches",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{"country": "US"}],
"schedule": {"every": "1d"},
},
)
watch = res.json()["watch"]
```
## Response
```json 201 Created
{
"watch": {
"id": "watch_8f2c1a7e",
"name": "",
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"extract": null,
"webhookUrl": null,
"schedule": { "every": "1d", "intervalMinutes": 1440 },
"status": "active",
"pausedReason": null,
"nextRunAt": "2026-07-12T14:00:00Z",
"lastRunAt": null,
"lastJobId": null,
"createdAt": "2026-07-11T14:00:00Z"
}
}
```
The created public Watch.
Watch id (`watch_...`).
Display name; `""` when omitted.
Saved query.
Surfaces run on each tick.
Saved region objects.
Normalized extraction config, or `null` when disabled.
Delivery destination, or `null`.
Both `every` and the source-of-truth `intervalMinutes`.
`active`, `paused`, or `canceled`.
Pause reason when present, e.g. `user` or `insufficient_credits`.
Next scheduled tick, ISO-8601.
Most recent tick, or `null`.
Parent job id from the most recent run, when present.
Creation time, ISO-8601.
Responses always echo both schedule fields. For an off-grid interval, `every`
is a display convenience; `intervalMinutes` is the source of truth.
## Errors
| Code | Status | When |
| -------------------------- | ------ | --------------------------------------------------------------------- |
| `VALIDATION_FAILED` | 400 | A body field, schedule form, region, or extraction target is invalid. |
| `AUTH_INVALID` | 401 | The API key is missing, invalid, or revoked. |
| `WATCH_LIMIT_EXCEEDED` | 403 | Creating the watch would exceed the plan's active-watch ceiling. |
| `WATCH_INTERVAL_TOO_SHORT` | 422 | The requested interval is below the plan's minimum. |
Creating a watch never returns `INSUFFICIENT_CREDITS`: creation costs 0 credits, and credits are considered only when a run dispatches.
```json 422
{
"code": "WATCH_INTERVAL_TOO_SHORT",
"error": "minimum watch interval is 1440 minutes on the free plan",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#watch_interval_too_short"
}
```
## Related
Scheduling, limits, lifecycle, billing, and run history.
Page through the owner's watches.
Change the cadence, extraction, webhook, or status.
Verify signed deliveries from dispatched runs.
---
# List watches
Source: https://docs.aisearchapi.dev/api-reference/watches/list
> Page through scheduled watches newest first, with status filters and cursor pagination.
`GET /v1/watches` returns the owner's watches, newest first, with cursor pagination.
## Tenant scope
Restricted (`sk_*`) keys see only their own watches. Internal keys can act on behalf of one tenant for watch reads **and writes** by sending `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
## Query parameters
Page size, `1`–`100`.
Filter by `active`, `paused`, or `canceled`.
Opaque cursor from a previous page's `nextCursor`. Omit for the first page.
Internal keys only. Owner id to enforce for this read. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Request
```bash cURL
curl "https://api.aisearchapi.dev/v1/watches?limit=20&status=active" \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/watches?limit=20&status=active',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const page = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/watches",
params={"limit": 20, "status": "active"},
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
page = res.json()
```
## Response
```json 200 OK
{
"items": [
{
"id": "watch_8f2c1a7e",
"name": "Startup CRM monitor",
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"extract": { "targets": ["HubSpot", "attio.com"] },
"webhookUrl": "https://example.com/hooks/aisearch",
"schedule": { "every": "1d", "intervalMinutes": 1440 },
"status": "active",
"pausedReason": null,
"nextRunAt": "2026-07-12T14:00:00Z",
"lastRunAt": "2026-07-11T14:00:00Z",
"lastJobId": "job_8t2q",
"createdAt": "2026-07-01T14:00:00Z"
}
],
"nextCursor": "eyJvZmZzZXQiOjIwfQ"
}
```
Public Watch objects, newest first.
Watch id (`watch_...`).
Display name.
Saved query.
Surfaces run on each tick.
Saved region objects.
Saved extraction config, or `null`.
Delivery destination, or `null`.
`every` plus source-of-truth `intervalMinutes`.
`active`, `paused`, or `canceled`.
Why the watch is paused, when present.
Next scheduled tick, ISO-8601.
Most recent tick, or `null`.
Most recent parent job id, when present.
Creation time, ISO-8601.
Pass as `?cursor=` to fetch the next page. `null` on the last page.
## Paginate to the end
```bash cURL
cursor=""
while true; do
url="https://api.aisearchapi.dev/v1/watches?limit=100"
[ -n "$cursor" ] && url="$url&cursor=$cursor"
page=$(curl -s "$url" -H "Authorization: Bearer $AISEARCH_API_KEY")
printf '%s\n' "$page" | jq -r '.items[] | [.id, .status] | @tsv'
cursor=$(printf '%s\n' "$page" | jq -r '.nextCursor // empty')
[ -z "$cursor" ] && break
done
```
```javascript Node
async function* allWatches() {
let cursor;
do {
const url = new URL('https://api.aisearchapi.dev/v1/watches');
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
});
const page = await res.json();
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
for await (const watch of allWatches()) console.log(watch.id, watch.status);
```
```python Python
import os, requests
def all_watches():
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
res = requests.get(
"https://api.aisearchapi.dev/v1/watches",
params=params,
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
page = res.json()
yield from page["items"]
cursor = page["nextCursor"]
if not cursor:
break
for watch in all_watches():
print(watch["id"], watch["status"])
```
## Errors
The API key is missing, invalid, or revoked.
```json 401
{
"code": "AUTH_INVALID",
"error": "missing or invalid API key; send Authorization: Bearer ",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#auth_invalid"
}
```
## Related
Scheduling, lifecycle, billing, and run history.
Save a new scheduled query.
Read one watch and its ten most recent runs.
Page through full run history.
---
# Get a watch
Source: https://docs.aisearchapi.dev/api-reference/watches/get
> Read one scheduled watch with its current lifecycle state and ten most recent runs.
`GET /v1/watches/:id` returns one watch plus its most recent runs, newest first.
Watch id (`watch_...`).
## Tenant scope
Reads are owner-scoped: another tenant's watch id is `404 WATCH_NOT_FOUND`, never `403`. Restricted (`sk_*`) keys always read their own owner. Internal keys can act on behalf of one tenant for watch reads **and writes** with `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
Internal keys only. Owner id to enforce for this read. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Request
```bash cURL
curl https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const { watch, runs } = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
body = res.json()
watch, runs = body["watch"], body["runs"]
```
## Response
```json 200 OK
{
"watch": {
"id": "watch_8f2c1a7e",
"name": "Startup CRM monitor",
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"extract": { "targets": ["HubSpot", "attio.com"] },
"webhookUrl": "https://example.com/hooks/aisearch",
"schedule": { "every": "1d", "intervalMinutes": 1440 },
"status": "active",
"pausedReason": null,
"nextRunAt": "2026-07-12T14:00:00Z",
"lastRunAt": "2026-07-11T14:00:00Z",
"lastJobId": "job_8t2q",
"createdAt": "2026-07-01T14:00:00Z"
},
"runs": [
{
"id": "wrun_2b7d4e9a",
"watchId": "watch_8f2c1a7e",
"jobId": "job_8t2q",
"scheduledFor": "2026-07-11T14:00:00Z",
"status": "dispatched",
"detail": "search job dispatched",
"createdAt": "2026-07-11T14:00:01Z"
}
]
}
```
The public Watch object, including its query, surfaces, regions, extraction
config, webhook URL, normalized schedule, lifecycle state, and timestamps.
Up to **10** most recent runs, newest first. Use [`GET
/v1/watches/:id/runs`](/api-reference/watches/runs) for full history.
Run id (`wrun_...`).
Owning watch id.
Created parent search job, or `null` when skipped.
Tick this run represents, ISO-8601.
`dispatched`, `skipped_credits`, `skipped_limit`, or `failed`.
Human-readable outcome detail.
Run-row creation time, ISO-8601.
For a `dispatched` run, read its `jobId` with [`GET
/v1/jobs/:id`](/api-reference/get-job) exactly like any other async search.
## Errors
No watch exists for the given id, or it belongs to another tenant. Reads and
writes are owner-scoped, so another tenant's id is a `404`, never a `403`.
```json 404
{
"code": "WATCH_NOT_FOUND",
"error": "no watch with id 'watch_8f2c1a7e'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#watch_not_found"
}
```
## Related
Scheduling, lifecycle, billing, and run history.
Page through the complete run history.
Pause, resume, or change the configuration.
Inspect a dispatched run's parent job.
---
# Update a watch
Source: https://docs.aisearchapi.dev/api-reference/watches/update
> Change a watch cadence, extraction targets, webhook destination, or active state without replacing it.
`PATCH /v1/watches/:id` updates selected watch fields. Send only what changes.
Watch id (`watch_...`).
## Tenant scope
Writes are owner-scoped: another tenant's watch id is `404 WATCH_NOT_FOUND`, never `403`. Restricted (`sk_*`) keys always write to their own owner. Internal keys can act on behalf of one tenant for watch reads **and writes** with `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
Internal keys only. Owner id to enforce for this write. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Body
Every body field is optional.
New display name.
New cadence as `{ "every": "15m|30m|1h|6h|12h|1d|7d" }` or `{ "intervalMinutes": integer }`. The interval must meet the plan floor.
New Auto Extract config. `true` and `{}` enable opportunistic extraction;
`{ "targets": [...] }` pins up to 10 targets of at most 100 characters each;
`false` disables extraction.
New public HTTPS destination. Send `null` to remove the webhook.
`active` or `paused` only. Pausing sets `pausedReason: "user"`. Resuming from
`paused` clears `pausedReason` and reschedules `nextRunAt` to `now +
intervalMinutes`.
You cannot set `status: "canceled"` with `PATCH`. Use [`DELETE
/v1/watches/:id`](/api-reference/watches/delete) to soft-cancel the watch.
## Request
```bash cURL
curl -X PATCH https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schedule": { "every": "6h" },
"extract": { "targets": ["HubSpot", "attio.com"] },
"status": "active"
}'
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e',
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
schedule: { every: '6h' },
extract: { targets: ['HubSpot', 'attio.com'] },
status: 'active',
}),
},
);
const { watch } = await res.json();
```
```python Python
import os, requests
res = requests.patch(
"https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"schedule": {"every": "6h"},
"extract": {"targets": ["HubSpot", "attio.com"]},
"status": "active",
},
)
watch = res.json()["watch"]
```
## Response
```json 200 OK
{
"watch": {
"id": "watch_8f2c1a7e",
"name": "Startup CRM monitor",
"query": "best crm for startups",
"surfaces": ["chatgpt", "perplexity"],
"regions": [{ "country": "US" }],
"extract": { "targets": ["HubSpot", "attio.com"] },
"webhookUrl": "https://example.com/hooks/aisearch",
"schedule": { "every": "6h", "intervalMinutes": 360 },
"status": "active",
"pausedReason": null,
"nextRunAt": "2026-07-11T20:30:00Z",
"lastRunAt": "2026-07-11T14:00:00Z",
"lastJobId": "job_8t2q",
"createdAt": "2026-07-01T14:00:00Z"
}
}
```
The complete public Watch after the update. The schedule always includes both
`every` and source-of-truth `intervalMinutes`.
## Errors
An update field is invalid, including `status: "canceled"`, a malformed
schedule, or an invalid extraction target.
No watch exists for the given id, or it belongs to another tenant.
The new schedule is below the plan's minimum interval. The message names the
floor and plan.
```json 422
{
"code": "WATCH_INTERVAL_TOO_SHORT",
"error": "minimum watch interval is 60 minutes on the starter plan",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#watch_interval_too_short"
}
```
## Related
Lifecycle, plan limits, billing, and run history.
Read the current configuration and recent runs.
Soft-cancel a watch permanently.
Verify signed deliveries from watch runs.
---
# Delete a watch
Source: https://docs.aisearchapi.dev/api-reference/watches/delete
> Soft-cancel a watch so it stops scheduling new runs while preserving its run history.
`DELETE /v1/watches/:id` sets a watch to `canceled`. It does not hard-delete the row or its run history.
Watch id (`watch_...`).
## Tenant scope
Writes are owner-scoped: another tenant's watch id is `404 WATCH_NOT_FOUND`, never `403`. Restricted (`sk_*`) keys always write to their own owner. Internal keys can act on behalf of one tenant for watch reads **and writes** with `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
Internal keys only. Owner id to enforce for this write. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Request
This endpoint has no request body.
```bash cURL
curl -X DELETE https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e',
{
method: 'DELETE',
headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` },
},
);
const { watch } = await res.json();
```
```python Python
import os, requests
res = requests.delete(
"https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e",
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
watch = res.json()["watch"]
```
## Response
```json 200 OK
{
"watch": {
"id": "watch_8f2c1a7e",
"status": "canceled"
}
}
```
The watch id.
Always `canceled` after the soft delete.
A canceled watch stops scheduling new runs. Existing run history stays
readable with [`GET /v1/watches/:id/runs`](/api-reference/watches/runs).
## Errors
No watch exists for that id, or it belongs to another tenant (reads and writes
are owner-scoped — another tenant's id is a `404`, never a `403`).
```json 404
{
"code": "WATCH_NOT_FOUND",
"error": "no watch with id 'watch_8f2c1a7e'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#watch_not_found"
}
```
## Related
Lifecycle, scheduling, billing, and auto-pause behavior.
Read preserved run history after cancellation.
Read the watch and its recent runs.
Create a new scheduled query.
---
# List watch runs
Source: https://docs.aisearchapi.dev/api-reference/watches/runs
> Page through every scheduled attempt for a watch and inspect dispatched or skipped outcomes.
`GET /v1/watches/:id/runs` returns the watch's run history, newest first, with cursor pagination.
Watch id (`watch_...`).
## Tenant scope
Reads are owner-scoped: another tenant's watch id is `404 WATCH_NOT_FOUND`, never `403`. Restricted (`sk_*`) keys always read their own owner. Internal keys can act on behalf of one tenant for watch reads **and writes** with `?owner=` or `X-AISearch-On-Behalf-Of: `; the query parameter wins when both are present. A restricted key's override is ignored.
When an override is applied, the response adds a top-level `owner` field and an `X-AISearch-Owner` header containing the enforced owner id.
## Query parameters
Page size, `1`–`100`.
Opaque cursor from a previous page's `nextCursor`. Omit for the first page.
Internal keys only. Owner id to enforce for this read. Overrides the
`X-AISearch-On-Behalf-Of` header when both are sent.
## Request
```bash cURL
curl "https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e/runs?limit=20" \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e/runs?limit=20',
{ headers: { Authorization: `Bearer ${process.env.AISEARCH_API_KEY}` } },
);
const page = await res.json();
```
```python Python
import os, requests
res = requests.get(
"https://api.aisearchapi.dev/v1/watches/watch_8f2c1a7e/runs",
params={"limit": 20},
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
)
page = res.json()
```
## Response
```json 200 OK
{
"items": [
{
"id": "wrun_2b7d4e9a",
"watchId": "watch_8f2c1a7e",
"jobId": "job_8t2q",
"scheduledFor": "2026-07-11T14:00:00Z",
"status": "dispatched",
"detail": "search job dispatched",
"createdAt": "2026-07-11T14:00:01Z"
},
{
"id": "wrun_6c3a1f8d",
"watchId": "watch_8f2c1a7e",
"jobId": null,
"scheduledFor": "2026-07-10T14:00:00Z",
"status": "skipped_credits",
"detail": "insufficient credits at dispatch time",
"createdAt": "2026-07-10T14:00:01Z"
}
],
"nextCursor": null
}
```
Scheduled attempts, newest first.
Run id (`wrun_...`).
Owning watch id.
Created parent job, or `null` when skipped.
Tick this run represents, ISO-8601.
Run outcome.
Human-readable elaboration.
Run-row creation time, ISO-8601.
Pass as `?cursor=` to fetch the next page. `null` on the last page.
## Run statuses
| Status | Meaning |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- |
| `dispatched` | A search job was created and `jobId` is set. Read it like any other async job. |
| `skipped_credits` | Balance was insufficient at dispatch time; nothing was billed. Three consecutive skips pause the watch. |
| `skipped_limit` | An account or plan ceiling prevented dispatch; nothing was billed. |
| `failed` | The scheduled attempt failed to submit. A captured-but-empty or failed job is still `dispatched` with a `jobId`. |
## Errors
No watch exists for the given id, or it belongs to another tenant. The API
does not expose another tenant's watch or run history.
```json 404
{
"code": "WATCH_NOT_FOUND",
"error": "no watch with id 'watch_8f2c1a7e'",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#watch_not_found"
}
```
## Related
Scheduling, lifecycle, billing, and run outcomes.
Read a watch with its ten newest runs.
Inspect a dispatched run's parent job.
Receive signed deliveries from dispatched runs.
---
# ChatGPT
Source: https://docs.aisearchapi.dev/api-reference/surfaces/chatgpt
> Capture answers from the real ChatGPT app and normalize them into the canonical Envelope.
Capture the answer ChatGPT gives for a query and receive it in the same canonical [Envelope](/guides/output-formats) every surface returns.
Captures come from the **real ChatGPT app** in a live browser, not a sanitized
model API. You get what a person would actually see.
| Field | Value |
| ------------------- | ------------------------------- |
| Surface enum | `chatgpt` |
| Credits per capture | **5** (charged only on success) |
| Alias endpoint | `POST /v1/search/chatgpt` |
## Capture ChatGPT
### With `POST /v1/search`
Pass `surfaces: ["chatgpt"]`. A single-surface request with no webhook runs **synchronously by default** — a `200` with the finished Envelope inline. Add `?mode=async` for the durable `202` + poll path:
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best noise-cancelling headphones under $300",
"surfaces": ["chatgpt"],
"regions": [{ "country": "US" }]
}'
```
```json 202 Accepted
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.chatgpt.us"]
}
```
Fetch the child Envelope once it's done with `GET /v1/jobs/job_8t2q.chatgpt.us`. See [Synchronous](/guides/synchronous) and [Asynchronous](/guides/asynchronous).
### With the alias `POST /v1/search/chatgpt`
The per-surface alias targets ChatGPT only — sync by default like any single-surface call — and accepts `prompt` (an alias of `query`) and a flat `country`.
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search/chatgpt \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best noise-cancelling headphones under $300",
"country": "US"
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search/chatgpt', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'best noise-cancelling headphones under $300',
country: 'US',
}),
});
const result = await res.json();
const envelope = result.children[0];
```
## Request fields
The prompt to run — a non-empty string. On the alias endpoint you may send
`prompt` instead.
For this page, `["chatgpt"]`. Only used with `POST /v1/search` — the alias
infers the surface from the path.
`{ country, state?, city?, language? }[]`, `country` as an ISO-3166 alpha-2 code, at most 10 per request. Omitted: one untargeted (`GLOBAL`) capture. See [Regions](/guides/regions).
## The Envelope
ChatGPT returns the **same canonical Envelope** as every other surface, so one parser handles them all. Below it's trimmed; the real result always has four sections (`job`, `provenance`, `answer`, `evidence`). For the full schema, see [Output formats](/guides/output-formats).
```json Envelope (trimmed)
{
"job": {
"id": "job_8t2q.chatgpt.us",
"query": "best noise-cancelling headphones under $300",
"surface": "chatgpt",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-27T00:00:00.000Z",
"completedAt": "2026-06-27T00:00:03.120Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "gpt-5-3",
"inferred": true,
"confidence": 0.6
},
"webSearch": { "enabled": true, "known": true },
"triggerState": "search",
"persona": "anonymous_default_model",
"loginState": "logged_out",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-27T00:00:03.000Z",
"callUuid": "call_e9e13810ec46f8bbb814497766d1d05c",
"schemaVersion": "2026-06-27"
},
"answer": {
"text": "For under $300, the Sony WH-1000XM5 and Bose QuietComfort Ultra lead on noise cancellation...",
"markdown": "For under $300, the **Sony WH-1000XM5** and **Bose QuietComfort Ultra** lead on noise cancellation...",
"blocks": [
{
"type": "paragraph",
"text": "The Sony WH-1000XM5 offers class-leading ANC.",
"referenceIds": [0]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.sony.com",
"title": "Sony WH-1000XM5",
"role": "cited",
"cited": true,
"charRanges": [[10, 25]]
},
{
"id": 1,
"url": "https://www.bose.com",
"title": "Bose QuietComfort Ultra",
"role": "cited",
"cited": true,
"charRanges": [[30, 52]]
}
],
"fanOut": {
"provenance": "observed",
"queries": ["best noise cancelling headphones under 300 2026"]
},
"mentions": null,
"shopping": null,
"ads": null
}
}
```
Plain-text answer.
Markdown rendering of the answer. Always populated.
Structured blocks, each typed (`paragraph`, `heading`, `list`, `code`,
`quote`) with `text` and `referenceIds`.
Cited and retrieved sources, each `{ id, url, title, role, cited, charRanges }`.
`id` is an integer that `answer.blocks[].referenceIds` point to.
The follow-up web searches behind the answer. ChatGPT surfaces these, so
`provenance` is `"observed"` and `queries` is populated. `mentions`,
`shopping`, and `ads` are reserved and currently `null`.
Which model produced the answer: `{ providerId, observedLabel, inferred,
confidence }`.
`true` when ChatGPT returned an answer; `false` when it returned nothing.
If ChatGPT returns nothing for a query, `provenance.surfacePresent` is `false`
and a `surface_absent` warning is attached. The job still completes with an
empty `answer` — absence is a valid result, and it costs no credits.
Because the Envelope is identical across surfaces, code written for ChatGPT
works unchanged for Claude, Perplexity, Copilot, and the rest. See [Output
formats](/guides/output-formats).
Full request and response schema for `POST /v1/search`.
The canonical Envelope every surface returns.
---
# Claude
Source: https://docs.aisearchapi.dev/api-reference/surfaces/claude
> Capture Claude's answer for any prompt and receive it as the same canonical Envelope every surface returns.
Capture what **Claude** (Anthropic) answers for a prompt and receive it as the canonical [Envelope](/guides/output-formats). Claude typically returns a prose answer with provenance on how it was captured.
**How Claude is captured today:** unlike our browser-first surfaces, the
claude.ai consumer app is login-walled, so `auto` routes Claude through the
official Anthropic model lane for now. How a capture was acquired is an
internal detail — the Envelope's `provenance` is lane-free and does not expose
a capture method. Live browser capture of the Claude UI is on the roadmap. See
[`GET /v1/surfaces`](/guides/surfaces) for the capability matrix.
| Property | Value |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| `surfaces` value | `claude` |
| Provider | Anthropic |
| Capture lane | Official Anthropic model (internal — not exposed in the Envelope; browser capture on the roadmap) |
| Credits per capture | **3** |
| Commonly returns | `answer`, `provenance` |
## Request
Add `"claude"` to `surfaces` on [`POST /v1/search`](/api-reference/search). A single-surface request with no webhook runs **synchronously by default** (a `200` with the Envelope inline). Add `?mode=async` for the durable path — `202` with a parent job and one child per surface × region, read via [`GET /v1/jobs/:id`](/api-reference/get-job) or a [webhook](/guides/webhooks).
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "what is retrieval augmented generation",
"surfaces": ["claude"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search?mode=async', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'what is retrieval augmented generation',
surfaces: ['claude'],
regions: [{ country: 'US' }],
}),
});
const job = await res.json();
console.log(res.status, job); // 202
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
params={"mode": "async"},
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "what is retrieval augmented generation",
"surfaces": ["claude"],
"regions": [{"country": "US"}],
},
)
print(res.status_code, res.json()) # 202
```
### Response `202 Accepted`
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.claude.us"]
}
```
Read the dotted child id with `GET /v1/jobs/job_8t2q.claude.us` to fetch its Envelope.
## Per-surface alias
`POST /v1/search/claude` targets Claude only — sync by default like any single-surface call. It accepts two convenience fields: `prompt` (alias of `query`) and `country` (flat sugar for `regions: [{ "country": "..." }]`). It returns `200` with the result inline.
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search/claude \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "what is retrieval augmented generation",
"country": "US"
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search/claude', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'what is retrieval augmented generation',
country: 'US',
}),
});
const result = await res.json();
console.log(res.status, result.children[0].answer.markdown); // 200
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search/claude",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={"prompt": "what is retrieval augmented generation", "country": "US"},
)
print(res.status_code, res.json()["children"][0]["answer"]["markdown"]) # 200
```
## Envelope
A completed Claude child returns the canonical four-section Envelope. Below is a trimmed example. `answer.markdown` is always populated, and `answer.blocks[]` carry the answer as typed blocks.
```json
{
"job": {
"id": "job_8t2q.claude.us",
"query": "what is retrieval augmented generation",
"surface": "claude",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-30T17:02:11.000Z",
"completedAt": "2026-06-30T17:02:26.000Z"
},
"provenance": {
"model": {
"providerId": "claude-sonnet-4-5",
"observedLabel": "Claude",
"inferred": false,
"confidence": 0.97
},
"webSearch": { "enabled": false, "known": true },
"triggerState": "parametric",
"persona": "anonymous_default_model",
"loginState": "unknown",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-30T17:02:26.000Z",
"callUuid": "call_5c0b0d3f9a7e4b1c8d2e6f4a1b3c5d7e",
"schemaVersion": "2026-06-27"
},
"answer": {
"text": "Retrieval augmented generation (RAG) combines a language model with a retrieval step...",
"markdown": "**Retrieval augmented generation (RAG)** combines a language model with a retrieval step...",
"blocks": [
{
"type": "paragraph",
"text": "Retrieval augmented generation (RAG)...",
"referenceIds": []
}
]
},
"evidence": {
"sources": [],
"fanOut": { "provenance": "none", "queries": [] },
"mentions": null,
"shopping": null,
"ads": null
}
}
```
See [The Envelope](/guides/output-formats) for every field, and how `text`, `markdown`, and `blocks` relate.
If Claude returns nothing for a prompt, the job still completes:
`provenance.surfacePresent` is `false`, `job.warnings` carries a
`surface_absent` warning, and `answer` is empty. An empty capture costs no
credits.
## Credits
Each successful Claude capture costs **3 credits** — priced for the official-api lane (no browser session), the full Envelope, every field included. Credits are charged only on success; failed or empty captures are free. See [Credits](/guides/credits).
## Regions
Request Claude in specific locations with `regions`. One child is created per surface × region, and each child's `provenance.region` reports the `requested` vs `effective` location if it could not be honored exactly.
```json
{
"query": "best project management tools",
"surfaces": ["claude"],
"regions": [
{ "country": "US" },
{ "country": "GB", "city": "London" },
{ "country": "DE", "language": "de" }
]
}
```
This fans out to `job_.claude.us`, `job_.claude.gb:london`, and `job_.claude.de:de`. See [Regions](/guides/regions).
Full request body, sync variants, and idempotency.
Every field in the canonical per-surface result.
What's live today and what's coming next.
What each surface costs and when you are charged.
---
# Perplexity
Source: https://docs.aisearchapi.dev/api-reference/surfaces/perplexity
> Capture Perplexity answers as the same canonical Envelope every surface returns.
Capture what **Perplexity** answers for a query. Captures are pulled from the live Perplexity app in a real browser, then normalized into the same canonical Envelope every surface returns, so the parser you write here works everywhere.
`perplexity`
**3 credits** — charged only on success.
Every surface normalizes to the **same Envelope**. See [Output
formats](/guides/output-formats).
## Request
The simplest way to capture one Perplexity result is the alias `POST /v1/search/perplexity` — sync by default like any single-surface call — which accepts `prompt` (an alias of `query`) and a flat `country`.
```bash cURL (alias)
curl https://api.aisearchapi.dev/v1/search/perplexity \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "What are the best noise-canceling headphones in 2026?",
"country": "US"
}'
```
```bash cURL (canonical endpoint)
curl https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "What are the best noise-canceling headphones in 2026?",
"surfaces": ["perplexity"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search/perplexity', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'What are the best noise-canceling headphones in 2026?',
country: 'US',
}),
});
const result = await res.json();
const envelope = result.children[0];
console.log(envelope.answer.markdown);
console.log(envelope.provenance.surfacePresent);
```
Both single-surface calls above run synchronously by default and return `200` with the parent id, rollup status, and the Envelope in `children[0]`. Add `?mode=async` for the durable `202` + poll path.
### Body fields
Your prompt, a non-empty string. On the `/v1/search/perplexity` alias you may
send it as `prompt` instead.
Must include `"perplexity"`. On the alias endpoint the surface is fixed by the
path, so you omit it.
One capture per surface × region, at most 10 regions per request. Each entry is `{ country, state?, city?, language? }`; `country` is an ISO-3166 alpha-2 code. Omitted: one untargeted (`GLOBAL`) capture. On the alias endpoint use the flat `country` field instead.
## Response (trimmed Envelope)
A Perplexity capture returns the canonical four-section Envelope. Below it is trimmed to show the shape.
```json
{
"job": {
"id": "job_8t2q.perplexity.us",
"query": "What are the best noise-canceling headphones in 2026?",
"surface": "perplexity",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-27T00:00:00.000Z",
"completedAt": "2026-06-27T00:00:04.500Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "Perplexity",
"inferred": true,
"confidence": 0.6
},
"webSearch": { "enabled": true, "known": true },
"triggerState": "search",
"persona": "anonymous_default_model",
"loginState": "logged_out",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-27T00:00:04.000Z",
"callUuid": "call_7a1f2e3d4c5b6a7988990a1b2c3d4e5f",
"schemaVersion": "2026-06-27"
},
"answer": {
"text": "For 2026, the standout noise-canceling headphones are the Sony WH-1000XM6 and the Bose QuietComfort Ultra...",
"markdown": "For 2026, the standout noise-canceling headphones are the **Sony WH-1000XM6** and the **Bose QuietComfort Ultra**...",
"blocks": [
{
"type": "paragraph",
"text": "The Sony WH-1000XM6 leads on active noise cancellation.",
"referenceIds": [0, 1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.sony.com",
"title": "Sony WH-1000XM6",
"role": "cited",
"cited": true,
"charRanges": [[46, 61]]
},
{
"id": 1,
"url": "https://www.bose.com",
"title": "Bose QuietComfort Ultra",
"role": "cited",
"cited": true,
"charRanges": [[70, 92]]
}
],
"fanOut": { "provenance": "none", "queries": [] },
"mentions": null,
"shopping": null,
"ads": null
}
}
```
Always populated. Use this as your rendering source of truth. `answer.text`
carries the plain-text variant, and `answer.blocks[]` carry the answer as
typed blocks.
Cited and retrieved sources, each `{ id, url, title, role, cited, charRanges }`;
`id` is the integer that `answer.blocks[].referenceIds` point to. Perplexity is
captured on our own fleet, so `evidence.fanOut` is `{ "provenance": "none",
"queries": [] }` — no fan-out is claimed. `mentions`, `shopping`, and `ads` are
reserved and currently `null`.
Which model produced the answer: `{ providerId, observedLabel, inferred,
confidence }`.
When `false` (paired with a `surface_absent` warning), Perplexity returned
nothing for this prompt. The job still completes with an empty answer, and you
are not charged.
Need many surfaces or regions at once? Use the async `POST /v1/search`
(multi-surface, or `?mode=async`), which returns a parent `jobId` plus one
child per surface × region. See [Asynchronous captures](/guides/asynchronous).
## Related
The full Envelope: job, provenance, answer, and evidence.
The canonical `POST /v1/search` endpoint and every body field.
Capture Perplexity from multiple countries and languages.
How the sync default and the per-surface alias return results inline.
---
# Gemini
Source: https://docs.aisearchapi.dev/api-reference/surfaces/gemini
> Google Gemini is a recognized surface with no live capture path yet — here's its current status, what a request returns today, and what's next.
**Gemini** (`gemini`) is part of the surface enum, but it has **no live capture path in v1**. It returns a `422` rather than a stand-in that isn't the real Gemini experience.
Submitting `surfaces: ["gemini"]` today returns **`422
UNSUPPORTED_METHOD_FOR_SURFACE`**. This is deliberate: nothing we currently
offer clears the bar for a trustworthy Gemini capture (see below).
| Property | Value |
| ------------------- | --------------------------------- |
| `surfaces` value | `gemini` |
| Provider | Google |
| Status | **Not yet requestable** |
| Listed credit price | 4 per capture (applies once live) |
## Why there's no v1 path
Every capture we serve must faithfully represent either the vendor's official API or the real consumer experience — never a sanitized stand-in. Gemini doesn't clear that bar yet:
- Google's Gemini API terms bar resale of its output, so serving it through Google's own API isn't on the table.
- The Gemini web app is login-walled: every guest capture hits a login stop. Serving it faithfully needs an authenticated account pool, which isn't provisioned yet.
You can confirm this at any time from the live capability matrix:
```bash
curl https://api.aisearchapi.dev/v1/surfaces
```
```json Excerpt
{
"id": "gemini",
"live": false
}
```
## What a request returns today
```bash
curl -X POST https://api.aisearchapi.dev/v1/search \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "query": "best crm for startups", "surfaces": ["gemini"] }'
```
```json 422 Unprocessable Entity
{
"code": "UNSUPPORTED_METHOD_FOR_SURFACE",
"error": "auto routing has no supported v1 method for surface 'gemini'; surface 'gemini' has no supported v1 method",
"request_id": "req_9f2c1a7e4b5d48f1a3c6e8d0b2a4f6c8",
"docs_url": "https://docs.aisearchapi.dev/guides/errors#unsupported_method_for_surface"
}
```
The rejection happens at admission — nothing is spawned and nothing is billed. On a [batch](/api-reference/batch), only the Gemini item is rejected; other items proceed.
## Cover Google's AI answers today
While Gemini is pending, Google's AI-generated answers are live through two other surfaces:
The AI summary at the top of Google Search — live via real browser
(`google_ai_overview`).
Google's conversational AI search experience — live via real browser
(`google_ai_mode`).
## When it goes live
Gemini support lands once an authenticated account pool is provisioned. When it does, you'll request it like any other surface — `surfaces: ["gemini"]`, same request body, same canonical [Envelope](/guides/output-formats) — with no integration changes. Watch the [changelog](/changelog) or poll `GET /v1/surfaces` for the flip.
What's live today and what's coming next.
The 422 you'll see if you request Gemini early.
---
# Copilot
Source: https://docs.aisearchapi.dev/api-reference/surfaces/copilot
> Capture Microsoft Copilot answers from the live app and receive them as the same canonical Envelope every surface returns.
Capture what **Microsoft Copilot** actually shows a user for a prompt — from the live app in a real browser — and receive it as the canonical [Envelope](/guides/output-formats).
| Property | Value |
| ------------------- | ------------------------------- |
| `surfaces` value | `copilot` |
| Provider | Microsoft |
| Credits per capture | **5** (charged only on success) |
| Alias endpoint | `POST /v1/search/copilot` |
Every surface normalizes to the **same** Envelope, so the parser you write
here handles ChatGPT, Perplexity, and the rest unchanged.
## Request
Add `"copilot"` to `surfaces` on [`POST /v1/search`](/api-reference/search). A single-surface request with no webhook runs **synchronously by default** (a `200` with the Envelope inline). Add `?mode=async` for the durable path:
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best crm for startups",
"surfaces": ["copilot"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search?mode=async', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best crm for startups',
surfaces: ['copilot'],
regions: [{ country: 'US' }],
}),
});
const job = await res.json();
console.log(res.status, job); // 202
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
params={"mode": "async"},
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best crm for startups",
"surfaces": ["copilot"],
"regions": [{"country": "US"}],
},
)
print(res.status_code, res.json()) # 202
```
### Response `202 Accepted`
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.copilot.us"]
}
```
Read the dotted child id with [`GET /v1/jobs/job_8t2q.copilot.us`](/api-reference/get-job) to fetch its Envelope, or receive it via [webhook](/guides/webhooks) on its terminal state.
## Per-surface alias
`POST /v1/search/copilot` targets Copilot only — sync by default like any single-surface call — and accepts `prompt` (an alias of `query`) plus a flat `country`:
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search/copilot \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best crm for startups",
"country": "US"
}'
```
## Envelope (trimmed)
A completed Copilot child returns the canonical four-section Envelope:
```json
{
"job": {
"id": "job_8t2q.copilot.us",
"query": "best crm for startups",
"surface": "copilot",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-27T00:00:00.000Z",
"completedAt": "2026-06-27T00:00:03.900Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "Copilot",
"inferred": true,
"confidence": 0.6
},
"webSearch": { "enabled": true, "known": true },
"triggerState": "search",
"persona": "anonymous_default_model",
"loginState": "logged_out",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-27T00:00:03.000Z",
"callUuid": "call_2b4d6f8a0c1e3f5a7b9d0c2e4f6a8b0d",
"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": "For startups, the top CRMs are HubSpot, Attio and Pipedrive.",
"referenceIds": [0, 1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.hubspot.com",
"title": "HubSpot",
"role": "cited",
"cited": true,
"charRanges": [[31, 38]]
},
{
"id": 1,
"url": "https://attio.com",
"title": "Attio",
"role": "cited",
"cited": true,
"charRanges": [[40, 45]]
}
],
"fanOut": { "provenance": "none", "queries": [] },
"mentions": null,
"shopping": null,
"ads": null
}
}
```
`answer.markdown` is always populated; `answer.blocks[]` carry the answer as
typed blocks.
Records how the capture ran: the observed model label, requested vs effective
region, and other capture context.
If Copilot returns nothing for a prompt, the child still completes:
`provenance.surfacePresent` is `false`, `job.warnings` carries a
`surface_absent` warning, and `answer` is empty. An empty capture costs no
credits.
Full request and response schema for `POST /v1/search`.
Every field in the canonical per-surface result.
Poll a parent or fetch a child Envelope.
Get each child's Envelope pushed on its terminal state.
---
# Google AI Overview
Source: https://docs.aisearchapi.dev/api-reference/surfaces/google-ai-overview
> Capture the AI Overview block that Google Search shows above the classic results, normalized to the canonical AI Search Envelope.
**Google AI Overview** is the AI-generated summary Google renders at the top of a Search results page. The API captures that block from a real browser — the live Google Search experience — and returns it as the same [Envelope](/guides/output-formats) every other surface produces.
| | |
| ------------------- | ------------------------------------ |
| `surfaces` value | `google_ai_overview` |
| Alias path | `POST /v1/search/google_ai_overview` |
| Credits per capture | **5** (charged only on success) |
| Provider | Google |
## What it returns
The AI Overview capture normalizes into the same canonical Envelope:
- `answer.text` / `answer.markdown` — the Overview summary. `markdown` is always populated.
- `answer.blocks[]` — structured blocks, each typed with `text` and `referenceIds`.
- `provenance.model` — the observed model label and confidence.
- `provenance.surfacePresent` — whether the Overview returned an answer.
- `provenance.region` — requested vs. effective region.
- `evidence.sources[]` — the cited/retrieved sources `answer.blocks[].referenceIds` point to. This is an own-fleet surface, so `evidence.fanOut` is `{ "provenance": "none", "queries": [] }`.
Every surface normalizes to the **same** Envelope. Whether you capture Google
AI Overview, ChatGPT, or Perplexity, you parse one shape, so the code below
works unchanged across surfaces. See [Output formats](/guides/output-formats).
## Request
Add `google_ai_overview` to `surfaces` on a standard [`POST /v1/search`](/api-reference/search). A single-surface request with no webhook runs **synchronously by default** (a `200` with the Envelope inline); add `?mode=async` for the durable fan-out path — one child per surface × region, returning `202` with a parent job.
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best running shoes for flat feet",
"surfaces": ["google_ai_overview"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node
const res = await fetch('https://api.aisearchapi.dev/v1/search?mode=async', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best running shoes for flat feet',
surfaces: ['google_ai_overview'],
regions: [{ country: 'US' }],
}),
});
const job = await res.json();
console.log(res.status, job); // 202
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
params={"mode": "async"},
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={
"query": "best running shoes for flat feet",
"surfaces": ["google_ai_overview"],
"regions": [{"country": "US"}],
},
)
print(res.status_code, res.json()) # 202
```
The `202` carries the parent job and one dotted child id — read it with [`GET /v1/jobs/:id`](/api-reference/get-job) or receive it on a [webhook](/guides/webhooks).
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.google_ai_overview.us"]
}
```
## Per-surface alias
`POST /v1/search/google_ai_overview` targets this one surface — sync by default like any single-surface call. It holds the connection open and returns the result inline with `200` (the Envelope in `children[0]`), no polling. It accepts two convenience fields: `prompt` (alias of `query`) and `country` (flat sugar for `regions: [{ "country": "..." }]`).
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search/google_ai_overview \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best running shoes for flat feet",
"country": "US"
}'
```
```javascript Node
const res = await fetch(
'https://api.aisearchapi.dev/v1/search/google_ai_overview',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'best running shoes for flat feet',
country: 'US',
}),
},
);
const result = await res.json();
console.log(res.status, result.children[0].answer.markdown); // 200
```
```python Python
import os
import requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search/google_ai_overview",
headers={
"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}",
"Content-Type": "application/json",
},
json={"prompt": "best running shoes for flat feet", "country": "US"},
)
print(res.status_code, res.json()["children"][0]["answer"]["markdown"]) # 200
```
## Envelope
A trimmed capture, the same four sections (`job`, `provenance`, `answer`, `evidence`) you get from every surface.
```json
{
"job": {
"id": "job_8t2q.google_ai_overview.us",
"query": "best running shoes for flat feet",
"surface": "google_ai_overview",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-30T17:02:11.000Z",
"completedAt": "2026-06-30T17:02:26.000Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "AI Overview",
"inferred": true,
"confidence": 0.6
},
"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:26.000Z",
"callUuid": "call_9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a",
"schemaVersion": "2026-06-27"
},
"answer": {
"text": "For flat feet, look for stability shoes with firm midsole support like the Brooks Adrenaline GTS and ASICS Gel-Kayano...",
"markdown": "For flat feet, look for **stability shoes** with firm midsole support like the **Brooks Adrenaline GTS** and **ASICS Gel-Kayano**...",
"blocks": [
{
"type": "paragraph",
"text": "For flat feet, look for stability shoes...",
"referenceIds": [0, 1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.brooksrunning.com",
"title": "Brooks Adrenaline GTS",
"role": "cited",
"cited": true,
"charRanges": [[70, 91]]
},
{
"id": 1,
"url": "https://www.asics.com",
"title": "ASICS Gel-Kayano",
"role": "cited",
"cited": true,
"charRanges": [[96, 112]]
}
],
"fanOut": { "provenance": "none", "queries": [] },
"mentions": null,
"shopping": null,
"ads": null
}
}
```
If Google shows no Overview for the query, the child still completes:
`provenance.surfacePresent` is `false`, `job.warnings` carries a
`surface_absent` warning, and `answer` is empty. An empty capture costs no
credits.
`answer.markdown` is always populated, and `answer.blocks[]` carry the answer
as typed blocks. See [Output formats](/guides/output-formats) for turning this
into rendered text or structured data.
The full enum and how fan-out works.
Every field in the canonical per-surface result.
---
# Google AI Mode
Source: https://docs.aisearchapi.dev/api-reference/surfaces/google-ai-mode
> Capture Google's conversational AI Mode as the same canonical Envelope every surface returns.
Google **AI Mode** is Google's conversational search experience: a follow-up-friendly, chat-style answer built on top of Search. Captures come from the live experience in a real browser, then normalize into the exact same Envelope every other surface returns, so one parser reads AI Mode, ChatGPT, Perplexity, and the rest.
**Enum:** `google_ai_mode` · **Credits:** 4 per successful capture ·
**Typically returns:** a conversational `answer` with `provenance`
## What it returns
| Section | Google AI Mode |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `answer.text` / `answer.markdown` | Conversational answer (`markdown` always populated) |
| `answer.blocks[]` | Structured blocks, each typed with `text` and `referenceIds` |
| `provenance.model` | The observed model label and confidence |
| `provenance.surfacePresent` | Whether AI Mode returned an answer |
| `provenance.region` | Requested vs. effective region |
| `evidence.sources[]` | Cited/retrieved sources `answer.blocks[].referenceIds` point to (own-fleet, so `evidence.fanOut` is `none`) |
Everything maps to the same fields, so your rendering logic never branches per
surface. See [Output formats](/guides/output-formats) for how `text`,
`markdown`, and `blocks` relate.
## Request
Add `google_ai_mode` to `surfaces` on [`POST /v1/search`](/api-reference/search). A single-surface request with no webhook runs **synchronously by default**; add `?mode=async` for the durable path — one child per surface × region.
```bash cURL
curl -X POST "https://api.aisearchapi.dev/v1/search?mode=async" \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "best noise-cancelling headphones under $300",
"surfaces": ["google_ai_mode"],
"regions": [{ "country": "US" }]
}'
```
```javascript Node.js
const res = await fetch('https://api.aisearchapi.dev/v1/search?mode=async', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: 'best noise-cancelling headphones under $300',
surfaces: ['google_ai_mode'],
regions: [{ country: 'US' }],
}),
});
const { jobId, children } = await res.json();
console.log(jobId, children);
```
```python Python
import os, requests
res = requests.post(
"https://api.aisearchapi.dev/v1/search",
params={"mode": "async"},
headers={"Authorization": f"Bearer {os.environ['AISEARCH_API_KEY']}"},
json={
"query": "best noise-cancelling headphones under $300",
"surfaces": ["google_ai_mode"],
"regions": [{"country": "US"}],
},
)
print(res.json())
```
The async call returns `202` with a parent job and one child id per surface × region:
```json
{
"jobId": "job_8t2q",
"status": "processing",
"children": ["job_8t2q.google_ai_mode.us"]
}
```
Fetch the child id (dotted) once it completes to get the Envelope:
```bash
curl https://api.aisearchapi.dev/v1/jobs/job_8t2q.google_ai_mode.us \
-H "Authorization: Bearer $AISEARCH_API_KEY"
```
## Sync alias
For a single surface and region, the alias `POST /v1/search/google_ai_mode` is sync-by-default (like any single-surface call) and returns the result inline (`200`, the Envelope in `children[0]`). It accepts `prompt` (an alias of `query`) and a flat `country`.
```bash cURL
curl -X POST https://api.aisearchapi.dev/v1/search/google_ai_mode \
-H "Authorization: Bearer $AISEARCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "best noise-cancelling headphones under $300",
"country": "US"
}'
```
```javascript Node.js
const res = await fetch(
'https://api.aisearchapi.dev/v1/search/google_ai_mode',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.AISEARCH_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: 'best noise-cancelling headphones under $300',
country: 'US',
}),
},
);
const result = await res.json();
console.log(result.children[0].answer.markdown);
```
## Envelope (trimmed)
Every surface returns this shape, four sections: `job`, `provenance`, `answer`, `evidence`.
```json
{
"job": {
"id": "job_8t2q.google_ai_mode.us",
"query": "best noise-cancelling headphones under $300",
"surface": "google_ai_mode",
"region": "us",
"status": "completed",
"warnings": [],
"requestedAt": "2026-06-27T00:00:00.000Z",
"completedAt": "2026-06-27T00:00:05.100Z"
},
"provenance": {
"model": {
"providerId": null,
"observedLabel": "AI Mode",
"inferred": true,
"confidence": 0.6
},
"webSearch": { "enabled": true, "known": true },
"triggerState": "search",
"persona": "anonymous_default_model",
"loginState": "logged_out",
"surfacePresent": true,
"region": { "requested": "US", "effective": "US" },
"capturedAt": "2026-06-27T00:00:05.000Z",
"callUuid": "call_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d",
"schemaVersion": "2026-06-27"
},
"answer": {
"markdown": "For most people under $300, the **Sony WH-1000XM5** leads on cancellation and comfort, while the **Bose QuietComfort** is the pick if you want a lighter clamp.",
"text": "For most people under $300, the Sony WH-1000XM5 leads on cancellation and comfort, while the Bose QuietComfort is the pick if you want a lighter clamp.",
"blocks": [
{
"type": "paragraph",
"text": "The Sony WH-1000XM5 leads on cancellation and comfort.",
"referenceIds": [0]
},
{
"type": "paragraph",
"text": "The Bose QuietComfort is the pick for a lighter clamp.",
"referenceIds": [1]
}
]
},
"evidence": {
"sources": [
{
"id": 0,
"url": "https://www.sony.com",
"title": "Sony WH-1000XM5",
"role": "cited",
"cited": true,
"charRanges": [[22, 37]]
},
{
"id": 1,
"url": "https://www.bose.com",
"title": "Bose QuietComfort",
"role": "cited",
"cited": true,
"charRanges": [[92, 109]]
}
],
"fanOut": { "provenance": "none", "queries": [] },
"mentions": null,
"shopping": null,
"ads": null
}
}
```
If `provenance.surfacePresent` is `false` and the child carries a
`surface_absent` warning, AI Mode returned nothing for that query. The job
still completes with an empty `answer`. Parse it the same way and check
`surfacePresent` before rendering.
## Notes
Each successful `google_ai_mode` capture costs **4 credits**, charged only on success. You start with 500 free credits.
Pass `regions: [{ country, state?, city?, language? }]` (ISO-3166 alpha-2; omitted means one untargeted `GLOBAL` capture). Each surface × region produces one child. Compare `provenance.region.requested` with `effective` to see whether the region was honored exactly. See [Regions](/guides/regions).
AI Mode, ChatGPT, Claude, Perplexity, Copilot, Google AI Overview, Google Search, and Google News all normalize to this Envelope. Write your rendering once against `answer` and every surface flows through it.
How `answer.text`, `answer.markdown`, and `answer.blocks[]` relate.
The full async request body, fields, and lifecycle.
---
# Changelog
Source: https://docs.aisearchapi.dev/changelog
> Product updates and release notes for the AI Search API.
Notable changes to the **AI Search API HTTP contract**, newest first. For the broader product changelog — new surfaces, dashboard, and pricing — see the canonical [product changelog at aisearchapi.dev/changelog](https://aisearchapi.dev/changelog).
**Auto Extract (BETA)**
- `POST /v1/search`, the per-surface alias, and batch items now accept `extract: true` or `extract: { targets: [...] }` for answer-derived brand mention extraction.
- `evidence.mentions.items[]` now reports `brand`, `domain`, `mentioned`, `position`, `sentiment`, `linked`, `target`, `sourceIds`, and `snippet`. Pinned targets remain visible when absent; no result is fabricated.
- Auto Extract costs 0 credits during BETA. `extraction_failed` and `extraction_skipped` warnings report extraction-only degradation while the capture still completes normally.
**Watches**
- Scheduled query subscriptions save a query, surfaces, regions, extraction settings, cadence, and optional webhook. Six endpoints cover create, list, get, update, soft-cancel, and cursor-paginated run history.
- Plans enforce active-watch ceilings and minimum intervals. Three consecutive insufficient-credit skips automatically pause a watch; resuming clears the pause reason and schedules the next run from the current time.
- Creating a watch is free. Each dispatched run bills per surface × region like a normal async search under the same success-only billing; skipped runs cost nothing.
**MCP server**
- `https://api.aisearchapi.dev/mcp` provides stateless Streamable HTTP MCP with API-key authentication.
- Ten tools cover search, jobs, surfaces, usage, watches, and watch run history with the same auth, credits, tenancy, and errors as the REST API.
**Initial public release.**
The first public version of the AI Search API is here. Capture what the live AI apps actually show — browser-first — and parse every surface with one shape.
**Search**
- `POST /v1/search` — **sync by default for a single surface with no webhook** (one `200` with the finished result inline). Multi-surface requests, requests with a `webhook`, or `?mode=async` fan out durably: `202 { jobId, status: "processing", children: [...] }`, with one child per surface × region.
- Force the inline path with `?mode=sync` or the `Prefer: wait=30` header; simplify the shape with `?view=flat`.
- Alias `POST /v1/search/:surface` (for example `/v1/search/chatgpt`) that accepts `prompt` (alias of `query`) and a flat `country`.
- `POST /v1/search/batch` — up to 500 independent items, validated per item, with an atomic whole-batch credit reservation.
**Surfaces**
- Live at launch: `chatgpt`, `claude`, `perplexity`, `copilot`, `google_ai_overview`, `google_ai_mode`, `google_search`, and `google_news`. The API always auto-routes each surface to its best available capture path.
- `gemini` is in the enum but has no v1 capture path yet (submitting it returns a `422`).
- `GET /v1/surfaces` — live capability matrix: every surface and its current status.
**One Envelope for every surface**
- The canonical Envelope normalizes every surface into the same four sections (`job`, `provenance`, `answer`, and `evidence`), so you write one parser and it works everywhere.
- `answer.markdown` is always populated, alongside `answer.text` and `answer.blocks[]`.
- Provenance on every capture: `model`, `webSearch`, `surfacePresent`, `region`, and a single `schemaVersion` stamp — lane-free, with no internal driver or normalizer versions exposed.
- Opt-in proof-of-page: request `include.html: true` on a consumer-UI (scrape) capture to get a top-level `html` URL, fetched via `GET /v1/artifacts/:key`. The verbatim upstream capture itself is internal and is not customer-fetchable.
**Jobs**
- `GET /v1/jobs/:id` — poll a parent (rollup plus children) or fetch a child Envelope by its dotted three-segment id (for example `job_8t2q.chatgpt.us`).
- `GET /v1/jobs` (list, cursor-paginated) and `POST /v1/jobs/:id/cancel`.
**Webhooks**
- HMAC-SHA256-signed delivery (over the raw body bytes, `X-AISearch-Signature` header) via `webhook: { url, secret }`, with SSRF-guarded destinations. Omit `secret` to sign with your account's managed `whsec_` secret.
**Regions**
- Per-request `regions: [{ country, state?, city?, language? }]` (ISO-3166 alpha-2 country; omitted means one untargeted `GLOBAL` capture; at most 10 regions per request).
- `provenance.region` reports `requested` vs `effective` when a region can't be honored exactly. `GET /v1/regions` for discovery.
**Idempotency**
- Safe retries with an `idempotencyKey` body field — same key + same body replays the original job (`Idempotent-Replayed: true`); a different body returns `409`.
**DX & operations**
- One flat error envelope everywhere: `{ code, error, request_id, docs_url }`, with `X-Request-Id` and `X-AISearch-Version` on every response and live `X-RateLimit-*` / `X-Concurrency-*` admission headers on submits.
- `GET /v1/usage` (per-`(surface, region)` ledger + balance), `GET /v1/health`, and `GET /v1/async/status` for an inflight snapshot of your key.
- **Strict request validation.** Unknown top-level fields on a search body are rejected with `400 VALIDATION_FAILED` (named in the message), not silently dropped; `query` is capped at 2000 characters (`422 QUERY_TOO_LONG`).
- **Deep health probe.** `GET /v1/health?probe=deep` runs live round-trips against Postgres, D1, and R2 and returns `503 degraded` if any dependency is down. This is the endpoint uptime monitors should watch.
**Billing**
- Credit-based billing, charged only on successful captures, with **500 free credits** to start.