openapi: 3.1.0
info:
  title: Dataswap API
  version: 0.3.0
  summary: Structured SERP data, billed in credits, with durable idempotency.
  description: |
    The SERP intelligence layer for the AI era.

    **This specification describes what the API actually does today**, not the product roadmap.
    Where the two differ, the difference is annotated as `x-divergence` on the endpoint.

    ## Authentication
    Send your API key as `Authorization: Bearer sk_live_…` (the `X-API-Key` header is also
    accepted). Keys are verified against a salted SHA-256 hash; revoked keys are rejected with
    `401 invalid_api_key`.

    ### Scopes
    Every key carries one or more scopes: `read` (data extraction and consumption), `track`
    (tracking and scheduling — reserved, no endpoints yet) and `admin` (key management under
    `/keys`). A route that requires a missing scope returns `403 insufficient_scope`, **not**
    401: the key is valid and the client should neither discard nor rotate it. The extraction
    endpoints (`/search`, `/news`, `/maps`, `/batch`) and `/usage` require no scope today — a
    valid key is enough.

    ## Credits
    `cost = base × freshness_multiplier + Σ enrichments`, rounded to 2 decimals.

    | operation | base |
    |---|---|
    | `search` | 1 |
    | `news` | 1 |
    | `maps` | 2 |
    | `extract/catalog` | 1 per product extracted |

    AI tools (`/ai/*`), flat price per call:

    | tool | credits | | tool | credits |
    |---|---|---|---|---|
    | `answer` (with `sources`) | 2 | | `review-summary` | 3 |
    | `answer` (without `sources`, runs the search) | 4 | | `keyword-cluster` | 2 |
    | `content-brief` | 3 | | `serp-intent` | 2 |
    | `visibility` | 3 | | `content-gap` | 3 |
    | `serp-diff-explain` | 2 | | `paa-expand` | 2 |
    | `competitor-brief` | 3 | | `summarize` | 2 |
    | | | | `rerank` | 1 |

    | freshness | multiplier | semantics |
    |---|---|---|
    | `cached` | ×0.5 | returns **only** if a cached result exists; on a miss → `404 cache_miss` at **0 credits** |
    | `fresh` (default) | ×1.0 | serves from cache when still valid, otherwise fetches |
    | `live` | ×1.5 | bypasses the cache, always fetches |

    The `enrichments` (`intent` +0.5, `share_of_serp` +1, `ai_answer_visibility` +2) are defined
    in the pricing model but **are not produced yet**: asking for them returns
    `400 enrichments_not_available` rather than charging for something we cannot deliver.

    ### Fixed and variable prices

    **Not every operation costs the same on every call.** Some are priced per request; for others
    the price scales with the size of the request — how many results it returns, or how many
    billable steps the operation has to run for you. `GET /pricing` tells you which is which,
    per operation:

    | field | meaning |
    |---|---|
    | `credits` | the price quoted for the base request, and the **floor**: a call is never charged less |
    | `pricing_model` | `fixed` — the same credits on every call, whatever the size of the request; `variable` — the final charge is settled per call |
    | `varies_with` | `null` when fixed. `result_count` — grows with the number of results returned, so a smaller `limit` costs less. `work_performed` — the operation composes several billable steps and you pay only for the ones that ran |
    | `price_note` | the same information in one sentence, ready to show to a human |

    Do not hard-code a price table: read `GET /pricing`, and read `pricing_model` with it.

    ### Billing model: quote → reserve → settle → refund
    A charge is not a single write, and for a variable operation the final amount is not known
    until the request has run. Every request goes through:

    1. **quote** — the price in `GET /pricing` for that operation. This is what you are billed for
       a fixed operation, and the minimum for a variable one;
    2. **reserve** — a `held` entry in the credit ledger, with the balance already debited (this
       prevents concurrent drain and guarantees we never spend upstream without secured credits).
       For a variable operation we reserve a **ceiling**: the worst plausible case for the request
       you sent, sized from the limit you declared. It is reported in `X-Credits-Reserved`;
    3. **settle** — once the request has run, the amount actually due is charged and the entry
       becomes `committed` in the **same transaction** that seals the idempotency record. That
       amount is `X-Credits-Used`;
    4. **refund** — the unused part of the ceiling goes back to your balance in that same
       transaction. Any failure after the reservation (upstream, cache, sealing) returns the whole
       reservation (`refunded`) and the balance goes back to what it was.

    **The guarantee, and it holds for every operation:** the amount charged is **never below the
    quoted price** and **never above the amount reserved**. Budget against `credits` as your floor
    and against `X-Credits-Reserved` as your worst case for a given request.

    `X-Credits-Reserved` is sent **only when the ceiling differs from what you were charged** —
    on a fixed-price operation the ceiling *is* the quoted price, so the header is simply absent
    and nothing about those calls has changed.

    What this means for you: **a request that fails is not charged**. The cache is read BEFORE
    the reservation — it is free, and it is what determines the hit/miss price.

    ## Idempotency
    Send `Idempotency-Key` on `POST /search`, `/news`, `/maps` and `/batch`. The key is durable
    (24h TTL) and scoped per engine — the same key on `/search` and on `/news` are two distinct
    records.

    - **replay** — same key, same body, request already finished: returns the ORIGINAL response
      in full, with `X-Idempotent-Replay: true` and the original `X-Credits-*`. Not charged again.
    - **reuse** — same key with a different body: `400 idempotency_key_reused`.
    - **in flight** — same key while a concurrent request is still running:
      `409 idempotency_in_flight`. After 120s without a sign of life the request is considered
      dead, the key is taken over and the previous owner's reservations are refunded — there is
      never a double charge.
    - A request that **fails** releases the key immediately, so a legitimate retry can run at once.

    ## Long-running operations (202 + job_id)
    Some operations (Amazon data, reviews, business profiles, app data, and `/commerce/match`)
    take longer than it makes sense to hold an HTTP connection open for. On those, if the result
    does **not** arrive within ~25s:

    1. the API responds **`202 Accepted`** with `job_id`, `status: "processing"` and `poll_url`;
    2. the work **continues** server-side — do not resubmit the request;
    3. poll `GET /jobs/{id}` until `status` is `done` (or `failed`).

    If the result arrives inside the window you get the normal `200` and never notice the
    difference. **The same endpoint may answer `200` or `202`** depending on how long it takes —
    handle both.

    **Credits:** a `202` **does not charge**. Credits stay reserved and are only debited once
    there is a result, which is why `credits_used` is `null` while the job is running, and why a
    failure refunds the reservation. We never charge without delivering.

    The shape of the `202` response is `AsyncHandoff`. **All seventeen endpoints that can return it
    are listed individually** under the Amazon, Business, On-Page, App data and Commerce tags, each
    with both its `200` and its `202`. Everything else — `/search`, `/news`, `/maps`, the AI tools —
    is synchronous and always answers `200`.

    `/commerce/match` is the newest of them, and the one most likely to hand off: it searches
    several shopping surfaces for the same product before reasoning over the candidates, so the
    `202` is the normal path rather than the exception. Its job carries the whole composition —
    you still collect one result, charged once.

    `/extract/catalog` is the one endpoint that is **always** asynchronous: it answers `202` even
    when it is quick.

    ## Official client

    A TypeScript/JavaScript client is available and handles the `202` handoff, idempotent retries
    and typed errors for you:

    ```bash
    npm install dataswap
    # or: npm install https://dataswap.io/sdk/dataswap-latest.tgz
    ```

    ```ts
    import { Dataswap } from 'dataswap';
    const dataswap = new Dataswap({ apiKey: process.env.DATASWAP_API_KEY });
    const serp = await dataswap.search({ q: 'best running shoes', gl: 'us' });
    ```

    ## Rate limiting
    Fixed per-minute window, with `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
    `X-RateLimit-Reset` on every response. On exceeding it, `429 rate_limited` with `Retry-After`.
    The limit is per API key; unauthenticated requests get a lower anonymous limit.
  contact:
    name: Dataswap
    url: https://dataswap.io
servers:
  - url: https://api.dataswap.io/v1
    description: Production
  - url: http://localhost:3001/v1
    description: Local development
security:
  - bearerAuth: []
  - apiKeyHeader: []
tags:
  - name: SERP
    description: Structured SERP extraction (search, news, maps).
  - name: Batch
    description: Asynchronous batches of up to 100 queries.
  - name: Jobs
    description: Polling long-running operations handed off with `202`.
  - name: Extraction
    description: E-commerce catalog extraction, billed per product produced.
  - name: AI
    description: AI tools over SERP and content data.
  - name: Grounding
    description: >-
      Retrieval and extraction primitives for AI agents. Requests are processed inside the EU and
      their content is never sent to third-party model vendors.
  - name: GEO
    description: >-
      Generative Engine Optimization: visibility across AI answer surfaces, not just blue links.
  - name: Commerce
    description: Product matching and digital-shelf intelligence.
  - name: Provenance
    description: >-
      Signed receipts attesting how data was obtained. Public endpoints — auditors are not
      customers.
  - name: Amazon
    description: Amazon marketplace data. Asynchronous — may hand off with `202`.
  - name: Business
    description: Business profiles, hotels and reviews. Asynchronous — may hand off with `202`.
  - name: On-Page
    description: Technical SEO audits. The crawl is asynchronous — expect a `202` on real sites.
  - name: App data
    description: App store data. Asynchronous — may hand off with `202`.
  - name: Account
    description: Consumption and API key management.
  - name: Operations
    description: Liveness and the published contract.

paths:
  /search:
    post:
      tags: [SERP]
      summary: Structured Google Search SERP
      operationId: search
      description: |
        Returns a structured Google Search SERP. Base cost **1 credit**, adjusted by
        `freshness`. This is the only endpoint whose `organic_results` is populated.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SerpRequest' }
            examples:
              minimal:
                summary: Minimal query
                value: { q: "best crm for startups", gl: "us", hl: "en", device: "desktop" }
              cached_only:
                summary: Cache only (0.5 credits; 404 cache_miss if absent)
                value: { q: "best crm for startups", gl: "us", freshness: "cached" }
      responses:
        '200':
          $ref: '#/components/responses/SerpOk'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /news:
    post:
      tags: [SERP]
      summary: Google News
      operationId: news
      description: |
        News SERP. Base cost **1 credit**. Results come in `news_results`; `organic_results`
        **is empty** on this endpoint.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SerpRequest' }
            examples:
              minimal:
                value: { q: "artificial intelligence", gl: "us", num: 3 }
      responses:
        '200':
          $ref: '#/components/responses/SerpOk'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /maps:
    post:
      tags: [SERP]
      summary: Google Maps / Local
      operationId: maps
      description: |
        Local/Maps SERP. Base cost **2 credits**. Results come in `local_results`;
        `organic_results` **is empty** on this endpoint.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SerpRequest' }
            examples:
              minimal:
                value: { q: "coffee shop", location: "Lisbon, Portugal", gl: "pt" }
      responses:
        '200':
          $ref: '#/components/responses/SerpOk'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /shopping:
    post:
      tags: [SERP]
      summary: Google Shopping product results
      operationId: shopping
      description: |
        Google Shopping product results. Base cost **3 credits**, adjusted by `freshness`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [q]
              additionalProperties: true
            example: { "q": "running shoes", "gl": "us" }
      responses:
        '200':
          description: Structured results.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'
  /images:
    post:
      tags: [SERP]
      summary: Google Images results
      operationId: images
      description: |
        Google Images results. Base cost **2 credits**, adjusted by `freshness`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [q]
              additionalProperties: true
            example: { "q": "trail running", "gl": "us" }
      responses:
        '200':
          description: Structured results.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'
  /onpage/instant:
    post:
      tags: [On-Page]
      summary: Instant single-page audit
      operationId: onpageInstant
      description: |
        Instant single-page audit. Base cost **4 credits**, adjusted by `freshness`. Synchronous — no crawl, so no `202`: it audits exactly one page.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              additionalProperties: true
            example: { "url": "https://example.com/" }
      responses:
        '200':
          description: Structured results.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '404':
          $ref: '#/components/responses/CacheMiss'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'
  /pricing:
    get:
      tags: [Account]
      summary: Current price book
      operationId: pricing
      description: |
        Credits per operation, as they are charged right now, plus **whether each price is fixed or
        variable**. Prices track the underlying cost with a margin floor, so they can move — read
        this instead of hard-coding a table.

        `credits` is the price quoted for the base request. For an operation with
        `pricing_model: fixed` that is exactly what every call costs. For `variable` it is the
        **floor**: the call reserves a ceiling (`X-Credits-Reserved`), is charged what it actually
        came to (`X-Credits-Used`), and the difference returns to your balance. See *Billing model*
        in the introduction for the full contract; the `billing` object in this response repeats it
        in a form you can surface to your own users.

        The response deliberately does **not** expose our cost or margin per operation.
      responses:
        '200':
          description: The price book, and the billing contract that applies to it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceBook' }
              example:
                billing:
                  model: quote → reserve a ceiling → settle → refund the difference
                  quoted: >-
                    The `credits` field is the price quoted for the base request. It is also the
                    floor: a call is never charged less than this.
                  reserved: >-
                    Before a request runs we reserve a ceiling from your balance. It is reported in
                    the `X-Credits-Reserved` response header, which is present only when the ceiling
                    differs from what you were finally charged.
                  charged: >-
                    `X-Credits-Used` is what the call actually cost you, settled once the request
                    completed from what it really consumed.
                  refund: >-
                    The difference between the reserved ceiling and the amount charged goes back to
                    your balance in the same transaction that seals the request. A request that
                    fails is not charged at all.
                  guarantee: >-
                    The amount charged is never below the quoted price and never above the amount
                    reserved.
                pricing:
                  - operation: search
                    credits: 1
                    credit_unit_usd: 0.002
                    pricing_model: fixed
                    varies_with: null
                    price_note: >-
                      Fixed: the same number of credits on every call, whatever the size of the
                      request.
                    synced_at: '2026-08-02T07:11:04.512Z'
                  - operation: backlinks_summary
                    credits: 40.08
                    credit_unit_usd: 0.002
                    pricing_model: variable
                    varies_with: result_count
                    price_note: >-
                      Variable: the quoted price covers the base request, and the final charge grows
                      with the number of results the request returns. Send a smaller limit to pay
                      less.
                    synced_at: '2026-08-02T07:11:04.512Z'
                  - operation: context_pack
                    credits: 3
                    credit_unit_usd: 0.002
                    pricing_model: variable
                    varies_with: work_performed
                    price_note: >-
                      Variable: this operation composes several billable steps, and the final charge
                      covers only the steps that actually ran for your request.
                    synced_at: '2026-08-02T07:11:04.512Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /batch:
    post:
      tags: [Batch]
      summary: Create a SERP batch
      operationId: createBatch
      description: |
        Accepts up to 100 queries (any engine) and **starts processing immediately**,
        asynchronously. The response itself is synchronous and carries the `batch_id` to poll
        at `GET /batch/{id}`.

        Before creating the batch the API checks that your balance covers the **total estimated
        cost** (`402 insufficient_credits` if it does not); it then debits item by item as each
        one completes. If the balance runs out midway, the remaining items are marked `failed`
        and processing stops — we do not burn fetches that could never be billed.
      x-divergence: |
        Our product documentation describes this endpoint as returning `202 Accepted` with
        `status: "queued"`. The current implementation returns **`201 Created` with
        `status: "processing"`**, because the batch has already entered execution rather than
        being queued. This specification documents the real behaviour; aligning the two is an
        open decision.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchRequest' }
            examples:
              multi_engine:
                value:
                  queries:
                    - { engine: google_search, q: "best crm", gl: "us" }
                    - { engine: google_news, q: "ai", gl: "us", num: 2 }
                    - { engine: google_maps, q: "restaurant", location: "Porto, Portugal" }
      responses:
        '201':
          description: |
            Batch created and already processing. `status` is always `processing` in this response.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchCreated' }
              example:
                batch_id: 6f1b6f3e-1c1f-4a58-9a20-5f2a1c9e77d1
                status: processing
                total: 3
                estimated_credits: 4
                created_at: "2026-07-19T10:40:12.000Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'

  /batch/{id}:
    get:
      tags: [Batch]
      summary: Batch status and results
      operationId: getBatch
      description: |
        Poll until `status` leaves `processing`. Only returns batches belonging to your own
        account; an `id` from another account (or a malformed one) returns `404 batch_not_found`.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Batch status, including the items already completed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchStatus' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: '`batch_not_found` — no such batch, or it belongs to another account.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
              example:
                error:
                  code: batch_not_found
                  message: "Batch not found."
                  type: not_found
                  request_id: req_8f3c9a12
        '500':
          $ref: '#/components/responses/InternalError'

  /usage:
    get:
      tags: [Account]
      summary: Account usage summary
      operationId: usage
      description: |
        Aggregate over the account's whole history (`window: "all_time"`). The `period`
        parameter is not implemented yet — any query string is ignored.
      responses:
        '200':
          description: Credit and request summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'

  /keys:
    post:
      tags: [Account]
      summary: Create an API key
      operationId: createApiKey
      description: |
        Creates an API key for the authenticated account. Requires the `admin` scope.

        **The secret (`key`) is returned in this response and never again.** We store only a
        salted SHA-256 hash, the `key_prefix` and the last 4 characters; there is no way to
        recover a lost key — only to create another one and revoke this one.

        The secret has the form `sk_<mode>_<48 hex>`. `mode` defaults to `live` when omitted:
        a `test` key and a `live` key are distinct keys over the same account and the same
        balance.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateApiKeyRequest' }
            examples:
              minimal:
                value: { name: "CI pipeline" }
              full:
                value: { name: "Dashboard prod", scopes: [read, track], mode: live }
      responses:
        '201':
          description: Key created. The only response that contains the secret in clear text.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyCreated' }
              example:
                id: 3f6a2c10-9b4e-4d21-8a77-1c5e0b9d4f22
                name: "CI pipeline"
                # Deliberately truncated: an example with the FULL shape of a real key
                # (`sk_live_` + 48 hex) is indistinguishable from a genuine credential in a
                # publicly served file. Our build gate rejects that pattern.
                key: sk_live_1d77...8b1d
                key_prefix: sk_live_1d77
                last4: 8b1d
                scopes: [read]
                mode: live
                created_at: "2026-07-19T10:40:12.000Z"
        '400':
          $ref: '#/components/responses/KeyBadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      tags: [Account]
      summary: List the account's API keys
      operationId: listApiKeys
      description: |
        Lists the authenticated account's keys (most recent first), **including revoked ones** —
        filter on `revoked` client-side if you only want the active keys. Requires the `admin`
        scope.

        **Never returns secrets** — only `key_prefix` + `last4`, for visual identification.

        The response is a **top-level array**, not a wrapper object.
      responses:
        '200':
          description: The account's keys, ordered by `created_at` descending.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyList' }
              example:
                - id: 3f6a2c10-9b4e-4d21-8a77-1c5e0b9d4f22
                  name: "CI pipeline"
                  key_prefix: sk_live_1d77
                  last4: 8b1d
                  scopes: [read]
                  mode: live
                  last_used_at: "2026-07-19T11:02:44.000Z"
                  revoked_at: null
                  revoked: false
                  created_at: "2026-07-19T10:40:12.000Z"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '500':
          $ref: '#/components/responses/InternalError'

  /keys/{id}:
    delete:
      tags: [Account]
      summary: Revoke an API key
      operationId: revokeApiKey
      description: |
        Revokes (sets `revoked_at` on) the given key. Requires the `admin` scope. Revocation is
        immediate: the next request using that key gets `401 invalid_api_key`.

        **You cannot revoke the key that authenticated this very request** — that would leave the
        account with no way to manage itself (`400 cannot_revoke_current_key`). Create the new key
        first, authenticate with it, and only then revoke the old one.

        **Idempotent:** revoking an already-revoked key returns `200` with the original
        `revoked_at`, not `404`. An `id` that does not exist, is malformed, or belongs to another
        account returns `404 key_not_found` (we do not confirm the existence of other accounts'
        keys).
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Key revoked (or already revoked — see idempotency above).
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKeyRevoked' }
              example:
                id: 3f6a2c10-9b4e-4d21-8a77-1c5e0b9d4f22
                revoked_at: "2026-07-19T12:15:03.000Z"
                revoked: true
        '400':
          description: |
            `cannot_revoke_current_key` — the given key is the one that authenticated this request.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
              example:
                error:
                  code: cannot_revoke_current_key
                  message: "You cannot revoke the API key that authenticated this request. Use another key (or create a new one) to revoke this one."
                  type: invalid_request
                  request_id: req_8f3c9a12
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          description: '`key_not_found` — no such key, malformed `id`, or a key from another account.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
              example:
                error:
                  code: key_not_found
                  message: "API key not found."
                  type: not_found
                  request_id: req_8f3c9a12
        '500':
          $ref: '#/components/responses/InternalError'

  /ai/answer:
    post:
      tags: [AI]
      summary: Grounded, cited answer built from SERP results
      operationId: aiAnswer
      description: |
        Produces an answer with `[n]` citations pointing at the sources you supply. If you omit
        `sources`, the API fetches the SERP for `query` first (billed separately).

        Accepts `Idempotency-Key`, like the SERP endpoints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, minLength: 2, maxLength: 500 }
                sources:
                  type: array
                  description: Sources to ground the answer on. Without them, the API fetches the SERP itself.
                  items: { $ref: '#/components/schemas/AiSource' }
                language: { type: string, maxLength: 8 }
                max_words: { type: integer, minimum: 20, maximum: 400 }
                gl: { type: string, maxLength: 8, description: Search country, used when the API fetches the SERP. }
                num: { type: integer, minimum: 1, maximum: 20 }
            example:
              query: best ergonomic chair 2026
              max_words: 150
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/content-brief:
    post:
      tags: [AI]
      summary: Content brief built from the top of the SERP
      operationId: aiContentBrief
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword, results]
              properties:
                keyword: { type: string, minLength: 2, maxLength: 200 }
                results:
                  type: array
                  items: { $ref: '#/components/schemas/AiSerpResult' }
                people_also_ask: { type: array, items: { type: string } }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/visibility:
    post:
      tags: [AI]
      summary: Brand visibility inside AI engine answers (GEO)
      operationId: aiVisibility
      description: |
        Measures whether — and how — a brand shows up in the answers AI engines give to a set of
        prompts. You supply the engine answers yourself in `answers`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brand, answers]
              properties:
                brand: { type: string, minLength: 1, maxLength: 120 }
                competitors: { type: array, items: { type: string } }
                answers:
                  type: array
                  items:
                    type: object
                    required: [engine, answer]
                    properties:
                      engine: { type: string, maxLength: 40 }
                      prompt: { type: string, maxLength: 2000 }
                      answer: { type: string, maxLength: 20000 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/serp-diff-explain:
    post:
      tags: [AI]
      summary: Explains ranking changes and suggests next actions
      operationId: aiSerpDiffExplain
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword, diff]
              properties:
                keyword: { type: string, minLength: 1, maxLength: 300 }
                diff:
                  type: object
                  description: >-
                    Observed changes, grouped by kind. This is exactly the `changes` object returned
                    by `POST /v1/serp/diff` — pass it straight through.
                  properties:
                    added: { type: array, items: { $ref: '#/components/schemas/AiRankChange' } }
                    removed: { type: array, items: { $ref: '#/components/schemas/AiRankChange' } }
                    moved_up: { type: array, items: { $ref: '#/components/schemas/AiRankChange' } }
                    moved_down: { type: array, items: { $ref: '#/components/schemas/AiRankChange' } }
                your_domain: { type: string, maxLength: 253 }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/competitor-brief:
    post:
      tags: [AI]
      summary: Competitive brief built from the SERP
      operationId: aiCompetitorBrief
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query, results]
              properties:
                query: { type: string, minLength: 2, maxLength: 300 }
                results:
                  type: array
                  items: { $ref: '#/components/schemas/AiSerpResult' }
                your_domain: { type: string, maxLength: 253 }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/review-summary:
    post:
      tags: [AI]
      summary: Review digest — themes, sentiment and actions
      operationId: aiReviewSummary
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, reviews]
              properties:
                subject: { type: string, minLength: 1, maxLength: 200 }
                reviews:
                  type: array
                  items:
                    type: object
                    required: [text]
                    properties:
                      text: { type: string, maxLength: 4000 }
                      rating: { type: number }
                      author: { type: string, maxLength: 120 }
                      date: { type: string, maxLength: 40 }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/keyword-cluster:
    post:
      tags: [AI]
      summary: Clusters keywords by topic and intent
      operationId: aiKeywordCluster
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keywords]
              properties:
                keywords: { type: array, items: { type: string } }
                threshold:
                  type: number
                  description: Clustering sensitivity. Higher means tighter clusters.
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/serp-intent:
    post:
      tags: [AI]
      summary: Search intent and SERP features
      operationId: aiSerpIntent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword, results]
              properties:
                keyword: { type: string, minLength: 2, maxLength: 300 }
                results:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { $ref: '#/components/schemas/AiSource' }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/content-gap:
    post:
      tags: [AI]
      summary: Content gaps against your competitors
      operationId: aiContentGap
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [your_topics, competitor_results]
              properties:
                your_topics:
                  type: array
                  minItems: 1
                  maxItems: 200
                  items: { type: string }
                competitor_results:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { $ref: '#/components/schemas/AiSerpResult' }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/paa-expand:
    post:
      tags: [AI]
      summary: Expands People-Also-Ask into grounded answers
      operationId: aiPaaExpand
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [questions]
              properties:
                questions:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string }
                sources:
                  type: array
                  maxItems: 50
                  items: { $ref: '#/components/schemas/AiSource' }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/summarize:
    post:
      tags: [AI]
      summary: Multi-document summary with citations
      operationId: aiSummarize
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [documents]
              properties:
                documents:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string }
                max_words: { type: integer, minimum: 20, maximum: 600 }
                language: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /ai/rerank:
    post:
      tags: [AI]
      summary: Reorders documents by relevance to a query
      operationId: aiRerank
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query, documents]
              properties:
                query: { type: string, minLength: 1, maxLength: 2000 }
                documents:
                  type: array
                  minItems: 1
                  maxItems: 512
                  items: { type: string }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /amazon/products:
    post:
      tags: [Amazon]
      summary: Amazon product search results
      operationId: amazonProducts
      description: |
        Amazon product search results. Base cost **3 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "brake pads", "se_domain": "amazon.com" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /amazon/asin:
    post:
      tags: [Amazon]
      summary: Product detail by ASIN
      operationId: amazonAsin
      description: |
        Product detail by ASIN. Base cost **3 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asin]
              additionalProperties: true
            example: { "asin": "B08N5WRWNW" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /amazon/sellers:
    post:
      tags: [Amazon]
      summary: Sellers offering a product
      operationId: amazonSellers
      description: |
        Sellers offering a product. Base cost **3 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asin]
              additionalProperties: true
            example: { "asin": "B08N5WRWNW" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /amazon/reviews:
    post:
      tags: [Amazon]
      summary: Reviews for a product
      operationId: amazonReviews
      description: |
        Reviews for a product. Base cost **3 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asin]
              additionalProperties: true
            example: { "asin": "B08N5WRWNW" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /apps/search:
    post:
      tags: [App data]
      summary: Google Play app search
      operationId: appsSearch
      description: |
        Google Play app search. Base cost **3 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "car parts" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /business/info:
    post:
      tags: [Business]
      summary: Google Business profile info
      operationId: businessInfo
      description: |
        Google Business profile info. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "IKEA Alfragide" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /business/my-business-updates:
    post:
      tags: [Business]
      summary: Posts and updates from a business profile
      operationId: businessUpdates
      description: |
        Posts and updates from a business profile. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "IKEA Alfragide" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /business/questions-and-answers:
    post:
      tags: [Business]
      summary: Questions and answers on a business profile
      operationId: businessQa
      description: |
        Questions and answers on a business profile. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "IKEA Alfragide" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /business/hotel-searches:
    post:
      tags: [Business]
      summary: Hotel search results
      operationId: businessHotelSearches
      description: |
        Hotel search results. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "hotels lisbon" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /business/hotel-info:
    post:
      tags: [Business]
      summary: Detail for one hotel
      operationId: businessHotelInfo
      description: |
        Detail for one hotel. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hotel_identifier]
              additionalProperties: true
            example: { "hotel_identifier": "ChIJ..." }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /reviews/google:
    post:
      tags: [Business]
      summary: Google reviews for a business
      operationId: reviewsGoogle
      description: |
        Google reviews for a business. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "IKEA Alfragide" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /reviews/trustpilot:
    post:
      tags: [Business]
      summary: Trustpilot reviews for a domain
      operationId: reviewsTrustpilot
      description: |
        Trustpilot reviews for a domain. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              additionalProperties: true
            example: { "domain": "example.com" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /reviews/tripadvisor:
    post:
      tags: [Business]
      summary: Tripadvisor reviews
      operationId: reviewsTripadvisor
      description: |
        Tripadvisor reviews. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url_path]
              additionalProperties: true
            example: { "url_path": "Hotel_Review-g189158" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /search/trustpilot:
    post:
      tags: [Business]
      summary: Search Trustpilot businesses
      operationId: searchTrustpilot
      description: |
        Search Trustpilot businesses. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "car parts" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /search/tripadvisor:
    post:
      tags: [Business]
      summary: Search Tripadvisor entities
      operationId: searchTripadvisor
      description: |
        Search Tripadvisor entities. Base cost **2 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword]
              additionalProperties: true
            example: { "keyword": "lisbon hotels" }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /onpage/summary:
    post:
      tags: [On-Page]
      summary: Technical SEO audit of a site (crawl)
      operationId: onpageSummary
      description: |
        Technical SEO audit of a site (crawl). Base cost **4 credits**.

        ⏳ **May answer `200` or `202`.** If the upstream takes longer than the handoff window you
        get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`. Do not resubmit:
        the work is already running, and a second request is a second job with a second
        reservation. See "Long-running operations" above.
        This one runs a **crawl**, so it is the most likely of all to hand off: a site of any size
        takes longer than the handoff window. The report is only sealed once the crawl has actually
        finished — a partial crawl is never billed as a result.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target]
              additionalProperties: true
            example: { "target": "example.com", "max_crawl_pages": 10 }
      responses:
        '200':
          description: Result available within the handoff window.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            X-Credits-Used: { $ref: '#/components/headers/X-Credits-Used' }
            X-Credits-Remaining: { $ref: '#/components/headers/X-Credits-Remaining' }
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
        '502':
          $ref: '#/components/responses/UpstreamError'

  /jobs/{id}:
    get:
      tags: [Jobs]
      summary: Status of a long-running operation
      operationId: getJob
      description: |
        Polls a job handed off by a `202`. Keep polling until `status` is `done` or `failed`;
        an interval of 2–5s is plenty.

        A `job_id` that does not belong to your account returns **404**, not 403 — we do not even
        confirm that other accounts' resources exist.

        `credits_used` is only populated once the job finishes successfully: until then the
        credits are reserved rather than debited, and a failure refunds them.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current job state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/JobStatus' }
              examples:
                running:
                  summary: Still processing
                  value:
                    job_id: 9f1c7e2a-4b1d-4a3e-8c55-2f6b0d9e1a34
                    status: processing
                    operation: amazon_reviews
                    credits_used: null
                    created_at: '2026-07-28T09:12:03.000Z'
                    finished_at: null
                    result: null
                    error: null
                finished:
                  summary: Finished
                  value:
                    job_id: 9f1c7e2a-4b1d-4a3e-8c55-2f6b0d9e1a34
                    status: done
                    operation: amazon_reviews
                    credits_used: 3
                    created_at: '2026-07-28T09:12:03.000Z'
                    finished_at: '2026-07-28T09:13:48.000Z'
                    result: { items: [] }
                    error: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          description: No such job, or it belongs to another account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
              example:
                error:
                  code: job_not_found
                  message: "Job not found."
                  type: not_found
                  request_id: req_8f3c9a12
        '500':
          $ref: '#/components/responses/InternalError'

  /extract/catalog:
    post:
      tags: [Extraction]
      summary: Extract an e-commerce catalog
      operationId: createCatalogExtraction
      description: |
        Starts a job that discovers product pages on a site (sitemap first, falling back to the
        links on the page you submit) and extracts each one into structured data.

        **Always asynchronous:** responds `202` immediately with the `job_id`; the result is
        collected from `GET /extract/catalog/{jobId}`.

        **Billed per product produced.** On creation we reserve `max_products` credits (the
        ceiling); at the end the reservation is refunded in full and you are charged for exactly
        the number of products extracted. A job that produces nothing costs nothing.
      security:
        - bearerAuth: []
        - apiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CatalogExtractRequest' }
            example:
              url: https://example-store.com/
              max_products: 50
      responses:
        '202':
          description: Job accepted. Poll `/extract/catalog/{jobId}`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogExtractCreated' }
              example:
                job_id: 3c9a1f28-7d64-4e0b-9a12-88b7c1d4e5f6
                status: queued
                max_products: 50
                estimated_credits: 50
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '500':
          $ref: '#/components/responses/InternalError'

  /extract/catalog/{jobId}:
    get:
      tags: [Extraction]
      summary: Status and result of a catalog extraction
      operationId: getCatalogExtraction
      description: |
        `status` moves through `queued` → `running` → `completed` (or `failed`). The result
        document only appears on `completed`; `credits_charged` reflects what was actually
        charged.
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current extraction state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogExtractStatus' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          description: No such job, or it belongs to another account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }
              example:
                error:
                  code: extract_job_not_found
                  message: "Extraction job not found."
                  type: not_found
                  request_id: req_8f3c9a12
        '500':
          $ref: '#/components/responses/InternalError'

  /context:
    post:
      tags: [Grounding]
      summary: Grounding context for an agent, trimmed to a token budget
      description: >-
        Runs one licensed search, then deduplicates, reranks and trims the results to fit exactly
        the token budget you give it. Returns ready-to-inject blocks with citation URLs.
        Reranking is included at no extra cost — you are billed per call, never per token.
        The credit cost varies with how many tokens are actually returned; the amount held during
        the call is reported in `X-Credits-Reserved` and the unused part is refunded on completion.
      operationId: contextPack
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, minLength: 1, maxLength: 400 }
                token_budget: { type: integer, minimum: 256, maximum: 32000, default: 4000 }
                depth: { type: integer, minimum: 1, maximum: 20, default: 10 }
                mode:
                  type: string
                  enum: [snippet, deep]
                  default: snippet
                  description: '`deep` fetches full page content for the top results and costs more.'
                gl: { type: string, maxLength: 8 }
                hl: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /extract/schema:
    post:
      tags: [Grounding]
      summary: Fill your own JSON Schema from the web, with per-field citations
      description: >-
        Give it a JSON Schema and either a query or a list of URLs. It returns the schema filled
        in, plus one entry per field with the source it came from and a confidence score.
        Fields with no supporting evidence come back `null` — never invented.
      operationId: extractSchema
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [json_schema]
              properties:
                json_schema: { type: object, description: 'A JSON Schema object describing what you want.' }
                query: { type: string, minLength: 1, maxLength: 400 }
                urls:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items: { type: string, format: uri }
                model_tier:
                  type: string
                  enum: [standard, premium]
                  default: standard
                  description: '`premium` uses a larger model for deep or ambiguous schemas and costs double.'
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /geo/answer-landscape:
    post:
      tags: [GEO]
      summary: Who gets cited across search and AI answers, for one query
      description: >-
        Runs the organic SERP, the AI Overview, optionally AI Mode, and a set of LLM answers for
        the same query, then reports share-of-answer per domain across every surface, how much the
        engines agree, and where they diverge. All counting and percentages are computed in code,
        not by a model. If one surface is unavailable the call still succeeds with `partial: true`.
      operationId: geoAnswerLandscape
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, minLength: 1, maxLength: 400 }
                engines:
                  type: array
                  maxItems: 6
                  items: { type: string }
                  description: 'AI engines to poll. Each one adds cost.'
                include_ai_mode: { type: boolean, default: false }
                gl: { type: string, maxLength: 8 }
                hl: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /entity:
    post:
      tags: [GEO]
      summary: Everything about a domain, captured at one instant
      description: >-
        Runs backlinks, technologies, rank, reviews and whois for a domain in parallel and returns
        a single synthesized card with citations. Every signal carries the same timestamp, which
        is what separates this from stitching several dashboards together by hand.
        Expect 10-20 seconds. Unavailable signals are reported rather than faked.
      operationId: entityCard
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain]
              properties:
                domain: { type: string, minLength: 3, maxLength: 253 }
                include:
                  type: array
                  maxItems: 8
                  items:
                    type: string
                    enum: [backlinks, tech, rank, reviews, whois]
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /commerce/match:
    post:
      tags: [Commerce]
      summary: Decide whether offers are the same product
      description: |
        Finds candidate offers for a product across shopping and marketplace results and returns a
        verdict per candidate - same, variant, bundle or different - with the evidence behind it.
        Matching by identifier is resolved without a model; only ambiguous candidates are reasoned
        over, which is what keeps the price flat as your catalog grows.

        ⏳ **May answer `200` or `202`.** This endpoint fans out to more than one shopping surface,
        and each surface is a long-running upstream task, so it often exceeds the handoff window.
        When it does you get a `202` with a `job_id` — poll `GET /jobs/{id}` until it is `done`.
        Do not resubmit: the work is already running, and a second request is a second job with a
        second reservation. See "Long-running operations" above.

        Asking for a single surface in `targets` is both cheaper and usually faster than the
        default, which searches all of them.
      operationId: commerceMatch
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product]
              properties:
                product:
                  type: object
                  required: [title]
                  properties:
                    title: { type: string, minLength: 2, maxLength: 400 }
                    brand: { type: string, maxLength: 120 }
                    gtin: { type: string, maxLength: 32 }
                    attrs: { type: object }
                targets:
                  type: array
                  maxItems: 2
                  items: { type: string, enum: [shopping, amazon] }
                limit: { type: integer, minimum: 1, maximum: 50, default: 20 }
                gl: { type: string, maxLength: 8 }
                hl: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '202':
          description: |
            Handed off. The work continues server-side; collect it from `poll_url`.
            **No credits were charged** — they are reserved until there is a result, and refunded
            if the job fails. The body you collect from `GET /jobs/{id}` is exactly the `200` body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AsyncHandoff' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /commerce/shelf:
    post:
      tags: [Commerce]
      summary: Share of digital shelf, including what AI answers cite
      description: >-
        For a set of category keywords, measures where your brand shows up in shopping results and
        organic search, how your prices sit against the shelf, and - the part no shelf tool covers -
        which products and stores the AI Overview actually cites. Metrics are computed in code; the
        model only writes the reading of them.
      operationId: commerceShelf
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keywords, brand]
              properties:
                keywords:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string, maxLength: 200 }
                brand: { type: string, minLength: 1, maxLength: 120 }
                competitors:
                  type: array
                  maxItems: 10
                  items: { type: string, maxLength: 120 }
                gl: { type: string, maxLength: 8 }
                hl: { type: string, maxLength: 8 }
      responses:
        '200': { $ref: '#/components/responses/AiOk' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /commerce/shelf/history:
    get:
      tags: [Commerce]
      summary: How your shelf moved over time
      description: >-
        The time series of every shelf measurement this account has run for a brand: shelf share,
        price position against the market, organic presence, and how often AI answers cited the
        brand - plus which domains they cited instead. Each capture is one point, taken from the
        metrics of the `POST /v1/commerce/shelf` call that produced it, so the series starts the
        first time you call that endpoint and grows on every call after it.


        **This read is free.** It serves data this account already paid for and calls no external
        source, so it costs 0 credits and never touches your balance. Only your own captures are
        visible: the account is taken from the API key, never from a parameter.
      operationId: commerceShelfHistory
      parameters:
        - name: brand
          in: query
          required: true
          description: >-
            Brand to read the series for. Matched in normalized form, so `Nike`, `nike` and
            `NIKE` all resolve to the same series.
          schema: { type: string, minLength: 1, maxLength: 120 }
        - name: keyword
          in: query
          required: false
          description: >-
            Narrow the series to a single keyword. Without it, each point aggregates every keyword
            measured in that capture.
          schema: { type: string, minLength: 1, maxLength: 200 }
        - name: from
          in: query
          required: false
          description: Start of the window, inclusive (ISO-8601 date or timestamp).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: false
          description: End of the window, inclusive (ISO-8601 date or timestamp).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          required: false
          description: >-
            How many captures to return, counting back from the most recent. Points always come
            out in chronological order.
          schema: { type: integer, minimum: 1, maximum: 365, default: 30 }
      responses:
        '200':
          description: The series, oldest point first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  brand: { type: string }
                  keyword: { type: string, nullable: true }
                  from: { type: string, format: date-time, nullable: true }
                  to: { type: string, format: date-time, nullable: true }
                  points:
                    type: array
                    items:
                      type: object
                      properties:
                        captured_at: { type: string, format: date-time }
                        request_id:
                          type: string
                          description: The call that produced this point - the same id as its signed provenance receipt.
                        gl: { type: string }
                        hl: { type: string }
                        keywords: { type: integer, description: Keywords measured in this capture. }
                        shopping_ok:
                          type: integer
                          description: Shelves actually observed. Percentages use this as the denominator, so a failed surface never reads as lost share.
                        serp_ok: { type: integer }
                        ai_overview_present: { type: integer, description: Keywords where an AI Overview was served. }
                        failed_surfaces: { type: integer, description: Surfaces that failed in this capture - same meaning as `coverage.failed_surfaces` in the live response. }
                        shelf_share_pct: { type: number, nullable: true }
                        organic_presence_pct: { type: number, nullable: true }
                        ai_citation_rate: { type: number, nullable: true }
                        avg_price_rank: { type: number, nullable: true, description: 1 = cheapest offer on the shelf. }
                        avg_price_percentile: { type: number, nullable: true, description: 0 = cheapest on the shelf, 100 = most expensive. }
                        brand_avg_price: { type: number, nullable: true }
                        market_median_price: { type: number, nullable: true }
                        index_vs_market: { type: number, nullable: true, description: Brand price divided by market median. Above 1 = priced above the shelf. }
                        currency: { type: string, nullable: true }
                        keywords_priced: { type: integer }
                        cited_domains:
                          type: array
                          description: Domains the AI answers cited in this capture, most widespread first.
                          items:
                            type: object
                            properties:
                              domain: { type: string }
                              keywords: { type: integer }
                  change:
                    type: object
                    nullable: true
                    description: >-
                      Difference between the first and the last point of the window. `null` when
                      there are fewer than two captures, and each field is `null` when either end
                      is missing - a delta against nothing would be an invented number.
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                      captures: { type: integer }
                      shelf_share_pct: { type: number, nullable: true }
                      organic_presence_pct: { type: number, nullable: true }
                      ai_citation_rate: { type: number, nullable: true }
                      brand_avg_price: { type: number, nullable: true }
                      index_vs_market: { type: number, nullable: true }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '500': { $ref: '#/components/responses/InternalError' }

  /provenance/public-key:
    get:
      tags: [Provenance]
      summary: Public key and data-sourcing policy
      description: >-
        Public on purpose - whoever verifies a receipt is a legal or audit team, not a customer.
        Also served at `/.well-known/dataswap-provenance.json`.
      operationId: provenancePublicKey
      security: []
      responses:
        '200':
          description: Key and policy
          content:
            application/json:
              schema: { type: object }
        '500': { $ref: '#/components/responses/InternalError' }

  /provenance/verify:
    post:
      tags: [Provenance]
      summary: Verify a signed provenance receipt
      description: >-
        Confirms a receipt was issued by this API and, if you also send the payload, that the
        receipt belongs to that exact content. A receipt is a signed self-attestation, not a
        third-party audit or certification.
      operationId: provenanceVerify
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [receipt]
              properties:
                receipt: { type: object }
                payload: {}
      responses:
        '200':
          description: Verification result
          content:
            application/json:
              schema: { type: object }
        '400': { $ref: '#/components/responses/BadRequest' }
        '500': { $ref: '#/components/responses/InternalError' }

  /openapi.yaml:
    get:
      tags: [Operations]
      summary: This specification
      operationId: openapiSpec
      description: |
        Returns this document, so a client or code generator can fetch the contract straight from
        the API instead of having to know where the website keeps it. Unauthenticated — the
        contract is readable before you have a key.

        The same file is served as a static asset at `https://dataswap.io/openapi.yaml`, and
        rendered for humans at `https://dataswap.io/reference`.
      security: []
      responses:
        '200':
          description: The OpenAPI 3.1 document, as YAML.
          content:
            application/yaml:
              schema: { type: string }
        '502':
          description: |
            `spec_unavailable` — the specification is not present in this server's bundle. The
            API keeps serving data normally; only this document is missing.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorBody' }

  /health:
    servers:
      - url: https://api.dataswap.io
        description: Production
      - url: http://localhost:3001
        description: Local development
    get:
      tags: [Operations]
      summary: Liveness
      operationId: health
      description: |
        Outside the `/v1` prefix and **unauthenticated**. Pure liveness: it does not touch the
        database or the cache, so a `200` here does not guarantee that dependencies are healthy.
      security: []
      responses:
        '200':
          description: Service is alive.
          content:
            application/json:
              schema:
                type: object
                required: [status, service, time]
                properties:
                  status: { type: string, const: ok }
                  service: { type: string, const: dataswap-api }
                  time: { type: string, format: date-time }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key as a Bearer token (`sk_live_…` / `sk_test_…`)."
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: "Alternative to Bearer, for clients that cannot control the Authorization header."

  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      schema: { type: string, maxLength: 255 }
      description: |
        Makes the request safe to retry. Repeating the same key with the **same body** returns the
        original response without charging again (`X-Idempotent-Replay: true`); with a
        **different** body it returns `400 idempotency_key_reused`; while a request is still in
        flight it returns `409 idempotency_in_flight`. Durable record, 24h TTL, scoped per engine.

  headers:
    X-Request-Id:
      schema: { type: string }
      description: Request identifier, present on every response (including errors).
    X-Credits-Used:
      schema: { type: number }
      description: |
        Credits charged for this request — the amount actually settled, which for a
        `variable` operation is only known once the request has run. Never below the price quoted
        in `GET /pricing`, never above `X-Credits-Reserved`. On an idempotent replay it echoes the
        original request's.
    X-Credits-Reserved:
      schema: { type: number }
      description: |
        The ceiling held from your balance while the request ran. Present **only** when it differs
        from `X-Credits-Used` — that is, only on operations whose price varies with the request
        (`pricing_model: variable` in `GET /pricing`). The difference was returned to your balance
        when the request was sealed.
    X-Credits-Remaining:
      schema: { type: number }
      description: Account balance after this request, with any unused reservation already returned.
    X-Idempotent-Replay:
      schema: { type: string, const: 'true' }
      description: |
        Present **only** when the response is a replay of an earlier request with the same
        `Idempotency-Key`. Absent on requests that were executed afresh.

  responses:
    SerpOk:
      description: |
        Structured SERP. See `SerpResponse` for which arrays each engine populates.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Credits-Used:
          $ref: '#/components/headers/X-Credits-Used'
        X-Credits-Reserved:
          $ref: '#/components/headers/X-Credits-Reserved'
        X-Credits-Remaining:
          $ref: '#/components/headers/X-Credits-Remaining'
        X-Idempotent-Replay:
          $ref: '#/components/headers/X-Idempotent-Replay'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SerpResponse' }

    AiOk:
      description: |
        The tool's result. The field shape varies per tool; `credits_used` and `request_id` are
        always present.
      headers:
        X-Credits-Used:
          schema: { type: string }
          description: Credits debited for this request.
        X-Credits-Remaining:
          schema: { type: string }
          description: Balance after the debit.
        X-Request-Id:
          schema: { type: string }
      content:
        application/json:
          schema:
            type: object
            required: [credits_used, request_id]
            properties:
              credits_used: { type: number }
              request_id: { type: string }
            additionalProperties: true
    BadRequest:
      description: |
        `invalid_request` (body validation), `enrichments_not_available` (enrichments requested
        but not produced yet) or `idempotency_key_reused` (same `Idempotency-Key` with a
        different body).
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          examples:
            invalid_request:
              value:
                error:
                  code: invalid_request
                  message: "q should not be empty"
                  type: invalid_request
                  request_id: req_8f3c9a12
            enrichments_not_available:
              value:
                error:
                  code: enrichments_not_available
                  message: "Enrichments (intent, share_of_serp, ai_answer_visibility) are not available yet — remove the enrichments field from your request."
                  type: invalid_request
                  request_id: req_8f3c9a12
            idempotency_key_reused:
              value:
                error:
                  code: idempotency_key_reused
                  message: "This Idempotency-Key was already used with a different request body. Use a new key per distinct request."
                  type: invalid_request
                  request_id: req_8f3c9a12

    Unauthorized:
      description: '`invalid_api_key` — missing, invalid or revoked key.'
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: invalid_api_key
              message: "Invalid or missing API key."
              type: authentication_error
              request_id: req_8f3c9a12

    InsufficientScope:
      description: |
        `insufficient_scope` — the key authenticated but lacks the scope the endpoint requires
        (the `/keys` endpoints require `admin`). The message lists the missing scopes.

        **Do not discard the key**: it is valid, it just is not allowed to do this operation.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: insufficient_scope
              message: "This API key is missing the scopes required for this operation: admin."
              type: forbidden
              request_id: req_8f3c9a12

    KeyBadRequest:
      description: |
        Body validation for `POST /keys`: `invalid_key_name` (empty or > 80 characters),
        `invalid_scopes` (empty array or unknown scope), `invalid_mode` (not `live`/`test`),
        or `invalid_request` from the validation pipeline.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          examples:
            invalid_scopes:
              value:
                error:
                  code: invalid_scopes
                  message: "Invalid scopes: write. Allowed: read, track, admin."
                  type: invalid_request
                  request_id: req_8f3c9a12
            invalid_key_name:
              value:
                error:
                  code: invalid_key_name
                  message: "The name field is required (1 to 80 characters)."
                  type: invalid_request
                  request_id: req_8f3c9a12

    InsufficientCredits:
      description: '`insufficient_credits` — the balance does not cover the request cost (or, for a batch, the total estimated cost).'
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: insufficient_credits
              message: "Insufficient credits: 1 needed, 0 available."
              type: insufficient_credits
              request_id: req_8f3c9a12

    CacheMiss:
      description: |
        `cache_miss` — you asked for `freshness: "cached"` and there is no cached result for this
        query. **Cost: 0 credits** (we do not run a real fetch and bill it at 0.5×).
        Retry with `freshness: "fresh"` or `"live"`.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: cache_miss
              message: "No cached result for this query. Retry with freshness=fresh or freshness=live."
              type: not_found
              request_id: req_8f3c9a12

    Conflict:
      description: |
        `idempotency_in_flight` — a request with this `Idempotency-Key` is already running.
        Wait for the original response; do not retry immediately with the same key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: idempotency_in_flight
              message: "A request with this Idempotency-Key is already in flight. Wait for the original response."
              type: conflict
              request_id: req_8f3c9a12

    UpstreamError:
      description: |
        `upstream_fetch_failed` — we failed to obtain results from the data source.
        The reserved credits were refunded: **this request was not charged**.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }
          example:
            error:
              code: upstream_fetch_failed
              message: "Failed to fetch results from the data source."
              type: upstream_error
              request_id: req_8f3c9a12

    InternalError:
      description: '`internal_error` — unhandled error. Reserved credits are refunded.'
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorBody' }

  schemas:
    PriceBook:
      type: object
      required: [billing, pricing]
      description: |
        The current price book. `billing` states the contract once — it is the same for every
        operation — and `pricing` carries one entry per operation.
      properties:
        billing:
          $ref: '#/components/schemas/BillingContract'
        pricing:
          type: array
          items: { $ref: '#/components/schemas/PriceBookEntry' }

    BillingContract:
      type: object
      required: [model, quoted, reserved, charged, refund, guarantee]
      description: |
        How a charge is arrived at, in prose you can show to your own users. Stable strings, but
        prose: branch your code on `pricing_model`, not on this text.
      properties:
        model: { type: string, description: The four steps, in order. }
        quoted: { type: string, description: What the `credits` field means, and that it is the floor. }
        reserved: { type: string, description: What is held before the request runs, and where it is reported. }
        charged: { type: string, description: What you actually pay, and when it is decided. }
        refund: { type: string, description: What happens to the unused part, and to a failed request. }
        guarantee: { type: string, description: The two bounds the charge is always inside. }

    PriceBookEntry:
      type: object
      required: [operation, credits, pricing_model, varies_with, price_note, synced_at]
      description: |
        One operation's price. **Read `pricing_model` together with `credits`** — on its own,
        `credits` is the quoted price and not necessarily the amount you will be charged.
      properties:
        operation:
          type: string
          description: Operation identifier, as it appears in your usage records.
          example: backlinks_summary
        credits:
          type: number
          description: |
            Credits quoted for the base request. Exactly what you pay when `pricing_model` is
            `fixed`; the minimum you can pay when it is `variable`.
          example: 40.08
        credit_unit_usd:
          type: [number, 'null']
          description: What one credit was worth in USD when this row was synced.
          example: 0.002
        pricing_model:
          type: string
          enum: [fixed, variable]
          description: |
            `fixed` — the same number of credits on every call, whatever the size of the request.
            `variable` — the charge is settled per call, between the quoted price and the ceiling
            reported in `X-Credits-Reserved`.
        varies_with:
          type: [string, 'null']
          enum: [result_count, work_performed, null]
          description: |
            `null` when `pricing_model` is `fixed`. `result_count` — the charge grows with the
            number of results the request returns, so a smaller `limit` costs less.
            `work_performed` — the operation composes several billable steps and you are charged
            only for the ones that actually ran.
        price_note:
          type: string
          description: The same information in one sentence, ready to show to a human.
        synced_at:
          type: string
          format: date-time
          description: When this row was last refreshed. Prices are re-derived daily.

    SerpRequest:
      type: object
      required: [q]
      additionalProperties: false
      description: |
        Body shared by `/search`, `/news` and `/maps`. Unknown fields are rejected with
        `400 invalid_request` — we do not silently ignore what you send.
      properties:
        q:
          type: string
          minLength: 1
          maxLength: 400
          description: Search query.
        gl:
          type: string
          minLength: 2
          maxLength: 2
          default: us
          description: "Country (ISO-3166 alpha-2), e.g. `pt`."
        hl:
          type: string
          minLength: 2
          maxLength: 5
          default: en
          description: "Language, e.g. `en` or `pt-PT`."
        location:
          type: string
          maxLength: 120
          description: "Canonical location, e.g. `Lisbon, Portugal`."
        device:
          type: string
          enum: [desktop, mobile]
          default: desktop
        num:
          type: integer
          minimum: 1
          maximum: 100
          default: 10
        page:
          type: integer
          minimum: 1
          maximum: 50
          default: 1
        freshness:
          type: string
          enum: [cached, fresh, live]
          default: fresh
          description: |
            `cached` (×0.5) only returns if a cached result exists — on a miss it is
            `404 cache_miss` at 0 credits. `fresh` (×1.0) serves from cache while still valid.
            `live` (×1.5) always fetches.
        enrichments:
          type: array
          description: |
            **Not available yet.** Sending this field with at least one element returns
            `400 enrichments_not_available` — we do not charge for what we cannot deliver yet.
            The array always comes back empty in `search_parameters.enrichments`.
          items:
            type: string
            enum: [intent, share_of_serp, ai_answer_visibility]

    SerpResponse:
      type: object
      required: [search_metadata, search_parameters]
      description: |
        One envelope for every engine. **Only `search_metadata` and `search_parameters` are
        guaranteed.** Which result array is populated depends on the endpoint:

        | endpoint | populated array |
        |---|---|
        | `POST /search` | `organic_results` |
        | `POST /news` | `news_results` |
        | `POST /maps` | `local_results` |

        `organic_results` is **not** guaranteed and must not be assumed: on `/news` and `/maps`
        it comes back empty. Always read the array that matches the engine you called.
      properties:
        search_metadata:
          type: object
          required: [id, status, created_at, engine, credits_used, cached, latency_ms]
          properties:
            id:
              type: string
              description: Same as the `X-Request-Id` header.
            status: { type: string, enum: [success, partial, error] }
            created_at: { type: string, format: date-time }
            engine: { type: string, enum: [google_search, google_news, google_maps] }
            credits_used:
              type: number
              description: Credits actually charged (same as `X-Credits-Used`).
            cached:
              type: boolean
              description: '`true` if the payload came from cache (the cost is already reflected in `credits_used`).'
            latency_ms: { type: integer }
        search_parameters:
          type: object
          required: [q, gl, hl, location, device, num, page, freshness, enrichments]
          description: The request parameters actually used, with defaults already applied.
          properties:
            q: { type: string }
            gl: { type: string }
            hl: { type: string }
            location: { type: [string, 'null'] }
            device: { type: string, enum: [desktop, mobile] }
            num: { type: integer }
            page: { type: integer }
            freshness: { type: string, enum: [cached, fresh, live] }
            enrichments:
              type: array
              description: Always empty until enrichments are produced.
              items: { type: string }
        organic_results:
          type: array
          description: Populated by `POST /search`. Empty on the other engines.
          items: { $ref: '#/components/schemas/OrganicResult' }
        news_results:
          type: array
          description: Populated by `POST /news`.
          items: { $ref: '#/components/schemas/NewsResult' }
        local_results:
          type: array
          description: Populated by `POST /maps`.
          items: { $ref: '#/components/schemas/LocalResult' }
        ads:
          type: array
          items: { $ref: '#/components/schemas/AdResult' }
        people_also_ask:
          type: array
          items: { $ref: '#/components/schemas/PeopleAlsoAskItem' }
        related_searches:
          type: array
          items: { $ref: '#/components/schemas/RelatedSearch' }
        knowledge_graph:
          oneOf:
            - $ref: '#/components/schemas/KnowledgeGraph'
            - type: 'null'

    OrganicResult:
      type: object
      required: [position, title, link, displayed_link, snippet]
      properties:
        position: { type: integer }
        title: { type: string }
        link: { type: string, format: uri }
        displayed_link: { type: string }
        snippet: { type: string }
        sitelinks:
          type: array
          items:
            type: object
            required: [title, link]
            properties:
              title: { type: string }
              link: { type: string, format: uri }

    NewsResult:
      type: object
      required: [position, title, link, source, date]
      properties:
        position: { type: integer }
        title: { type: string }
        link: { type: string, format: uri }
        source: { type: string }
        date: { type: string }
        snippet: { type: string }
        thumbnail: { type: string, format: uri }

    LocalResult:
      type: object
      required: [position, title, address]
      properties:
        position: { type: integer }
        title: { type: string }
        place_id: { type: string }
        address: { type: string }
        rating: { type: number }
        reviews: { type: integer }
        type: { type: string }
        phone: { type: string }
        link: { type: string, format: uri }

    AdResult:
      type: object
      required: [position, title, link, displayed_link, description]
      properties:
        position: { type: integer }
        title: { type: string }
        link: { type: string, format: uri }
        displayed_link: { type: string }
        description: { type: string }

    PeopleAlsoAskItem:
      type: object
      required: [question]
      properties:
        question: { type: string }
        snippet: { type: string }
        link: { type: string, format: uri }

    RelatedSearch:
      type: object
      required: [query]
      properties:
        query: { type: string }
        link: { type: string, format: uri }

    KnowledgeGraph:
      type: object
      required: [title]
      properties:
        title: { type: string }
        type: { type: string }
        description: { type: string }
        source:
          type: object
          properties:
            name: { type: string }
            link: { type: string, format: uri }
        attributes:
          type: object
          additionalProperties: { type: string }

    ErrorBody:
      type: object
      required: [error]
      description: |
        The canonical error envelope, identical across every endpoint and status code.

        | type | HTTP | codes |
        |---|---|---|
        | `invalid_request` | 400 | `invalid_request`, `enrichments_not_available`, `idempotency_key_reused`, `cannot_revoke_current_key`, `invalid_key_name`, `invalid_scopes`, `invalid_mode`, `method_not_allowed` |
        | `authentication_error` | 401 | `invalid_api_key` |
        | `forbidden` | 403 | `insufficient_scope` |
        | `insufficient_credits` | 402 | `insufficient_credits` |
        | `not_found` | 404 | `cache_miss`, `batch_not_found`, `key_not_found`, `route_not_found` |
        | `conflict` | 409 | `idempotency_in_flight` |
        | `rate_limited` | 429 | `rate_limited` |
        | `upstream_error` | 502 | `upstream_fetch_failed` |
        | `internal_error` | 500 | `internal_error` |

        `authentication_error` (401) and `forbidden` (403) are deliberately distinct: on a 403
        the key is valid and **must not be discarded or rotated** by the client — it is only
        missing a scope.
      properties:
        error:
          type: object
          required: [code, message, type, request_id]
          properties:
            code:
              type: string
              description: Stable code, safe to branch on in client logic.
            message:
              type: string
              description: Human-readable message. It may change — do not match on it.
            type:
              type: string
              enum:
                - invalid_request
                - authentication_error
                - forbidden
                - insufficient_credits
                - rate_limited
                - not_found
                - conflict
                - upstream_error
                - internal_error
            request_id:
              type: string
              description: Same as the `X-Request-Id` header. Include it in any support request.

    UsageSummary:
      type: object
      required: [account_id, plan, credits_balance, window, totals, by_operation]
      properties:
        account_id: { type: string, format: uuid }
        plan: { type: string }
        credits_balance: { type: number }
        window:
          type: string
          const: all_time
          description: Only the aggregate window exists; per-month periods are not implemented yet.
        totals:
          type: object
          required: [requests, credits_used, cache_hit_rate, success_rate, avg_latency_ms]
          properties:
            requests: { type: integer }
            credits_used: { type: number }
            cache_hit_rate: { type: number, description: Percentage (0–100). }
            success_rate: { type: number, description: Percentage (0–100). }
            avg_latency_ms: { type: integer }
        by_operation:
          type: array
          items:
            type: object
            required: [operation, requests, credits_used]
            properties:
              operation: { type: string }
              requests: { type: integer }
              credits_used: { type: number }

    BatchRequest:
      type: object
      required: [queries]
      additionalProperties: false
      properties:
        engine:
          type: string
          enum: [google_search, google_news, google_maps]
          default: google_search
          description: Default engine for queries that do not specify one.
        queries:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/BatchQuery' }

    BatchQuery:
      allOf:
        - $ref: '#/components/schemas/SerpRequest'
        - type: object
          properties:
            engine:
              type: string
              enum: [google_search, google_news, google_maps]
              description: Overrides the batch's default engine.

    BatchCreated:
      type: object
      required: [batch_id, status, total, estimated_credits, created_at]
      properties:
        batch_id: { type: string, format: uuid }
        status:
          type: string
          const: processing
          description: |
            Always `processing` — the batch starts immediately. See `x-divergence` on
            `POST /batch`: our product documentation describes `queued`.
        total: { type: integer, description: Number of queries planned. }
        estimated_credits:
          type: number
          description: |
            Total estimated cost, checked against the balance before the batch is created. The
            real cost can be lower (cache hits) and is reported by `GET /batch/{id}`.
        created_at: { type: string, format: date-time }

    BatchStatus:
      type: object
      required: [batch_id, status, total, completed, failed, credits_used, created_at, items]
      properties:
        batch_id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, completed, completed_with_errors, failed]
          description: |
            `completed` if no item failed, `failed` if none succeeded, `completed_with_errors`
            if there were both.
        total: { type: integer }
        completed: { type: integer }
        failed: { type: integer }
        credits_used:
          type: number
          description: Credits actually charged so far (only `done` items).
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        items:
          type: array
          items:
            type: object
            required: [idx, engine, operation, q, status]
            properties:
              idx: { type: integer }
              engine: { type: string, enum: [google_search, google_news, google_maps] }
              operation: { type: string, enum: [search, news, maps] }
              q: { type: string }
              status: { type: string, enum: [pending, done, failed] }
              credits_used: { type: number }
              cached: { type: [boolean, 'null'] }
              result:
                description: |
                  Raw engine payload (`organic_results` / `news_results` / `local_results`, …),
                  without `search_metadata`. `null` while pending.
                oneOf:
                  - $ref: '#/components/schemas/SerpResultPayload'
                  - type: 'null'
              error: { type: [string, 'null'] }

    SerpResultPayload:
      type: object
      description: Results without billing metadata — what the batch stores per item.
      properties:
        organic_results:
          type: array
          items: { $ref: '#/components/schemas/OrganicResult' }
        news_results:
          type: array
          items: { $ref: '#/components/schemas/NewsResult' }
        local_results:
          type: array
          items: { $ref: '#/components/schemas/LocalResult' }
        ads:
          type: array
          items: { $ref: '#/components/schemas/AdResult' }
        people_also_ask:
          type: array
          items: { $ref: '#/components/schemas/PeopleAlsoAskItem' }
        related_searches:
          type: array
          items: { $ref: '#/components/schemas/RelatedSearch' }
        knowledge_graph:
          oneOf:
            - $ref: '#/components/schemas/KnowledgeGraph'
            - type: 'null'

    ApiScope:
      type: string
      enum: [read, track, admin]
      description: |
        `read` — call the extraction endpoints and read usage.
        `track` — tracking and scheduling operations (reserved, no endpoints yet).
        `admin` — manage API keys (`/keys`).

    CreateApiKeyRequest:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 80
          description: |
            Human-readable label so you can identify the key in a listing. It is trimmed;
            a whitespace-only string is rejected (`invalid_key_name`).
        scopes:
          type: array
          minItems: 1
          maxItems: 3
          default: [read]
          description: Duplicates are removed. Omitted → `["read"]`.
          items: { $ref: '#/components/schemas/ApiScope' }
        mode:
          type: string
          enum: [live, test]
          default: live
          description: Determines the secret's prefix (`sk_live_…` / `sk_test_…`).

    ApiKeyCreated:
      type: object
      required: [id, name, key, key_prefix, last4, scopes, mode, created_at]
      description: |
        **Contains the secret in clear text.** It is the only API response that does — store it
        now, there is no way to recover it later.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key:
          type: string
          description: 'The full secret (`sk_<mode>_<48 hex>`), returned exactly once.'
        key_prefix:
          type: string
          description: |
            First 12 characters of the secret (`sk_live_` + 4 hex) — enough to tell keys apart
            in a UI.
        last4:
          type: string
          description: Last 4 characters of the secret, for visual identification.
        scopes:
          type: array
          items: { $ref: '#/components/schemas/ApiScope' }
        mode: { type: string, enum: [live, test] }
        created_at: { type: string, format: date-time }

    ApiKeySummary:
      type: object
      required: [id, name, key_prefix, last4, scopes, mode, last_used_at, revoked_at, revoked, created_at]
      description: Secret-free view, used in listings.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        key_prefix:
          type: string
          description: 'First 12 characters of the secret (`sk_live_` + 4 hex).'
        last4:
          type: [string, 'null']
          description: |
            `null` for keys created before we started storing this field — the plaintext is not
            recoverable, so no backfill was possible. Render `••••` in that case.
        scopes:
          type: array
          items: { $ref: '#/components/schemas/ApiScope' }
        mode: { type: string, enum: [live, test] }
        last_used_at:
          type: [string, 'null']
          format: date-time
          description: '`null` if the key has never authenticated a request.'
        revoked_at:
          type: [string, 'null']
          format: date-time
          description: '`null` while the key is active.'
        revoked:
          type: boolean
          description: UI convenience — equivalent to `revoked_at !== null`.
        created_at: { type: string, format: date-time }

    ApiKeyList:
      type: array
      description: |
        A top-level array (there is no wrapper object), ordered by `created_at` descending and
        including revoked keys.
      items: { $ref: '#/components/schemas/ApiKeySummary' }

    AiSource:
      type: object
      description: A SERP source used to ground the answer.
      properties:
        title: { type: string, maxLength: 300 }
        url: { type: string, maxLength: 2000 }
        snippet: { type: string, maxLength: 4000 }

    AiSerpResult:
      type: object
      description: A SERP result, with headings when available.
      properties:
        title: { type: string, maxLength: 300 }
        url: { type: string, maxLength: 2000 }
        domain: { type: string, maxLength: 253 }
        snippet: { type: string, maxLength: 4000 }
        headings: { type: array, items: { type: string } }

    AiRankChange:
      type: object
      required: [url]
      properties:
        url: { type: string, maxLength: 2000 }
        domain: { type: string, maxLength: 300 }
        previous_rank: { type: integer }
        current_rank: { type: integer }

    AsyncHandoff:
      type: object
      description: |
        The `202` response of a long-running operation. Work continues server-side; the result is
        collected from `poll_url`. The credits shown are **reserved**, not yet debited.
      required: [job_id, status, request_id, credits_reserved, poll_url]
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, const: processing }
        request_id: { type: string }
        credits_reserved:
          type: number
          description: Credits reserved. They are only debited once there is a result.
        poll_url:
          type: string
          description: "Path to poll, e.g. `/v1/jobs/9f1c7e2a-…`."

    JobStatus:
      type: object
      required: [job_id, status, operation, credits_used, created_at, finished_at, result, error]
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, done, failed]
          description: |
            `processing` while it runs; `done` with a result; `failed` with `error` populated
            (in which case the reserved credits were refunded).
        operation:
          type: string
          description: "The operation that created the job, e.g. `amazon_reviews`."
        credits_used:
          type: [number, 'null']
          description: |
            `null` until the job finishes — while there is no result there is no debit, so we do
            not report consumption either.
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        result:
          description: The operation's result. `null` until `status` is `done`.
        error: { type: [string, 'null'] }

    CatalogExtractRequest:
      type: object
      required: [url, max_products]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
          description: The http(s) URL of the store, or the page to start discovery from.
        max_products:
          type: integer
          minimum: 1
          maximum: 1000
          description: |
            Ceiling on products to extract — and the number of credits reserved. You are charged
            only for what is actually produced.

    CatalogExtractCreated:
      type: object
      required: [job_id, status, max_products, estimated_credits]
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, const: queued }
        max_products: { type: integer }
        estimated_credits:
          type: number
          description: Reserved ceiling. The final cost is the number of products extracted.

    CatalogExtractStatus:
      type: object
      required: [job_id, status, url, max_products, products_found, products_extracted]
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, completed, failed]
        url: { type: string, format: uri }
        max_products: { type: integer }
        products_found:
          type: integer
          description: Product URLs discovered.
        products_extracted:
          type: integer
          description: Pages actually extracted — this is what gets charged.
        credits_charged:
          type: [number, 'null']
          description: Charged at the end. `null` while the job is still running.
        result:
          type: [object, 'null']
          description: |
            The final document: `{ site, submitted_url, products_found, products_extracted,
            products: [...], generated_at }`. `null` until `completed`.
        error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }

    ApiKeyRevoked:
      type: object
      required: [id, revoked_at, revoked]
      properties:
        id: { type: string, format: uuid }
        revoked_at:
          type: string
          format: date-time
          description: |
            When the key was revoked. On a repeated revocation this returns the **original**
            `revoked_at`, not the time of the second request.
        revoked: { type: boolean, const: true }
