openapi: 3.0.3
info:
  title: Searlo API
  description: >
    Live search results as clean JSON. The search endpoints return Google
    results — web, images, news, video, shopping, maps, scholar, patents and
    the full results page with ads, People Also Ask and AI Overviews — plus
    AI answers (ChatGPT, Copilot, Gemini), rank checks, web data
    (scrape, crawl, map, extract) and account endpoints. Get a free API key
    with 3,000 credits (no card required) at https://dashboard.searlo.tech.
    Not affiliated with or endorsed by Google LLC or any other search engine.
    All trademarks belong to their owners.
  version: "1.2.0"
  contact:
    name: Searlo
    url: https://searlo.tech
    email: hello@searlo.tech
  license:
    name: Proprietary API — see https://searlo.tech/terms
externalDocs:
  description: Full documentation
  url: https://searlo.tech/docs
servers:
  - url: https://api.searlo.tech/api/v1
    description: Production

security:
  - ApiKeyAuth: []
  - BearerAuth: []

tags:
  - name: Web Data
    description: >
      Retrieval. Search finds the pages; these read them — scrape one, map a
      site, crawl it, or extract structured records. A fetched page costs 1
      credit; from 2026-11-01, a page that had to be rendered (render true,
      actions, or the automatic fallback) costs 5, and its `via` says
      `browser`. /map is unchanged. The automatic fallback is for accounts
      that have bought credits.
  - name: Search
    description: Query endpoints. Each successful call deducts credits per the pricing page.
  - name: Account
    description: Balance, usage and capability endpoints. Free — do not consume credits.
  - name: Async
    description: Queue work and collect it later, by polling or webhook.
  - name: Tools
    description: Email verification and the MCP server descriptor.
  - name: SERP
    description: >
      The full Google results page: ads, People Also Ask, related searches,
      knowledge graph and AI Overview, 3 credits per page. From 2026-11-01, a
      request whose AI Overview had to be completed by rendering the page in a
      browser costs 5 more (aioverview.rendered: true).
  - name: Rankings
    description: Where a domain ranks for a keyword, from a live SERP read, with stored history.
  - name: AI Answers
    description: >
      Answers and citations from ChatGPT, Microsoft Copilot and
      Gemini, as their users see them.
  - name: Domain
    description: >
      CURRENTLY UNAVAILABLE (coming soon): these endpoints return 403
      FEATURE_NOT_AVAILABLE (not retryable, not charged) until their keyword
      data source is re-enabled.
      Domain-level SEO reports — keyword footprint, top pages, competitors,
      keyword gap and overview. Every row states its provenance:
      `google_ads_site_seed` (Google Ads' keyword association, no position,
      priced as one flat discovery operation) or `serp_verified` (a live SERP
      Searlo ran, priced per keyword per depth). /domain/top-pages and
      /domain/competitors always run verified.

paths:
  /search/web:
    get:
      tags: [Search]
      summary: Web search
      description: >
        Organic results, knowledge panels, and featured snippets. 1 credit;
        a search that finds nothing, or fails, is not charged. Results are
        served down to about result 101: a page whose first result would come
        after that returns 400 PAGE_OUT_OF_RANGE (errorCode) with maxPage, the
        last page that can be served (11 at limit 10). dateRestrict limits the
        results to a recent window and works with gl and hl; combined with
        search operators in q (site:, inurl:, intitle:, filetype:,
        before:/after:, e-mail addresses, long OR chains) it returns 400
        DATE_FILTER_UNAVAILABLE (errorCode), not charged. At most a limited
        number of web, image, site, advanced and batch searches may run at once
        per account; past it, 429 with code 4203.
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/Limit"
        - name: page
          in: query
          description: Page number. Results are served down to about result 101 (see the description).
          schema: { type: integer, minimum: 1, default: 1 }
        - name: safe
          in: query
          schema: { type: string, enum: [active, "off"] }
        - name: lr
          in: query
          description: "Language restriction, e.g. lang_en."
          schema: { type: string, maxLength: 50 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - $ref: "#/components/parameters/DateRestrict"
        - $ref: "#/components/parameters/CacheBypass"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/images:
    get:
      tags: [Search]
      summary: Image search
      description: >
        Images with their size, thumbnail and the page each is on. The four
        filters (size or imgSize, imgType, imgColorType, imgDominantColor) take
        effect and combine; the ones applied are echoed in searchParameters.
        Google serves about 200 images per query: a page past the last one
        ((page - 1) * limit above 200) returns 400 VALIDATION_ERROR naming the
        last page (21 at limit 10), answered at once and not charged.
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Page"
        - name: size
          in: query
          description: Minimum image size.
          schema: { type: string, enum: [icon, small, medium, large, xlarge, xxlarge, huge] }
        - name: imgSize
          in: query
          description: Another name for size.
          schema: { type: string, enum: [icon, small, medium, large, xlarge, xxlarge, huge] }
        - name: imgType
          in: query
          description: Kind of image.
          schema: { type: string, enum: [photo, face, clipart, lineart, stock, animated] }
        - name: imgColorType
          in: query
          description: Colour treatment; trans is a transparent background.
          schema: { type: string, enum: [color, gray, mono, trans] }
        - name: imgDominantColor
          in: query
          description: Dominant colour.
          schema: { type: string, enum: [black, blue, brown, gray, green, orange, pink, purple, red, teal, white, yellow] }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - $ref: "#/components/parameters/CacheBypass"
      responses:
        "200":
          description: Image results
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ImagesResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/news:
    get:
      tags: [Search]
      summary: News search
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Page"
        - name: dateRestrict
          in: query
          description: >
            Only articles from the last n days, weeks, months or years: d7,
            w2, m1, y1. Articles dated before the window are dropped, so a page
            can hold fewer than limit.
          schema: { type: string, pattern: "^[dwmyDWMY][1-9][0-9]{0,3}$", example: d7 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - $ref: "#/components/parameters/CacheBypass"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /search/videos:
    get:
      tags: [Search]
      summary: Video search
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Page"
        - name: safe
          in: query
          schema: { type: string, enum: [active, "off"] }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /search/site:
    get:
      tags: [Search]
      summary: "Site-restricted search (site: operator)"
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: site
          in: query
          required: true
          description: Domain to restrict results to, e.g. linkedin.com.
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Page"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /search/advanced:
    get:
      tags: [Search]
      summary: Advanced search (dork-style operators)
      description: >
        A query with filters. A date filter (dateRange, timeRange or
        dateRestrict) cannot be combined with site, fileType or search
        operators in q: that request returns 400 DATE_FILTER_UNAVAILABLE
        (errorCode), not charged. exactTerms and excludeTerms work with a date.
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/Limit"
        - name: dateRange
          in: query
          schema: { type: string, enum: [day, week, month, year] }
        - name: timeRange
          in: query
          description: Another name for dateRange.
          schema: { type: string, enum: [day, week, month, year] }
        - $ref: "#/components/parameters/DateRestrict"
        - name: fileType
          in: query
          schema: { type: string, maxLength: 20 }
        - name: exactTerms
          in: query
          schema: { type: string, maxLength: 200 }
        - name: excludeTerms
          in: query
          schema: { type: string, maxLength: 200 }
        - name: site
          in: query
          schema: { type: string }
        - name: siteSearch
          in: query
          description: Another name for site.
          schema: { type: string }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Search]
      summary: Advanced search (dork-style operators, request body form)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, maxLength: 500 }
                limit: { type: integer, minimum: 1, maximum: 10, default: 10 }
                dateRange: { type: string, enum: [day, week, month, year] }
                dateRestrict: { type: string, pattern: "^[dwmyDWMY][1-9][0-9]{0,3}$", description: "d7, w2, m1, y1. Takes precedence over dateRange. Not with site, fileType or operators in the query." }
                fileType: { type: string }
                exactTerms: { type: string }
                excludeTerms: { type: string }
                site: { type: string }
                siteSearch: { type: string, description: "Another name for site." }
                gl: { type: string, maxLength: 5 }
                hl: { type: string, maxLength: 5 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /customsearch/v1:
    get:
      tags: [Search]
      summary: Custom Search JSON API drop-in
      description: >
        Google's Custom Search JSON API request and response, for code moving
        off it before Google switches it off on 2027-01-01. Also served at
        https://api.searlo.tech/customsearch/v1 (Google's own path) and at
        /customsearch/v1/siterestrict; all three run the same search. Takes
        Google's parameter names and answers in Google's shape (kind
        customsearch#search, queries.request / nextPage, searchInformation
        with string totalResults, items; items is left out when nothing
        matched). Errors use Google's envelope ({error:{code, message,
        errors[{message, domain, reason}], status, details}}): 400 for a bad or
        unsupported parameter or a bad key (reason keyInvalid), 403 for no key
        or for an account out of credits (reason dailyLimitExceeded, with the
        top-up link), 429 rateLimitExceeded, 503 backendError. Billed as
        /search/web (searchType=image as /search/images): 1 credit per call,
        whatever num asks for; a call that returns an error is not charged.
        cx is accepted and has no effect (Searlo searches the whole web);
        location is country level only. Full parameter table at
        https://searlo.tech/docs#custom-search-json-api.
      security:
        - CseKeyParam: []
        - ApiKeyAuth: []
        - BearerAuth: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 500 }
        - name: cx
          in: query
          description: Accepted and ignored.
          schema: { type: string }
        - name: num
          in: query
          schema: { type: integer, minimum: 1, maximum: 10, default: 10 }
        - name: start
          in: query
          description: 1-based index of the first result; start + num - 1 may not pass 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 1 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - name: lr
          in: query
          description: One language, lang_xx.
          schema: { type: string }
        - name: cr
          in: query
          description: One country, countryXX.
          schema: { type: string }
        - name: safe
          in: query
          schema: { type: string, enum: [active, "off"] }
        - name: dateRestrict
          in: query
          description: d[n], w[n], m[n] or y[n]. Not with operator parameters or searchType=image.
          schema: { type: string }
        - name: siteSearch
          in: query
          schema: { type: string }
        - name: siteSearchFilter
          in: query
          schema: { type: string, enum: [e, i] }
        - name: exactTerms
          in: query
          schema: { type: string }
        - name: excludeTerms
          in: query
          schema: { type: string }
        - name: orTerms
          in: query
          schema: { type: string }
        - name: fileType
          in: query
          schema: { type: string }
        - name: searchType
          in: query
          schema: { type: string, enum: [image] }
        - name: imgSize
          in: query
          schema: { type: string, enum: [icon, small, medium, large, xlarge, xxlarge, huge] }
        - name: imgType
          in: query
          schema: { type: string, enum: [clipart, face, lineart, stock, photo, animated] }
        - name: imgColorType
          in: query
          schema: { type: string, enum: [color, gray, mono, trans] }
        - name: imgDominantColor
          in: query
          schema: { type: string, enum: [black, blue, brown, gray, green, orange, pink, purple, red, teal, white, yellow] }
        - name: fields
          in: query
          description: Google's partial-response selector, e.g. items(title,link).
          schema: { type: string }
      responses:
        "200":
          description: Google's Custom Search response shape (customsearch#search).
          content:
            application/json:
              schema: { type: object }
        "400": { $ref: "#/components/responses/CseError" }
        "403": { $ref: "#/components/responses/CseError" }
        "429": { $ref: "#/components/responses/CseError" }
        "503": { $ref: "#/components/responses/CseError" }

  /search/suggestions:
    get:
      tags: [Search]
      summary: Autocomplete suggestions
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 10, default: 10 }
        - name: hl
          in: query
          schema: { type: string, minLength: 2, maxLength: 5, default: en }
      responses:
        "200":
          description: Autocomplete suggestions
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      query: { type: string }
                      suggestions: { type: array, items: { type: string } }
                      source: { type: string }
                      timestamp: { type: string, format: date-time }
                  message: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/trending:
    get:
      tags: [Search]
      summary: Trending searches (not available)
      deprecated: true
      description: >
        Not available. The route exists but has no data source behind it: it
        answers 200 with an error object in `data` instead of trending
        queries. Do not build on it.
      parameters:
        - name: region
          in: query
          schema: { type: string, minLength: 2, maxLength: 2, default: US }
      responses:
        "200":
          description: Trending searches for the region
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/batch:
    post:
      tags: [Search]
      summary: Batch search — up to 10 plain-text queries, 1 credit per query
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [queries]
              properties:
                queries:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items: { type: string, minLength: 1, maxLength: 500 }
      responses:
        "200":
          description: Results for each query, in order
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: array, items: { type: object } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /search/shopping:
    get:
      tags: [Search]
      summary: Google Shopping product results
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 55, default: 55 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "503":
          description: Temporarily unavailable (retryable) — no usable results were available within the call.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /credits/balance:
    get:
      tags: [Account]
      summary: Credits available to the account (free)
      description: >
        The credit balance and nothing else about the account. Works with an
        API key (x-api-key, or Authorization: Bearer sk_...) and with a
        dashboard login. Free; like any call it counts against the request
        limits. available is the balance GET /user/profile shows (0 while the
        account is on hold). The answer is never cached.
      responses:
        "200":
          description: The balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CreditsBalance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /credit/balance:
    get:
      tags: [Account]
      summary: Current credit balance, with expiry detail
      description: The fuller view; GET /credits/balance returns the balance alone.
      responses:
        "200":
          description: Account balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      balance: { type: number, example: 2500 }
                      available: { type: number, example: 2500, description: "Credits you can spend now (same as balance)." }
                      isLowBalance: { type: boolean }
                      lastUpdated: { type: string, format: date-time }
                      expiry:
                        type: object
                        description: When the credits expire.
                        properties:
                          policyApplies: { type: boolean }
                          durationDays: { type: integer }
                          totalExpiringAmount: { type: number }
                          nonExpiringAmount: { type: number }
                          nextExpiryAt: { type: string, format: date-time, nullable: true }
                          daysUntilNextExpiry: { type: integer, nullable: true }
                          nextExpiryAmount: { type: number, nullable: true }
                          breakdown: { type: array, items: { type: object, additionalProperties: true } }
                        additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/maps:
    get:
      tags: [Search]
      summary: Google Maps / local business results
      description: >
        Local businesses for a query, with address, rating, review count, hours
        and coordinates. A place name in `q` geocodes on its own; `ll`, `lat`,
        `lng` and `zoom` steer results toward a specific viewport. With none
        of them, `gl` sets where the search looks: the main city of that
        country, for 51 countries. Each place carries `businessStatus`
        (OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY or null),
        `openNow` (true, false or null) and `openState`, Google's state line in
        the language of `hl`. A `lite` answer is never cached: retry for the
        full record.
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 20, default: 20 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 1 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - name: ll
          in: query
          description: "Viewport hint as a Google ll token, e.g. @40.7,-74.0,12z."
          schema: { type: string, maxLength: 64 }
        - name: lat
          in: query
          schema: { type: number, minimum: -90, maximum: 90 }
        - name: lng
          in: query
          schema: { type: number, minimum: -180, maximum: 180 }
        - name: zoom
          in: query
          schema: { type: number, minimum: 1, maximum: 21 }
        - $ref: "#/components/parameters/CacheBypass"
      responses:
        "200":
          description: Local businesses
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MapsResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/places:
    get:
      tags: [Search]
      summary: Google Places / local pack
      description: >
        Local businesses for a query, scoped by a `location` string rather than
        a map viewport. Each result's `featureId` (the `0x…:0x…` place id) is
        what `/search/reviews` takes as `featureId`. Its `cid` holds the same
        hex value, which the reviews `cid` parameter (decimal ids only) does
        not accept.
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: location
          in: query
          description: "Where to search, e.g. Austin, Texas."
          schema: { type: string, maxLength: 200 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 20, default: 20 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 1 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
        - name: ll
          in: query
          description: "Viewport hint as a Google ll token, e.g. @40.7,-74.0,12z."
          schema: { type: string, maxLength: 64 }
        - name: lat
          in: query
          schema: { type: number, minimum: -90, maximum: 90 }
        - name: lng
          in: query
          schema: { type: number, minimum: -180, maximum: 180 }
        - $ref: "#/components/parameters/CacheBypass"
      responses:
        "200":
          description: Local businesses
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlacesResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/reviews:
    get:
      tags: [Search]
      summary: Google reviews for one place
      description: >
        Reviews for a single place, with rating, text, author, date and any
        owner response, plus the place's overall rating and review count.
        Address the place with `placeId`, `featureId` or `cid` from a
        places/maps result, or with `q` to resolve the top match first. A call
        returns the first page of reviews Google serves, about eight, in any
        language; `hasMore` says whether the place has more. Later pages cannot
        be fetched yet. A place Google sent without its reviews answers 503
        REVIEWS_WITHHELD (retryable, not charged).
      parameters:
        - name: placeId
          in: query
          description: "Google Place ID, e.g. ChIJd2ts_zTbmUcRqrBZ97_BxpA."
          schema: { type: string, maxLength: 128 }
        - name: featureId
          in: query
          description: "Place feature id, e.g. 0x808580620f5d41dd:0x13b2c33ee2dfe160."
          schema: { type: string, maxLength: 64 }
        - name: cid
          in: query
          description: Decimal place id, as carried by a Maps share link.
          schema: { type: string, maxLength: 32 }
        - name: q
          in: query
          description: Place name to resolve when no id is held.
          schema: { type: string, maxLength: 500 }
        - name: limit
          in: query
          description: Can only lower the count; a call returns about eight reviews at most.
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - $ref: "#/components/parameters/Gl"
        - name: hl
          in: query
          description: Language, e.g. en. Sets the language of relativeDate.
          schema: { type: string, maxLength: 5 }
      responses:
        "200":
          description: The place and its first page of reviews
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewsResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: >
            code REVIEWS_WITHHELD (retryable true): Google sent the place
            without its reviews although it reports some (reviewCount). Retry
            the request. Not charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /search/place-details:
    get:
      tags: [Search]
      summary: Google place details by Place ID
      description: >
        One place's full record: every field of a maps result, plus Google's
        attribute list (`attributes.payments.requires_cash_only`, service
        options, children, parking…) and the menu (`menu.url`, `menu.sections`
        of items with prices, `menu.photos`), and the same `businessStatus`,
        `openNow` and `openState` as a maps result. Address the place with
        `placeId` (ChIJ…), `featureId`, `cid` or `q`. A `lite` answer is a
        thinner tier and is never cached: retry for the full record.
      parameters:
        - name: placeId
          in: query
          description: "Google Place ID, e.g. ChIJd2ts_zTbmUcRqrBZ97_BxpA."
          schema: { type: string, maxLength: 128 }
        - name: featureId
          in: query
          description: "Place feature id, e.g. 0x4799db34ff6c6b77:0x90c6c1bff759b0aa."
          schema: { type: string, maxLength: 64 }
        - name: cid
          in: query
          description: Decimal place id, as carried by a Maps share link.
          schema: { type: string, maxLength: 32 }
        - name: q
          in: query
          description: Place name to resolve when no id is held.
          schema: { type: string, maxLength: 500 }
        - $ref: "#/components/parameters/Gl"
        - $ref: "#/components/parameters/Hl"
      responses:
        "200":
          description: The place in full
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PlaceDetailsResponse" }
        "400":
          description: >
            VALIDATION_ERROR, or code PLACE_NOT_FOUND (errorCode): the placeId
            names no business, or q matched nothing. Not charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "404":
          description: PLACE_NOT_FOUND (errorCode), Google does not know the id. Not charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/scholar:
    get:
      tags: [Search]
      summary: Google Scholar results
      description: >
        Scholarly results with authors, year, publication, citation count and a
        link to any free PDF. Every result carries `clusterId` (all versions of
        the work) and `citesId` (the papers citing it); pass either back as
        `cluster` or `cites` to walk the citation graph.
      parameters:
        - name: q
          in: query
          description: Search query. Omit only when using `cluster` or `cites`.
          schema: { type: string, maxLength: 500 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 1 }
        - $ref: "#/components/parameters/Hl"
        - name: yearFrom
          in: query
          schema: { type: integer, minimum: 1500, maximum: 2200 }
        - name: yearTo
          in: query
          schema: { type: integer, minimum: 1500, maximum: 2200 }
        - name: sortByDate
          in: query
          description: Sort by date instead of relevance.
          schema: { type: boolean }
        - name: includePatents
          in: query
          description: Include patents in the results. Has no effect when includeCitations is false.
          schema: { type: boolean }
        - name: includeCitations
          in: query
          description: >
            Include Scholar's citation-only records (included by default);
            false leaves them out.
          schema: { type: boolean }
        - name: cluster
          in: query
          description: Fetch every version of one work.
          schema: { type: string, maxLength: 32 }
        - name: cites
          in: query
          description: Fetch the works citing one work.
          schema: { type: string, maxLength: 32 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/patents:
    get:
      tags: [Search]
      summary: Google Patents results
      description: >
        Patents with publication number, inventor, assignee, priority, filing
        and grant dates, figures and a PDF link.
      parameters:
        - $ref: "#/components/parameters/Query"
        - name: limit
          in: query
          description: Rounded down to 10, 25, 50 or 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 10 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 1 }
        - name: sort
          in: query
          description: Default is relevance.
          schema: { type: string, enum: [new, old] }
        - name: country
          in: query
          description: "Issuing authority, e.g. US or US,EP."
          schema: { type: string, maxLength: 40 }
        - name: language
          in: query
          description: Publication language (sent upper-cased).
          schema: { type: string, maxLength: 20 }
        - name: status
          in: query
          schema: { type: string, enum: [GRANT, APPLICATION] }
        - name: type
          in: query
          schema: { type: string, enum: [PATENT, DESIGN] }
        - name: inventor
          in: query
          schema: { type: string, maxLength: 120 }
        - name: assignee
          in: query
          schema: { type: string, maxLength: 120 }
        - name: before
          in: query
          description: "Upper date bound, e.g. priority:20240101."
          schema: { type: string, maxLength: 32 }
        - name: after
          in: query
          description: "Lower date bound, e.g. filing:20200101."
          schema: { type: string, maxLength: 32 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/webpage:
    get:
      tags: [Search]
      summary: Fetch and extract one page
      description: >
        Fetch a URL and return its readable content as markdown, text or html,
        with title, description, author, published date and Open Graph
        metadata. Pages that render client-side are rendered automatically (on
        accounts that have bought credits). 1 credit; from 2026-11-01, 5 when
        the page had to be rendered (`via: browser`). An internationalised
        domain takes its encoded (xn--) form here.
      parameters:
        - name: url
          in: query
          required: true
          schema: { type: string, maxLength: 2048 }
        - name: format
          in: query
          schema: { type: string, enum: [markdown, text, html], default: markdown }
        - name: render
          in: query
          description: true always renders the page, false never renders. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 credits instead of 1.
          schema: { type: boolean }
        - name: onlyMainContent
          in: query
          description: Strip navigation, headers and footers. Default true.
          schema: { type: boolean }
        - name: includeLinks
          in: query
          schema: { type: boolean }
        - name: includeImages
          in: query
          schema: { type: boolean }
        - name: chunk
          in: query
          description: Split the content into overlapping chunks for embedding.
          schema: { type: boolean }
        - name: chunkMaxTokens
          in: query
          schema: { type: integer, minimum: 256, maximum: 16384 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Search]
      summary: Fetch and extract one page (JSON body)
      description: >
        The same as GET, with the fields in a JSON body, plus excludeSelectors:
        CSS selectors to remove before extraction.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, maxLength: 2048 }
                format: { type: string, enum: [markdown, text, html], default: markdown }
                render: { type: boolean }
                onlyMainContent: { type: boolean }
                includeLinks: { type: boolean }
                includeImages: { type: boolean }
                excludeSelectors: { type: array, maxItems: 20, items: { type: string, maxLength: 200 } }
                chunk: { type: boolean }
                chunkMaxTokens: { type: integer, minimum: 256, maximum: 16384 }
                timeout: { type: integer, minimum: 1000, maximum: 60000 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422": { $ref: "#/components/responses/TargetError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /scrape:
    post:
      tags: [Web Data]
      summary: Scrape one page
      description: >
        Fetch a URL and return every requested view of it in one call: markdown,
        plain text, raw html, links, images, headings, tables and the page's own
        JSON-LD, plus title, description, author, published date, canonical URL
        and Open Graph. Pages that render client-side are rendered automatically
        (on accounts that have bought credits). 1 credit; 2 when maxAge or
        cache false forces a live fetch; from 2026-11-01, 4 more when the page
        had to be rendered (`via: browser`).
        The url may be an internationalised domain name (https://www.löschwagen.com),
        fetched, cached and billed under its encoded form; a malformed URL or a
        control character in it is a 400, never charged.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, maxLength: 2048 }
                formats:
                  type: array
                  items:
                    type: string
                    enum: [markdown, text, html, links, images, headings, tables, json_ld]
                render: { type: boolean, description: "Force a browser render (true) or never render (false). From 2026-11-01 a page fetched in a browser costs 4 credits more." }
                onlyMainContent: { type: boolean, default: true }
                chunk: { type: boolean, description: Split content into overlapping chunks }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422": { $ref: "#/components/responses/TargetError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /map:
    post:
      tags: [Web Data]
      summary: List a site's URLs
      description: >
        What exists on a site, before paying to crawl it. Reads robots.txt for
        declared sitemaps, walks them (including sitemap indexes), and falls back
        to the homepage's internal links when a site publishes no sitemap. The
        url may be an internationalised domain name.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, maxLength: 2048 }
                limit: { type: integer, minimum: 1, maximum: 5000, default: 1000 }
                search: { type: string, description: Only URLs containing this text }
                includeSubdomains: { type: boolean }
                sitemapOnly: { type: boolean, description: "Sitemaps only, no link harvesting." }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422": { $ref: "#/components/responses/TargetError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /scrape/batch:
    post:
      tags: [Web Data]
      summary: Batch scrape
      description: >
        Many URLs, one job. Returns a job id immediately; poll it or supply a
        webhook. URLs are normalised and de-duplicated before queueing, so the
        same page written two ways is one fetch and one credit — compare
        `submitted` with `urlCount` in the response. Nothing is discovered:
        traversal options are ignored, because you already named every URL.
        Every page is fetched live at 1 credit (5 from 2026-11-01 for a page
        that needed a browser): `maxAge`, `cache`,
        `includeLinks` and `excludeSelectors` are accepted but have no effect
        on a batch, and only markdown, text, html, links and json_ld are
        stored. Status, results, cancellation and billing all use the crawl
        endpoints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [urls]
              properties:
                urls:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: string, maxLength: 2048 }
                formats:
                  type: array
                  items: { type: string, enum: [markdown, text, html, links, json_ld] }
                render: { type: boolean }
                onlyMainContent: { type: boolean, default: true }
                concurrency: { type: integer, minimum: 1, maximum: 8 }
                storeContent: { type: boolean, default: true }
                maxAge:
                  type: integer
                  minimum: 0
                  maximum: 2592000
                  description: >
                    Accepted for parity with /scrape but not used by a batch:
                    every page is fetched live.
                webhook:
                  type: object
                  description: Called once when the job ends; not retried.
                  properties:
                    url: { type: string }
                    secret: { type: string }
      responses:
        "202": { description: "Batch queued; the body carries the job id, urlCount and its status/results URLs." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /crawl:
    post:
      tags: [Web Data]
      summary: Start a crawl
      description: >
        Queue a crawl and get a job id back immediately. Credits are charged per
        page as it runs, so a job with `limit: 500` on a twelve-page site costs
        twelve; from 2026-11-01, a page that needed a browser costs 5 instead
        of 1. Poll the job or supply a webhook. robots.txt is obeyed by
        default.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, maxLength: 2048 }
                maxDepth: { type: integer, minimum: 0, maximum: 10, default: 2 }
                limit: { type: integer, minimum: 1, maximum: 5000, default: 100 }
                includePaths: { type: array, items: { type: string }, description: "Globs, e.g. /blog/*" }
                excludePaths: { type: array, items: { type: string } }
                formats:
                  type: array
                  items: { type: string, enum: [markdown, text, html, links, json_ld] }
                allowSubdomains: { type: boolean }
                respectRobots: { type: boolean, default: true }
                concurrency: { type: integer, minimum: 1, maximum: 8, default: 3 }
                webhook:
                  type: object
                  properties:
                    url: { type: string }
                    secret: { type: string }
      responses:
        "202": { description: Crawl queued; the body carries the job id and its status/results URLs }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Web Data]
      summary: List crawls
      description: Your crawls, newest first. Free.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: status
          in: query
          schema: { type: string, enum: [queued, running, completed, failed, cancelled] }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /crawl/{jobId}:
    get:
      tags: [Web Data]
      summary: Crawl status
      description: Progress, credits charged so far, and why the crawl stopped. Free.
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Crawl not found }
    delete:
      tags: [Web Data]
      summary: Cancel a crawl
      description: Stop spending on a crawl. Pages already fetched stay readable. Free.
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Crawl not found }

  /crawl/{jobId}/results:
    get:
      tags: [Web Data]
      summary: Crawl results
      description: >
        The crawled pages, paginated by cursor. Free — the pages were billed as
        they were fetched.
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [completed, failed, skipped, queued, all], default: completed }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Crawl not found }

  /extract:
    post:
      tags: [Web Data]
      summary: Extract structured data
      description: >
        URLs plus a field schema, records out. Values come first from what the
        page itself declares (schema.org JSON-LD, Open Graph, meta tags), then
        from unambiguous patterns in the text. Every field reports its source,
        and a field that cannot be found comes back null and is listed in
        `missingFields` rather than guessed. The URLs (internationalised domain
        names included) are fetched several at a time inside one overall time
        budget (75 seconds by default): pages that arrive in time are served
        and billed, the rest come back as rows that say why (`errorCode`,
        `retryable`), and `partial` is true when the budget ran out. Billing is
        one credit per page actually fetched (from 2026-11-01, five for a page
        that had to be rendered); a failed row is free.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [schema]
              properties:
                urls: { type: array, items: { type: string }, maxItems: 20 }
                url: { type: string }
                schema:
                  type: object
                  description: "Field name to type: string, number, integer, float, boolean or array"
                  additionalProperties:
                    type: string
                    enum: [string, number, integer, float, boolean, array]
                useLlm:
                  type: boolean
                  default: true
                  description: >
                    Fall back to a language model for fields the page's own
                    JSON-LD, Open Graph and meta tags did not declare. Defaults
                    to true when an LLM is configured on the deployment; has
                    no effect otherwise. Set false to force the deterministic
                    pass only.
                onlyMainContent:
                  type: boolean
                  default: true
                  description: Strip navigation, headers and footers from the page before extracting.
      responses:
        "200":
          description: One row per URL, in the order sent
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExtractResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422": { $ref: "#/components/responses/TargetError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search-and-scrape:
    post:
      tags: [Web Data]
      summary: Search and scrape in one call
      description: >
        Run a web search and fetch the results' pages. Returns the results with
        their content attached; a page that cannot be fetched comes back with
        `scraped: false` beside the ones that worked. One credit for the search
        plus one per page actually scraped; from 2026-11-01, five for a page
        that needed a browser.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [q]
              properties:
                q: { type: string, maxLength: 500 }
                limit: { type: integer, minimum: 1, maximum: 20, default: 5 }
                scrape: { type: boolean, default: true }
                formats:
                  type: array
                  items: { type: string, enum: [markdown, text, html, json_ld] }
                gl: { type: string, maxLength: 5 }
                hl: { type: string, maxLength: 5 }
      responses:
        "200": { $ref: "#/components/responses/SearchResults" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /search/countries:
    get:
      tags: [Account]
      summary: Countries the AI answer endpoints accept as gl (does not consume credits)
      description: >
        The countries the AI answer endpoints accept as gl. The search
        endpoints accept any two-letter gl, applied as the results country.
      responses:
        "200":
          description: Supported countries
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  engine: { type: string, example: all }
                  countries:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: US }
                        name: { type: string, example: United States }
                        states: { type: boolean, description: "US states are listed by GET /search/states." }
                        exit: { type: boolean, description: "Available as gl on the AI answer endpoints." }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/states:
    get:
      tags: [Account]
      summary: US states with their Google location (does not consume credits)
      description: >
        US states, with Google's name and uule token for each. State- and
        city-level location is not available yet: targeting is false, and a
        state passed as location or uule returns 400
        CITY_LOCATION_UNAVAILABLE. Only country=US is available; any other
        country returns 400. exitPinning false: results are not localized to
        the state.
      parameters:
        - { name: country, in: query, schema: { type: string, default: US } }
      responses:
        "200":
          description: US states
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  country: { type: string, example: US }
                  exitPinning: { type: boolean }
                  targeting: { type: boolean, example: false, description: "Whether results can be localized to a state. false: a state location returns 400 CITY_LOCATION_UNAVAILABLE." }
                  message: { type: string }
                  states:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: TX }
                        name: { type: string, example: Texas }
                        location: { type: string, example: "Texas,United States" }
                        uule: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/capabilities:
    get:
      tags: [Account]
      summary: Every endpoint this API serves, priced, with recent health (does not consume credits)
      description: >
        Each endpoint this deployment serves, with its credit price, whether it
        is switched on (reason when it is not) and its last two hours of calls.
        state is off when switched off, degraded when at least 3 calls in the
        window and fewer than half succeeded, live otherwise. May be cached
        for 30 seconds.
      responses:
        "200":
          description: Capabilities
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  generatedAt: { type: string, format: date-time }
                  capabilities:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: serp/html }
                        method: { type: string, example: GET }
                        path: { type: string, example: /serp/html }
                        credits: { oneOf: [{ type: number }, { type: string }] }
                        creditsNote: { type: string }
                        group: { type: string }
                        enabled: { type: boolean }
                        reason: { type: string, description: "Why it is switched off." }
                        state: { type: string, enum: [live, degraded, off] }
                        health:
                          type: object
                          properties:
                            window: { type: string, example: 2h }
                            ok: { type: integer }
                            fail: { type: integer }
                            successRate: { type: number, nullable: true, description: "0-1; null with no calls in the window." }
                  states: { type: object, additionalProperties: { type: string } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/analytics:
    get:
      tags: [Account]
      summary: Search history and usage analytics for the key
      responses:
        "200":
          description: Usage analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /credit/transactions:
    get:
      tags: [Account]
      summary: Credit transaction history (does not consume credits)
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: type
          in: query
          schema: { type: string, enum: [deduction, grant, refund] }
        - name: status
          in: query
          schema: { type: string, enum: [pending, completed, failed, refunded] }
      responses:
        "200":
          description: Transactions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: object }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /async/task:
    post:
      tags: [Async]
      summary: Queue one task and get a task id back
      description: >
        Submit work without holding a connection open. Poll
        `/async/task/{taskId}`, or supply `webhook` and Searlo POSTs the task
        when it completes or fails. The endpoint's price is deducted when the
        task is queued, kept when it completes and refunded when it fails or
        is cancelled. The field is `endpoint` (not `type`) and the webhook is
        `webhook: {url, secret}` (not `webhookUrl`); unknown top-level fields
        are stripped without an error. For `serp`, `news/serp` and
        `rankings/check` tasks a country `location` is applied as gl; a city
        or state fails the task at once with CITY_LOCATION_UNAVAILABLE, and it
        is refunded.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AsyncTaskSpec" }
      responses:
        "202":
          description: Task queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncTaskReply" }
        "200":
          description: Duplicate idempotencyKey; the existing task, with no new charge
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncTaskReply" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /async/task/batch:
    post:
      tags: [Async]
      summary: Queue up to 500 tasks in one request
      description: >
        Each task has the shape of a single request and is charged when it is
        queued. If credits run out part-way, the remaining tasks are not
        queued and are absent from `tasks`. A task that fails validation
        rejects the whole batch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tasks]
              properties:
                tasks:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { $ref: "#/components/schemas/AsyncTaskSpec" }
      responses:
        "202":
          description: At least one task was queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AsyncBatchReply" }
        "400":
          description: No task was queued (each row says why), or the body failed validation
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: "#/components/schemas/AsyncBatchReply" }
                  - { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /async/task/{taskId}:
    get:
      tags: [Async]
      summary: Fetch a queued task and its result (free)
      parameters:
        - name: taskId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The task, with `result` once it has completed or `error` once it has failed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  task: { $ref: "#/components/schemas/AsyncTask" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: No task with that id for this account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /async/status:
    get:
      tags: [Async]
      summary: Queue depth for this account (free)
      responses:
        "200":
          description: Queue status
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  queued: { type: integer }
                  processing: { type: integer }
                  priorityBreakdown:
                    type: array
                    items:
                      type: object
                      properties:
                        priority: { type: integer }
                        count: { type: integer }
                  last24h:
                    type: object
                    properties:
                      completed: { type: integer }
                      failed: { type: integer }
                  worker:
                    type: object
                    description: The answering process's workerId, enabled flag, and inflight and concurrency per lane.
                    additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }

  /async/queue:
    delete:
      tags: [Async]
      summary: Cancel every queued task and refund it (free)
      responses:
        "200":
          description: Queued tasks cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  cleared: { type: integer }
                  creditsRefunded: { type: number }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /email-verifier/verify:
    post:
      tags: [Tools]
      summary: Verify an email address
      description: >
        Syntax, domain, MX and mailbox checks, plus disposable and role-account
        detection.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        "200":
          description: Verification result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: object }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /mcp/info:
    get:
      tags: [Tools]
      summary: MCP server descriptor (no authentication, no credits)
      description: >
        The Model Context Protocol handshake — server name, version and the
        tools it exposes. Point Claude, Cursor or any MCP client at
        https://api.searlo.tech/api/v1/mcp.
      security: []
      responses:
        "200":
          description: Server descriptor
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data: { type: object }

  /serp:
    get:
      tags: [SERP]
      summary: Full Google SERP (ads, People Also Ask, AI Overview)
      description: >
        The whole results page as structured JSON: organic results, videos,
        ads, People Also Ask, related searches, the knowledge graph and the AI
        Overview, which is included by default (aioverview=true; pass false to
        skip it). 3 credits per page requested; from 2026-11-01, 5 more when
        the AI Overview had to be completed by rendering the page
        (aioverview.rendered: true; never with aioverview=false). A cached
        result is billed like a live one. Also served at /search/serp. Organic results carry their
        destination in `url`, not `link`.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 400 } }
        - { name: gl, in: query, schema: { type: string, minLength: 2, maxLength: 2, default: us } }
        - { name: hl, in: query, schema: { type: string, minLength: 2, maxLength: 5, default: en } }
        - { name: location, in: query, schema: { type: string, maxLength: 120 }, description: "A country, by name or two-letter code (e.g. Germany, DE), applied as gl. A city or state returns 400 with code CITY_LOCATION_UNAVAILABLE (Google no longer localizes results by uule); an unknown name returns 400 UNKNOWN_LOCATION with suggestions. Neither is charged. Mutually exclusive with uule." }
        - { name: uule, in: query, schema: { type: string, maxLength: 300 }, description: "A Google uule token. A country's token is applied as its gl; any other (a city, a state, coordinates) returns 400 CITY_LOCATION_UNAVAILABLE." }
        - { name: device, in: query, schema: { type: string, enum: [desktop, mobile, tablet], default: desktop }, description: "mobile and tablet are accepted and return the desktop layout. For the page an iPhone gets, use GET /serp/html with device=ios." }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 1 }, description: "First results page to fetch." }
        - { name: pages, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 1 }, description: "Consecutive pages to fetch. Each page delivered is billed (pagesFetched says how many came back); a SERP with no results is not charged." }
        - { name: num, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 10 }, description: "Results per page. Google serves at most 10 per page; ask for more with pages." }
        - { name: tbs, in: query, schema: { type: string, maxLength: 40 }, description: "Applied only when it is qdr:h, qdr:d, qdr:w, qdr:m or qdr:y (optionally followed by a number), li:1 or sbd:1; other values are ignored." }
        - { name: safe, in: query, schema: { oneOf: [{ type: boolean }, { type: string, enum: [active, "off"] }] }, description: "active (or true) turns SafeSearch on; off (or false, the default) leaves it off. Results are cached apart from SafeSearch-off results." }
        - { name: aioverview, in: query, schema: { type: boolean, default: true }, description: "Include the AI Overview. false omits the key and skips waiting for it." }
        - { name: googleGoto, in: query, schema: { type: boolean, default: true }, description: "Resolve /goto redirect links in the AI Overview sources." }
        - { name: googleGotoOrganic, in: query, schema: { type: boolean, default: false }, description: "Also resolve /goto links on organic results." }
        - { name: cache, in: query, schema: { type: boolean, default: true }, description: "false skips the cache and fetches live." }
        - { name: rawHtml, in: query, schema: { type: boolean, default: false }, description: "Also return rawHtml, the page's HTML as Google served it (+1 credit). Cached separately from a result without it. For the rendered page use GET /serp/html with js=true." }
      responses:
        "200":
          description: Full SERP
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SerpResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/SerpUnavailable" }

  /serp/html:
    get:
      tags: [SERP]
      summary: Google's results page as HTML, without or with JavaScript
      description: >
        Without js (2 credits per page): the full results page exactly as
        Google's server sent it. Nothing is extracted, rewritten or
        re-serialised; result links are Google's own /goto redirects.
        Typically 2-3 s, about 1 MB. With js=true (8 credits per page): the
        same page rendered, with the AI Overview opened out (finished
        streaming, Show more and Show all expanded) in the DOM that is
        returned. Typically 8-15 s. Images, fonts and media are not
        downloaded. device=ios returns the mobile layout Google serves to
        Safari on an iPhone (emulated, not a physical device). Never cached; failed calls are refunded. A country location
        is applied as gl; a city or state returns 400
        CITY_LOCATION_UNAVAILABLE. Measured through the public API: raw p50
        3.1 s / p95 7.6 s, rendered p50 9.2 s / p95 11.9 s; set client
        timeouts of 30 s (raw) and 90 s (rendered). Retry 429 after
        Retry-After and retryable 503s with backoff; a failed call is never
        charged.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 400 } }
        - { name: gl, in: query, schema: { type: string, minLength: 2, maxLength: 2, default: us } }
        - { name: hl, in: query, schema: { type: string, minLength: 2, maxLength: 5, default: en } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 1 } }
        - { name: tbs, in: query, schema: { type: string, maxLength: 40 }, description: "Google time or sort filter, e.g. qdr:w." }
        - { name: safe, in: query, schema: { type: string, enum: [active, "off"] }, description: "active turns SafeSearch on." }
        - { name: device, in: query, schema: { type: string, enum: [desktop, ios], default: desktop }, description: "ios: the page Google serves to Safari on an iPhone (Safari 26 user agent, iPhone 15 viewport, touch, no Chromium client hints)." }
        - { name: js, in: query, schema: { type: boolean, default: false }, description: "Return the rendered page with the AI Overview opened out. 8 credits instead of 2." }
        - { name: expandAio, in: query, schema: { type: boolean, default: true }, description: "With js=true: press the AI Overview's Show more, then Show all on its sources. false renders without pressing anything." }
        - { name: format, in: query, schema: { type: string, enum: [json, raw], default: json }, description: "raw answers with the page itself as text/html and moves the metadata to X-Searlo-* headers." }
        - { name: location, in: query, schema: { type: string, maxLength: 120 }, description: "A country, by name or two-letter code (e.g. Germany, DE), applied as gl. A city or state returns 400 with code CITY_LOCATION_UNAVAILABLE (Google no longer localizes results by uule); an unknown name returns 400 UNKNOWN_LOCATION with suggestions. Neither is charged. Mutually exclusive with uule." }
        - { name: uule, in: query, schema: { type: string, maxLength: 300 }, description: "A Google uule token. A country's token is applied as its gl; any other (a city, a state, coordinates) returns 400 CITY_LOCATION_UNAVAILABLE." }
      responses:
        "200":
          description: The page, in a JSON envelope (format=json) or as the HTML itself (format=raw)
          headers:
            X-Searlo-Url: { schema: { type: string }, description: "format=raw: the results page's URL." }
            X-Searlo-Fetched-At: { schema: { type: string, format: date-time }, description: "format=raw: when it was fetched." }
            X-Searlo-Empty: { schema: { type: string, enum: ["true", "false"] }, description: "format=raw: a results page with no results." }
            X-Searlo-Rendered: { schema: { type: string, enum: ["true", "false"] }, description: "format=raw: whether JavaScript ran." }
            X-Searlo-Aio: { schema: { type: string, example: "present=true; expanded=true; sources-opened=true; sources=7; show-all=pressed" }, description: "format=raw with js=true: the AI Overview receipt." }
            X-Credits-Used: { schema: { type: number }, description: "format=raw: credits this call took." }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SerpHtmlResult" }
            text/html:
              schema: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "422":
          description: >
            code QUERY_NOT_SERVED: a query shape this endpoint does not serve
            (search operators such as site:, inurl:, intitle:, filetype:,
            before:/after:, e-mail lookups, long OR chains). code
            COUNTRY_UNAVAILABLE: this country cannot be served right now.
            Not retryable as is. Not charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          description: code BUSY. At capacity (with js=true, rendering capacity). Retry after Retry-After (1-10 s). Not charged. The account and key rate limits answer 429 too, with code 4201 / 4202.
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503": { $ref: "#/components/responses/SerpUnavailable" }

  /serp/batch:
    post:
      tags: [SERP]
      summary: Full SERP for up to 20 keywords
      description: >
        Up to 20 keywords synchronously (meta.max_sync_batch reports the cap
        in force), 3 credits per distinct keyword per page delivered; from
        2026-11-01, 5 more for a keyword whose AI Overview had to be completed
        by rendering the page. The response is 200 whenever the batch ran; a
        keyword that fails inside it, or finds nothing, is not billed (read
        meta.failed and each failed row's retryable). The AI Overview is off by
        default in a batch. Larger jobs go through POST /async/task/batch with
        endpoint "serp".
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keywords]
              properties:
                keywords: { type: array, minItems: 1, maxItems: 20, items: { type: string, minLength: 1, maxLength: 400 } }
                gl: { type: string, minLength: 2, maxLength: 2, default: us }
                hl: { type: string, minLength: 2, maxLength: 5, default: en }
                location: { type: string, maxLength: 120, description: "A country, as on GET /serp. A city or state refuses the whole batch with 400 CITY_LOCATION_UNAVAILABLE before any keyword runs (not charged). Mutually exclusive with uule." }
                uule: { type: string, maxLength: 300, description: "As on GET /serp." }
                device: { type: string, enum: [desktop, mobile, tablet], default: desktop }
                pages: { type: integer, minimum: 1, maximum: 10, default: 1 }
                num: { type: integer, minimum: 1, maximum: 100, default: 10 }
                tbs: { type: string, maxLength: 40 }
                safe: { oneOf: [{ type: boolean }, { type: string, enum: [active, "off"] }], description: "As on GET /serp. Every keyword is searched with it." }
                aioverview: { type: boolean, default: false }
                googleGoto: { type: boolean, default: true }
                cache: { type: boolean, default: true }
      responses:
        "200":
          description: One row per distinct keyword; failed rows carry success false, status, retryable and message
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema: { type: string, example: serp-batch.v1 }
                  results:
                    type: array
                    items:
                      type: object
                      description: keyword and success, plus the full SERP body on success, or status, retryable and message on failure.
                      additionalProperties: true
                  credits:
                    type: object
                    properties: { used: { type: number } }
                  meta:
                    type: object
                    description: requested, distinct, succeeded, failed, pages, gl, hl, device, max_sync_batch and, at the cap, a note.
                    additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /serp/features:
    get:
      tags: [SERP]
      summary: Which SERP features a query shows
      description: >
        The feature names present on one results page. 3 credits; it only
        checks whether an AI Overview is present, so it never renders. Names come from organic, ads, shopping,
        ai_overview, ai_mode, featured_snippet, people_also_ask,
        related_searches, knowledge_graph, video, local_pack, sitelinks,
        top_stories, image_pack, twitter and faq. The results-page parse does
        not currently return shopping, local pack, top stories, image pack,
        Twitter, FAQ or featured-snippet blocks, so those names do not appear
        today.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 400 } }
        - { name: gl, in: query, schema: { type: string, minLength: 2, maxLength: 2, default: us } }
        - { name: hl, in: query, schema: { type: string, minLength: 2, maxLength: 5, default: en } }
        - { name: location, in: query, schema: { type: string, maxLength: 120 }, description: "A country, as on GET /serp (a city or state returns 400 CITY_LOCATION_UNAVAILABLE). Mutually exclusive with uule." }
        - { name: uule, in: query, schema: { type: string, maxLength: 300 }, description: "As on GET /serp." }
        - { name: device, in: query, schema: { type: string, enum: [desktop, mobile, tablet], default: desktop } }
        - { name: safe, in: query, schema: { oneOf: [{ type: boolean }, { type: string, enum: [active, "off"] }] }, description: "As on GET /serp." }
        - { name: cache, in: query, schema: { type: boolean, default: true } }
      responses:
        "200":
          description: Feature list for the query
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema: { type: string, example: serp-features.v1 }
                  query: { type: string }
                  features: { type: array, items: { type: string } }
                  counts:
                    type: object
                    description: organic, ads, shopping, videos, people_also_ask, related_searches, local_pack, top_stories, image_pack.
                    additionalProperties: { type: integer }
                  organic_count: { type: integer }
                  credits:
                    type: object
                    properties: { used: { type: number } }
                  meta:
                    type: object
                    description: gl, hl, device, cached and fetchedAt.
                    additionalProperties: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/SerpUnavailable" }

  /search/news/serp:
    get:
      tags: [SERP]
      summary: Google News results page
      description: >
        Google's News tab, fetched live and parsed (about a second per page):
        cards with title, source, date, snippet, thumbnail and the article
        link (Google's /goto resolved unless googleGoto=false). Positions
        continue across pages; a developing story's grouped articles are
        cards of their own, so a page can hold more than 10. 2 credits per
        page requested.
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 400 } }
        - { name: gl, in: query, schema: { type: string, minLength: 2, maxLength: 2, default: us } }
        - { name: hl, in: query, schema: { type: string, minLength: 2, maxLength: 5, default: en } }
        - { name: location, in: query, schema: { type: string, maxLength: 120 }, description: "A country, as on GET /serp (a city or state returns 400 CITY_LOCATION_UNAVAILABLE). Mutually exclusive with uule." }
        - { name: uule, in: query, schema: { type: string, maxLength: 300 }, description: "As on GET /serp." }
        - { name: page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 1 } }
        - { name: pages, in: query, schema: { type: integer, minimum: 1, maximum: 10, default: 1 } }
        - { name: tbs, in: query, schema: { type: string, maxLength: 40 } }
        - { name: googleGoto, in: query, schema: { type: boolean, default: true }, description: "Resolve the cards' /goto links." }
        - { name: cache, in: query, schema: { type: boolean, default: true } }
      responses:
        "200":
          description: News cards
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  searchParameters: { type: object, additionalProperties: true }
                  news:
                    type: array
                    items:
                      type: object
                      properties:
                        position: { type: integer }
                        title: { type: string }
                        url: { type: string }
                        redirectUrl: { type: string, nullable: true }
                        resolved: { type: boolean }
                        source: { type: string }
                        snippet: { type: string }
                        date: { type: string, nullable: true }
                        thumbnail: { type: string, nullable: true }
                        page: { type: integer }
                  hasNextPage: { type: boolean }
                  pagesFetched: { type: integer }
                  gotoResolved: { type: integer }
                  fetchedAt: { type: string, format: date-time }
                  source: { type: string }
                  cached: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/SerpUnavailable" }

  /rankings/check:
    post:
      tags: [Rankings]
      summary: Where a domain ranks for a keyword
      description: >
        A live SERP read to the requested depth. Billed like the SERP it reads:
        3 credits per 10 results of depth (depth 10 = 3 credits, depth 100 =
        30 credits). The domain is reduced to its registrable domain, so any
        subdomain counts as a match. Every check is stored for
        /rankings/history.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keyword, domain]
              properties:
                keyword: { type: string, minLength: 1, maxLength: 400 }
                domain: { type: string, minLength: 3, maxLength: 253 }
                gl: { type: string, minLength: 2, maxLength: 2, default: us }
                hl: { type: string, minLength: 2, maxLength: 5, default: en }
                device: { type: string, enum: [desktop, mobile], default: desktop }
                location: { type: string, maxLength: 300, description: "A country, by name, two-letter code or uule, applied as gl and echoed in the result. A city or state returns 400 CITY_LOCATION_UNAVAILABLE before any credit is taken." }
                depth: { type: integer, minimum: 10, maximum: 100, default: 100 }
                cache: { type: boolean, default: true }
      responses:
        "200":
          description: Position, ranking URL and the depth searched
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema: { type: string, example: ranking-check.v1 }
                  result: { $ref: "#/components/schemas/RankCheckResult" }
                  credits:
                    type: object
                    properties: { used: { type: number } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/SerpUnavailable" }

  /rankings/batch:
    post:
      tags: [Rankings]
      summary: Rank checks for up to 20 keywords and one domain
      description: >
        One check per distinct keyword, at the same price per keyword as
        /rankings/check. The response is 200 whenever the batch ran, so a
        keyword that fails inside it is still billed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [keywords, domain]
              properties:
                keywords: { type: array, minItems: 1, maxItems: 20, items: { type: string, minLength: 1, maxLength: 400 } }
                domain: { type: string, minLength: 3, maxLength: 253 }
                gl: { type: string, minLength: 2, maxLength: 2, default: us }
                hl: { type: string, minLength: 2, maxLength: 5, default: en }
                device: { type: string, enum: [desktop, mobile], default: desktop }
                location: { type: string, maxLength: 300, description: "As on /rankings/check. A city or state refuses the whole batch with 400 CITY_LOCATION_UNAVAILABLE before any credit is taken." }
                depth: { type: integer, minimum: 10, maximum: 100, default: 100 }
                cache: { type: boolean, default: true }
      responses:
        "200":
          description: One row per distinct keyword
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema: { type: string, example: ranking-batch.v1 }
                  results:
                    type: array
                    items:
                      type: object
                      description: success plus the check result fields, or keyword, success false, code, retryable and message.
                      additionalProperties: true
                  credits:
                    type: object
                    properties: { used: { type: number } }
                  meta:
                    type: object
                    description: domain, requested, distinct, measured, ranking, failed and max_sync_batch.
                    additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /rankings/history:
    get:
      tags: [Rankings]
      summary: Stored rank checks for a domain (free)
      description: >
        The checks you ran, newest first. It is not a back-filled index. With
        `keyword` set and at least two checks, `change` gives the movement
        between the latest two.
      parameters:
        - { name: domain, in: query, required: true, schema: { type: string, minLength: 3, maxLength: 253 } }
        - { name: keyword, in: query, schema: { type: string, minLength: 1, maxLength: 400 } }
        - { name: gl, in: query, schema: { type: string, minLength: 2, maxLength: 2 } }
        - { name: device, in: query, schema: { type: string, enum: [desktop, mobile] } }
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        "200":
          description: Rank snapshots, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  schema: { type: string, example: ranking-history.v1 }
                  series:
                    type: array
                    items:
                      type: object
                      properties:
                        keyword: { type: string }
                        domain: { type: string }
                        gl: { type: string }
                        hl: { type: string }
                        device: { type: string }
                        position: { type: integer, nullable: true }
                        url: { type: string, nullable: true }
                        search_depth: { type: integer }
                        serp_features: { type: array, items: { type: string } }
                        checked_at: { type: string, format: date-time }
                  change:
                    type: object
                    nullable: true
                    description: "Positive delta = moved up. event is entered or dropped_out when one side is null."
                    properties:
                      delta: { type: integer, nullable: true }
                      from: { type: integer, nullable: true }
                      to: { type: integer, nullable: true }
                      event: { type: string, enum: [entered, dropped_out] }
                  credits:
                    type: object
                    properties: { used: { type: number } }
                  meta:
                    type: object
                    additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /search/ai/{engine}:
    get:
      tags: [AI Answers]
      summary: An AI answer engine's answer and citations
      description: >
        The engine's answer to the prompt, generated live, with the sources it
        cited: ChatGPT (8 credits), Microsoft Copilot (8), Gemini
        (4). Perplexity is paused (403 FEATURE_NOT_AVAILABLE, not charged). Answers are produced live (typically 10-30 s) and
        are non-deterministic. A repeat within about an hour is served from
        cache and billed at the full rate, unless cache=false. Identical
        requests in flight at the same moment are merged into one run, and
        each is billed. Structured output (`schema`) is not available on this
        GET endpoint. aimode and grok are switched off in production. Searlo
        is independent and not affiliated with OpenAI, Microsoft, Google or
        Perplexity.
      parameters:
        - { name: engine, in: path, required: true, schema: { type: string, enum: [chatgpt, perplexity, copilot, gemini] } }
        - { name: q, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 500 } }
        - { name: gl, in: query, schema: { type: string, maxLength: 5 }, description: "Best-effort country for the answer. On Copilot, gl and hl form the Bing market code (e.g. en-US)." }
        - { name: hl, in: query, schema: { type: string, maxLength: 5 }, description: "The language to ask in." }
        - { name: cache, in: query, schema: { type: boolean, default: true }, description: "false forces (and bills) a fresh answer." }
        - { name: mode, in: query, schema: { type: string, maxLength: 20 }, description: "Echoed in searchParameters.mode and part of the cache key; it does not change the answer." }
        - { name: model, in: query, schema: { type: string, maxLength: 40 }, description: "Accepted but not used: answers come from each engine's default model." }
        - { name: html, in: query, schema: { type: boolean, default: false }, description: "Also return html, an HTML snapshot of the answer page, and pageUrl. Same credits. A snapshot request always runs the engine: it is never served from or written to the cache." }
      responses:
        "200":
          description: Answer with citations
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  searchParameters: { type: object, additionalProperties: true }
                  answer: { type: string }
                  answerMarkdown: { type: string }
                  citations: { type: array, items: { type: object, properties: { index: { type: integer }, url: { type: string }, title: { type: string }, domain: { type: string } } } }
                  sources: { type: array, items: { type: string }, description: "Unique cited domains (share of voice)." }
                  citationPills: { type: array, items: { type: object, additionalProperties: true }, description: "Copilot's source pills; empty on the other engines." }
                  related: { type: array, items: { type: string } }
                  model: { type: string, description: "Empty on ChatGPT and Copilot." }
                  threadUrl: { type: string, nullable: true }
                  complete: { type: boolean, description: "false when the answer was still streaming at the deadline. Incomplete answers are not cached." }
                  via: { type: string }
                  fetchedAt: { type: string, format: date-time }
                  source: { type: string }
                  cached: { type: boolean }
                  html: { type: string, description: "With html=true: the answer page as the browser held it when the answer was read (the rendered DOM; on Copilot, the answer frame's document). Stylesheets and scripts stay linked." }
                  pageUrl: { type: string, nullable: true, description: "With html=true: the page's URL at that moment." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "403":
          description: FEATURE_NOT_AVAILABLE, the engine is switched off on this deployment (retryable false). Not charged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: The engine returned something unusable (retryable). Refunded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /domain/keywords:
    get:
      tags: [Domain]
      summary: Domain keyword footprint
      description: >
        Keywords Google Ads associates with a domain's crawled content,
        demand-ordered, with search volume, CPC and competition. Unverified by
        default — `position` is null and `source` reads
        `google_ads_site_seed` — for a flat discovery-operation cost regardless
        of row count. Pass `verify=true` to also run a live SERP on up to
        `verify_limit` (max 10 per call) of the returned keywords; those rows
        switch to `source: serp_verified` with a real `position`, and the
        unverified remainder stays in the response, clearly marked. Verifying
        costs a SERP per keyword, priced by `depth` (10-100, in pages of 10).
      parameters:
        - name: domain
          in: query
          required: true
          description: "Domain to look up, e.g. example.com."
          schema: { type: string, minLength: 3, maxLength: 253 }
        - $ref: "#/components/parameters/DomainGl"
        - $ref: "#/components/parameters/DomainHl"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: min_volume
          in: query
          description: Floor on search_volume; rows below it are dropped.
          schema: { type: integer, minimum: 0, default: 0 }
        - $ref: "#/components/parameters/DomainVerify"
        - $ref: "#/components/parameters/DomainVerifyLimit"
        - $ref: "#/components/parameters/DomainDevice"
        - $ref: "#/components/parameters/DomainLocation"
        - $ref: "#/components/parameters/DomainDepth"
      responses:
        "200": { $ref: "#/components/responses/DomainKeywordsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domain/top-pages:
    get:
      tags: [Domain]
      summary: Domain top pages
      description: >
        Pages that actually rank, grouped from verified positions on a
        domain's keyword footprint. Always verified — there is no `verify`
        parameter, because a page cannot be a "top page" for a keyword nobody
        looked up. Coverage is bounded by `verify_limit` (max 10 keywords per
        call), not by the domain's whole footprint; the response's own `meta`
        says so.
      parameters:
        - name: domain
          in: query
          required: true
          schema: { type: string, minLength: 3, maxLength: 253 }
        - $ref: "#/components/parameters/DomainGl"
        - $ref: "#/components/parameters/DomainHl"
        - name: limit
          in: query
          description: Pages to return.
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: min_volume
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - $ref: "#/components/parameters/DomainVerifyLimit"
        - $ref: "#/components/parameters/DomainDevice"
        - $ref: "#/components/parameters/DomainLocation"
        - $ref: "#/components/parameters/DomainDepth"
      responses:
        "200": { $ref: "#/components/responses/DomainTopPagesResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domain/competitors:
    get:
      tags: [Domain]
      summary: Domain competitors
      description: >
        Domains that co-occur on the SERPs verified for this domain's
        keywords, ranked by shared keyword count. `overlap` is the share of
        sampled SERPs each rival appeared on (0-1). Always verified, like
        /domain/top-pages — no `verify` parameter.
      parameters:
        - name: domain
          in: query
          required: true
          schema: { type: string, minLength: 3, maxLength: 253 }
        - $ref: "#/components/parameters/DomainGl"
        - $ref: "#/components/parameters/DomainHl"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: min_volume
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - $ref: "#/components/parameters/DomainVerifyLimit"
        - $ref: "#/components/parameters/DomainDevice"
        - $ref: "#/components/parameters/DomainLocation"
        - $ref: "#/components/parameters/DomainDepth"
      responses:
        "200": { $ref: "#/components/responses/DomainCompetitorsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domain/gaps:
    get:
      tags: [Domain]
      summary: Domain keyword gap (query form)
      description: >
        Keywords up to 10 competitor domains are associated with that the
        target is not, ranked by how many competitors share each term and by
        search volume. Unverified by default — 2 credits per domain queried
        (target plus each competitor); pass `verify=true` to also measure the
        target's live position on up to `verify_limit` of the returned gap
        keywords. Use the GET form for a small competitor list; POST accepts
        the same fields as a JSON body.
      parameters:
        - name: target
          in: query
          required: true
          schema: { type: string, minLength: 3, maxLength: 253 }
        - name: competitors
          in: query
          required: true
          description: >
            1-10 competitor domains. A comma-separated string or repeated
            query values are both accepted.
          schema:
            oneOf:
              - type: array
                items: { type: string, minLength: 3, maxLength: 253 }
                minItems: 1
                maxItems: 10
              - type: string
        - $ref: "#/components/parameters/DomainGl"
        - $ref: "#/components/parameters/DomainHl"
        - name: limit
          in: query
          description: Keywords pulled per domain before diffing.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 200 }
        - name: result_limit
          in: query
          description: Gap rows returned, after diffing and sorting.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }
        - name: min_volume
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
        - $ref: "#/components/parameters/DomainVerify"
        - $ref: "#/components/parameters/DomainVerifyLimit"
        - $ref: "#/components/parameters/DomainDevice"
        - $ref: "#/components/parameters/DomainLocation"
        - $ref: "#/components/parameters/DomainDepth"
      responses:
        "200": { $ref: "#/components/responses/DomainGapsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Domain]
      summary: Domain keyword gap (body form)
      description: >
        Same report as GET /domain/gaps, with `target` and 1-10 `competitors`
        sent as a JSON body instead of query parameters — the more ergonomic
        form once the competitor list is longer than a couple of domains.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target, competitors]
              properties:
                target: { type: string, minLength: 3, maxLength: 253 }
                competitors:
                  type: array
                  items: { type: string, minLength: 3, maxLength: 253 }
                  minItems: 1
                  maxItems: 10
                gl: { type: string, minLength: 2, maxLength: 2, default: "us" }
                hl: { type: string, minLength: 2, maxLength: 5, default: "en" }
                limit: { type: integer, minimum: 1, maximum: 1000, default: 200 }
                result_limit: { type: integer, minimum: 1, maximum: 1000, default: 100 }
                min_volume: { type: integer, minimum: 0, default: 0 }
                verify: { type: boolean, default: false }
                verify_limit: { type: integer, minimum: 1, maximum: 10, default: 10 }
                device: { type: string, enum: [desktop, mobile], default: desktop }
                location: { type: string, maxLength: 300 }
                depth: { type: integer, minimum: 10, maximum: 100, default: 100 }
      responses:
        "200": { $ref: "#/components/responses/DomainGapsResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /domain/overview:
    get:
      tags: [Domain]
      summary: Domain overview
      description: >
        A domain's keyword footprint, top topics and top keywords, rolled up
        from the same discovery call as /domain/keywords — 2 credits flat.
        `organic_visibility` (keywords measured, keywords ranking, top-3/10/100
        counts, average position) is `null` unless `verify=true`, since it
        requires live SERPs rather than modelled Ads data.
      parameters:
        - name: domain
          in: query
          required: true
          schema: { type: string, minLength: 3, maxLength: 253 }
        - $ref: "#/components/parameters/DomainGl"
        - $ref: "#/components/parameters/DomainHl"
        - name: limit
          in: query
          schema: { type: integer, minimum: 50, maximum: 1000, default: 200 }
        - $ref: "#/components/parameters/DomainVerify"
        - $ref: "#/components/parameters/DomainVerifyLimit"
        - $ref: "#/components/parameters/DomainDevice"
        - $ref: "#/components/parameters/DomainLocation"
        - $ref: "#/components/parameters/DomainDepth"
      responses:
        "200": { $ref: "#/components/responses/DomainOverviewResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: >
        API key from your Searlo dashboard, e.g. sk_.... Get a free key with
        3,000 credits at https://dashboard.searlo.tech (no card required).
    BearerAuth:
      type: http
      scheme: bearer
      description: >
        The same API key, sent as Authorization: Bearer sk_.... A bearer token
        that does not start with sk_ is read as a dashboard login token.

    CseKeyParam:
      type: apiKey
      in: query
      name: key
      description: >
        The Searlo API key as the key query parameter, where Google's Custom
        Search JSON API takes its key. Used by /customsearch/v1 only.

  parameters:
    Query:
      name: q
      in: query
      required: true
      description: The search query.
      schema: { type: string, minLength: 1, maxLength: 500 }
    Limit:
      name: limit
      in: query
      description: Number of results to return.
      schema: { type: integer, minimum: 1, maximum: 10, default: 10 }
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    DateRestrict:
      name: dateRestrict
      in: query
      description: >
        Only results from the last n days, weeks, months or years: d7, w2, m1,
        y1. Works with gl and hl. Combined with search operators in the query
        (site:, filetype:, ...) it returns 400 DATE_FILTER_UNAVAILABLE, not
        charged.
      schema: { type: string, pattern: "^[dwmyDWMY][1-9][0-9]{0,3}$", example: d7 }
    CacheBypass:
      name: cache
      in: query
      description: false skips the cached copy and searches now. Same credits.
      schema: { type: boolean, default: true }
    Gl:
      name: gl
      in: query
      description: Country code for geolocation, e.g. us.
      schema: { type: string, maxLength: 5 }
    Hl:
      name: hl
      in: query
      description: Interface/result language, e.g. en.
      schema: { type: string, maxLength: 5 }
    DomainGl:
      name: gl
      in: query
      description: Country code for geolocation. Defaults to us.
      schema: { type: string, minLength: 2, maxLength: 2, default: "us" }
    DomainHl:
      name: hl
      in: query
      description: Interface/result language. Defaults to en.
      schema: { type: string, minLength: 2, maxLength: 5, default: "en" }
    DomainVerify:
      name: verify
      in: query
      description: >
        Run a live SERP per keyword to add measured positions. Defaults to
        false. Not offered on /domain/top-pages or /domain/competitors, which
        always verify.
      schema: { type: boolean, default: false }
    DomainVerifyLimit:
      name: verify_limit
      in: query
      description: >
        Maximum keywords to verify with a live SERP in this call. Capped at 10
        regardless of endpoint — call again to cover more of a longer list.
      schema: { type: integer, minimum: 1, maximum: 10, default: 10 }
    DomainDevice:
      name: device
      in: query
      description: Device to verify positions on.
      schema: { type: string, enum: [desktop, mobile], default: desktop }
    DomainLocation:
      name: location
      in: query
      description: "A country for the verifying SERP, by name, code or uule, applied as gl. A city or state returns 400 CITY_LOCATION_UNAVAILABLE."
      schema: { type: string, maxLength: 300 }
    DomainDepth:
      name: depth
      in: query
      description: >
        Result depth to check per verified keyword (10-100), rounded up
        internally to the next page of 10. Higher depth costs more credits
        per keyword verified.
      schema: { type: integer, minimum: 10, maximum: 100, default: 100 }

  responses:
    SearchResults:
      description: Search results
      content:
        application/json:
          schema: { $ref: "#/components/schemas/SearchResponse" }
    CseError:
      description: >
        Google's error envelope, as /customsearch/v1 answers every error:
        {error:{code, message, errors[{message, domain, reason}], status,
        details}}. details carries a RequestInfo with the Searlo request id.
      content:
        application/json:
          schema: { type: object }
    BadRequest:
      description: >
        Invalid request (code VALIDATION_ERROR; errors lists the fields). Search
        endpoints also answer 400 PAGE_OUT_OF_RANGE (maxPage) and
        DATE_FILTER_UNAVAILABLE, with the name in errorCode. Never charged.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    Unauthorized:
      description: Missing, invalid or expired API key (code UNAUTHORIZED)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    InsufficientCredits:
      description: >
        Account balance too low to complete the request (code
        INSUFFICIENT_CREDITS). details gives required, available and shortfall;
        topUpUrl links to buying more.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    RateLimited:
      description: >
        The account's rate limit (code 4201), the API key's own (code 4202) or
        the cap on searches in flight (code 4203, errorCode CONCURRENCY_LIMIT,
        Retry-After 1, X-Concurrency-Limit carries the cap). Wait Retry-After
        seconds. See also AT_CAPACITY / BUSY / VERIFIER_BUSY, a full lane
        rather than a limit reached. Never charged.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    SerpUnavailable:
      description: >
        code UPSTREAM_UNAVAILABLE: the results page came back unusable
        (retryable true, with a short Retry-After). Refunded. A switched-off
        endpoint answers 403 FEATURE_NOT_AVAILABLE instead, and an operator
        query (site:, intitle:, ...) 422 QUERY_NOT_SERVED; neither is charged.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    TargetError:
      description: >
        The site could not be fetched; the name is in errorCode. TARGET_REFUSED:
        the site refused every attempt or answered with an error of its own
        (targetStatus is its own status; retryable only for its 429 or 5xx).
        TARGET_NOT_ALLOWED: a private or reserved network address (retryable
        false). TARGET_UNREACHABLE: no attempt could fetch it, for example its
        secure-connection handshake is refused, it redirects in a loop or the
        page is too large (retryable false; targetError says which). Not
        charged.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
    DomainKeywordsResult:
      description: Domain keyword footprint
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DomainKeywordsResponse" }
    DomainTopPagesResult:
      description: Domain top pages
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DomainTopPagesResponse" }
    DomainCompetitorsResult:
      description: Domain competitors
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DomainCompetitorsResponse" }
    DomainGapsResult:
      description: Domain keyword gap
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DomainGapsResponse" }
    DomainOverviewResult:
      description: Domain overview
      content:
        application/json:
          schema: { $ref: "#/components/schemas/DomainOverviewResponse" }

  schemas:
    DomainKeywordRow:
      type: object
      properties:
        keyword: { type: string }
        search_volume: { type: integer, nullable: true }
        cpc: { type: number, nullable: true }
        competition: { type: string, nullable: true }
        parent_topic: { type: string, nullable: true }
        position: { type: integer, nullable: true, description: "Set only when verified is true." }
        url: { type: string, nullable: true }
        page_title: { type: string, description: "Present on verified rows." }
        serp_features: { type: array, items: { type: string }, description: "Present on verified rows." }
        competitors_above:
          type: array
          items: { type: string }
          description: Domains ranking above this one on the verifying SERP. Present on verified rows.
        verified: { type: boolean }
        verify_failed: { type: boolean, description: "True when verification was requested but the SERP check failed." }
        source: { type: string, enum: [google_ads_site_seed, serp_verified] }
    DomainKeywordsResponse:
      type: object
      properties:
        schema: { type: string, example: "domain-keywords.v1" }
        results:
          type: array
          items: { $ref: "#/components/schemas/DomainKeywordRow" }
        credits:
          type: object
          properties: { used: { type: number } }
        meta:
          type: object
          description: Carries domain, gl, hl, verified, verified_count, verify_failures, unverified_count, verify_cap and a human-readable note.
          additionalProperties: true
    DomainTopPagesResponse:
      type: object
      properties:
        schema: { type: string, example: "domain-top-pages.v1" }
        results:
          type: array
          items:
            type: object
            properties:
              url: { type: string }
              keywords: { type: integer }
              total_volume: { type: integer }
              best_position: { type: integer, nullable: true }
              avg_position: { type: number }
              top_keyword: { type: string }
        credits:
          type: object
          properties: { used: { type: number } }
        meta:
          type: object
          description: Carries pages_found and a note on verify_limit-bounded coverage.
          additionalProperties: true
    DomainCompetitorsResponse:
      type: object
      properties:
        schema: { type: string, example: "domain-competitors.v1" }
        results:
          type: array
          items:
            type: object
            properties:
              domain: { type: string }
              common_keywords: { type: integer }
              overlap: { type: number, description: "Share of sampled SERPs this domain appeared on, 0-1." }
              common_volume: { type: integer }
              avg_position: { type: number }
        credits:
          type: object
          properties: { used: { type: number } }
        meta:
          type: object
          description: Carries domain, serps_sampled, competitors_found, gl, hl and a note.
          additionalProperties: true
    DomainGapRow:
      type: object
      properties:
        keyword: { type: string }
        search_volume: { type: integer, nullable: true }
        cpc: { type: number, nullable: true }
        competition: { type: string, nullable: true }
        parent_topic: { type: string, nullable: true }
        competitors:
          type: array
          items: { type: string }
          description: Which of the named competitor domains this keyword came from.
        competitor_coverage: { type: number, description: "Share of named competitors associated with this keyword, 0-1." }
        position: { type: integer, nullable: true, description: "Set only when verify=true and this row was checked." }
        url: { type: string, nullable: true }
        verified: { type: boolean }
        source: { type: string, enum: [google_ads_site_seed, serp_verified] }
    DomainGapsResponse:
      type: object
      properties:
        schema: { type: string, example: "domain-gaps.v1" }
        results:
          type: array
          items: { $ref: "#/components/schemas/DomainGapRow" }
        credits:
          type: object
          properties: { used: { type: number } }
        meta:
          type: object
          description: Carries target, competitors, target_keywords, gaps_found, returned, gl, hl, verified, verified_count, verify_failures and a note.
          additionalProperties: true
    DomainOverviewResponse:
      type: object
      properties:
        schema: { type: string, example: "domain-overview.v1" }
        result:
          type: object
          properties:
            domain: { type: string }
            gl: { type: string }
            hl: { type: string }
            keyword_footprint: { type: integer }
            keywords_with_volume: { type: integer }
            total_search_volume: { type: integer }
            avg_cpc: { type: number, nullable: true }
            top_topics:
              type: array
              items:
                type: object
                properties:
                  topic: { type: string }
                  keywords: { type: integer }
                  volume: { type: integer }
            top_keywords:
              type: array
              items:
                type: object
                properties:
                  keyword: { type: string }
                  search_volume: { type: integer, nullable: true }
                  position: { type: integer, description: "Present only on rows a verified call measured." }
            organic_visibility:
              type: object
              nullable: true
              description: Null unless the request that built this overview passed verify=true.
              properties:
                keywords_measured: { type: integer }
                keywords_ranking: { type: integer }
                top_3: { type: integer }
                top_10: { type: integer }
                top_100: { type: integer }
                avg_position: { type: number }
        credits:
          type: object
          properties: { used: { type: number } }
        meta:
          type: object
          additionalProperties: true

    CreditsBalance:
      type: object
      required: [success, credits]
      properties:
        success: { type: boolean, example: true }
        credits:
          type: object
          required: [available]
          properties:
            available: { type: number, example: 2500, description: "Credits you can spend now: the balance GET /user/profile shows (0 while the account is on hold)." }
            reserved: { type: number, example: 6, description: "Present when known: how much of available requests still in flight, or awaiting settlement, already hold." }
            expiresAt: { type: string, format: date-time, description: "Present when some credits expire: when the next ones do." }
            expiringCredits: { type: number, description: "Present with expiresAt: how many credits expire then." }
    ImagesResponse:
      type: object
      properties:
        searchParameters: { type: object, additionalProperties: true, description: "q, type, gl, hl, num, page and the image filters applied (imgSize, imgType, imgColorType, imgDominantColor)." }
        page: { type: integer }
        nextPage: { type: integer, nullable: true }
        previousPage: { type: integer, nullable: true }
        totalResults: { type: integer }
        searchTime: { type: number }
        images:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              title: { type: string }
              imageUrl: { type: string, description: "The image itself." }
              width: { type: integer, nullable: true, description: "Pixels." }
              height: { type: integer, nullable: true, description: "Pixels." }
              thumbnailUrl: { type: string, nullable: true }
              thumbnailWidth: { type: integer, nullable: true }
              thumbnailHeight: { type: integer, nullable: true }
              link: { type: string, nullable: true, description: "The page the image is on." }
              sourceUrl: { type: string, description: "The page the image is on; the image URL when Google names no page." }
              source: { type: string, nullable: true }
              domain: { type: string, nullable: true }
              format: { type: string, nullable: true, example: image/jpeg, description: "The image's MIME type." }
              imageId: { type: string, nullable: true }
        source: { type: string }
        credits: { type: number, description: "0 when no image was found." }
    MapsPlace:
      type: object
      description: One place. Maps and place details share these fields.
      properties:
        position: { type: integer }
        title: { type: string }
        placeId: { type: string, nullable: true, description: "Google Place ID (ChIJ...)." }
        cid: { type: string, nullable: true, description: "The 0x...:0x... place id." }
        featureId: { type: string, nullable: true, description: "Knowledge Graph id (/g/...)." }
        category: { type: string, nullable: true }
        categories: { type: array, items: { type: string } }
        rating: { type: number, nullable: true }
        reviews: { type: integer, nullable: true }
        price: { type: string, nullable: true }
        address: { type: string, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        website: { type: string, nullable: true }
        domain: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        timezone: { type: string, nullable: true }
        description: { type: string, nullable: true }
        openState: { type: string, nullable: true, description: "Google's open/closed line in the language of hl, e.g. Open · Closes 6 PM. null for a place that has closed." }
        openNow: { type: boolean, nullable: true, description: "Open right now. null when the record does not say, and for a place that has closed." }
        businessStatus: { type: string, nullable: true, enum: [OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY, null], description: "Whether the place still trades; null when Google's record does not say (never guessed from missing data)." }
        hours: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
        openingDate: { type: string, nullable: true }
        openingDateText: { type: string, nullable: true }
        attributes: { type: object, nullable: true, additionalProperties: true, description: "Google's About list, grouped as Google groups it (payments, service_options, children, ...)." }
        thumbnailUrl: { type: string, nullable: true }
        reviewsLink: { type: string, nullable: true }
        bookingLink: { type: string, nullable: true }
        bookingLinks: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
    MapsResponse:
      type: object
      properties:
        searchParameters: { type: object, additionalProperties: true, description: "q, type, gl, hl, num, page and ll, the area searched, as @lat,lng,zoomz." }
        lite: { type: boolean, description: "Google served its reduced tier (rating and address, no review counts, price or hours). Never cached: retry for the full record." }
        page: { type: integer }
        totalResults: { type: integer }
        places: { type: array, items: { $ref: "#/components/schemas/MapsPlace" } }
        source: { type: string }
        credits: { type: number, description: "0 when no place was found." }
    PlacesResponse:
      type: object
      properties:
        searchParameters: { type: object, additionalProperties: true }
        lite: { type: boolean, description: "As on /search/maps." }
        page: { type: integer }
        totalResults: { type: integer }
        places:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              title: { type: string }
              address: { type: string, nullable: true }
              latitude: { type: number, nullable: true }
              longitude: { type: number, nullable: true }
              rating: { type: number, nullable: true }
              ratingCount: { type: integer, nullable: true }
              category: { type: string, nullable: true }
              categories: { type: array, items: { type: string } }
              priceLevel: { type: string, nullable: true }
              phoneNumber: { type: string, nullable: true }
              website: { type: string, nullable: true }
              domain: { type: string, nullable: true }
              openState: { type: string, nullable: true, description: "As on /search/maps." }
              openNow: { type: boolean, nullable: true }
              businessStatus: { type: string, nullable: true, enum: [OPERATIONAL, CLOSED_TEMPORARILY, CLOSED_PERMANENTLY, null] }
              hours: { type: array, nullable: true, items: { type: object, additionalProperties: true } }
              openingDate: { type: string, nullable: true }
              openingDateText: { type: string, nullable: true }
              attributes: { type: object, nullable: true, additionalProperties: true }
              thumbnailUrl: { type: string }
              cid: { type: string, nullable: true }
              featureId: { type: string, nullable: true, description: "The 0x...:0x... id that /search/reviews takes as featureId." }
              knowledgeGraphId: { type: string, nullable: true }
              placeId: { type: string, nullable: true }
        source: { type: string }
        credits: { type: number }
    PlaceDetailsResponse:
      type: object
      properties:
        searchParameters: { type: object, additionalProperties: true }
        lite: { type: boolean, description: "A thinner tier (fewer attributes, no menu). Never cached: retry for the rest." }
        place:
          allOf:
            - $ref: "#/components/schemas/MapsPlace"
            - type: object
              properties:
                menu: { type: object, nullable: true, additionalProperties: true, description: "url, sections of items with prices, and photos; null when Google has no menu." }
        source: { type: string }
        credits: { type: number }
    ReviewsResponse:
      type: object
      properties:
        searchParameters: { type: object, additionalProperties: true }
        place:
          type: object
          properties:
            title: { type: string }
            address: { type: string }
            featureId: { type: string }
            rating: { type: number, nullable: true }
            reviewCount: { type: integer, nullable: true, description: "The place's lifetime review count." }
        totalResults: { type: integer, description: "Reviews on this page." }
        hasMore: { type: boolean, description: "The place has more reviews than this page. Later pages cannot be fetched yet." }
        hasNextPage: { type: boolean, description: "Same as hasMore." }
        nextPageToken: { type: string, nullable: true, description: "Google's token for the next page; no parameter takes it yet." }
        reviews:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              reviewId: { type: string, nullable: true }
              rating: { type: integer, minimum: 1, maximum: 5 }
              text: { type: string }
              date: { type: string, format: date-time, nullable: true }
              timestamp: { type: integer, nullable: true, description: "date, in Unix seconds." }
              relativeDate: { type: string, description: "Google's own text, in the language of hl." }
              language: { type: string, nullable: true, description: "The language the review is written in." }
              author: { type: string }
              authorProfile: { type: string }
              authorPhoto: { type: string }
              authorId: { type: string, nullable: true }
              authorReviewCount: { type: integer, nullable: true }
              isLocalGuide: { type: boolean }
              response:
                type: object
                description: Present only when the owner replied.
                properties:
                  text: { type: string }
                  relativeTime: { type: string, nullable: true }
                  date: { type: string, format: date-time, nullable: true }
                  language: { type: string, nullable: true }
        source: { type: string }
        credits: { type: number, description: "0 when the place has no reviews." }
    ExtractResponse:
      type: object
      properties:
        searchParameters:
          type: object
          properties:
            type: { type: string, example: extract }
            urls: { type: integer }
            fields: { type: array, items: { type: string } }
        mode: { type: string, enum: [structured, structured+llm] }
        llmAvailable: { type: boolean }
        totalResults: { type: integer, description: "Rows in data." }
        partial: { type: boolean, description: "Present and true when the time budget ran out before every URL was fetched: the rows say which (TIMEOUT, NOT_FETCHED); the rest are served and billed." }
        data:
          type: array
          items:
            type: object
            required: [url, success, data]
            properties:
              url: { type: string }
              success: { type: boolean, description: "true: the page was fetched (1 credit). false: free." }
              data: { type: object, nullable: true, additionalProperties: true, description: "The record; null on a failed row." }
              _sources: { type: object, additionalProperties: { type: string, enum: [json-ld, meta, text, llm] } }
              missingFields: { type: array, items: { type: string } }
              via: { type: string, nullable: true }
              llmUsed: { type: boolean }
              llmSkipped: { type: string, enum: [time budget], description: "The page came in too late to ask the model for its missing fields." }
              errorCode: { type: string, enum: [TARGET_REFUSED, TARGET_NOT_ALLOWED, TARGET_UNREACHABLE, INVALID_REQUEST, SCRAPE_FAILED, TIMEOUT, NOT_FETCHED], description: "On a failed row." }
              error: { type: string, description: "On a failed row: what happened." }
              retryable: { type: boolean, description: "On a failed row: whether a retry may succeed." }
              targetStatus: { type: integer, description: "On a TARGET_REFUSED row: the site's own HTTP status." }
        source: { type: string }
        credits: { type: number, description: "Pages fetched." }
    SearchResponse:
      type: object
      description: The answer is the body itself (no success/data envelope).
      properties:
        page: { type: integer }
        nextPage: { type: integer, nullable: true, description: "The next page, or null when the results end here." }
        totalResults: { type: integer }
        credits: { type: number, description: "Credits charged for this request; 0 for an answer with no results. Equals the X-Credits-Deducted header." }
        searchParameters:
          type: object
          properties:
            q: { type: string }
            type: { type: string }
            gl: { type: string }
            hl: { type: string }
            num: { type: integer }
        organic:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              title: { type: string }
              link: { type: string }
              snippet: { type: string }
              domain: { type: string }
              displayedLink: { type: string }
              favicon: { type: string }
              date: { type: string }
              thumbnail: { type: string }
              breadcrumb: { type: string }
    SerpResult:
      type: object
      properties:
        success: { type: boolean }
        credits: { type: number, description: "Credits charged: 3 per page delivered (+1 with rawHtml); 0 for an empty SERP." }
        searchParameters: { type: object, additionalProperties: true }
        organic:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              title: { type: string }
              url: { type: string }
              redirectUrl: { type: string, nullable: true }
              resolved: { type: boolean }
              displayedUrl: { type: string }
              domain: { type: string }
              snippet: { type: string }
              date: { type: string, nullable: true }
              sitelinks:
                type: array
                items:
                  type: object
                  properties:
                    title: { type: string }
                    url: { type: string }
              page: { type: integer }
        videos:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              title: { type: string }
              url: { type: string }
              redirectUrl: { type: string, nullable: true }
              resolved: { type: boolean }
              source: { type: string }
              channel: { type: string }
        ads:
          type: array
          items:
            type: object
            properties:
              position: { type: integer }
              block: { type: string, enum: [top, bottom] }
              title: { type: string }
              url: { type: string }
              displayedUrl: { type: string }
              description: { type: string }
        peopleAlsoAsk:
          type: array
          description: The questions only; answers are not included.
          items:
            type: object
            properties:
              question: { type: string }
        relatedSearches:
          type: array
          items:
            type: object
            properties:
              query: { type: string }
        knowledgeGraph:
          type: object
          nullable: true
          properties:
            title: { type: string }
            type: { type: string }
            description: { type: string }
            attributes: { type: object, additionalProperties: { type: string } }
            website: { type: string, nullable: true }
        aioverview:
          type: object
          nullable: true
          description: Null when Google showed no AI Overview; absent when aioverview=false.
          properties:
            complete: { type: boolean, description: "true when the overview was read in full, from the page as served or from the rendered page; false when only part of it could be read." }
            pending: { type: boolean, description: "true when Google loads the overview after the page: text is empty, and GET /serp reads it from the rendered page." }
            rendered: { type: boolean, description: "Present and true when the overview was completed from the page rendered in a browser. From 2026-11-01 such a request costs 5 credits more." }
            text: { type: string }
            markdown: { type: string }
            sources:
              type: array
              items:
                type: object
                properties:
                  position: { type: integer }
                  label: { type: string }
                  title: { type: string }
                  supportingCount: { type: integer }
                  url: { type: string }
                  redirectUrl: { type: string, nullable: true }
                  resolved: { type: boolean }
                  domain: { type: string }
        aiModeAvailable: { type: boolean }
        pagesFetched: { type: integer, description: "Pages delivered, and billed: lower than pages when Google has no further page, a later page repeats the one before, or it did not come back." }
        gotoResolved: { type: integer }
        fetchedAt: { type: string, format: date-time }
        source: { type: string }
        cached: { type: boolean }
        rawHtml: { type: string, description: "With rawHtml=true: the page's HTML." }
    SerpHtmlResult:
      type: object
      properties:
        success: { type: boolean }
        searchParameters:
          type: object
          properties:
            q: { type: string }
            type: { type: string, enum: [html, rendered] }
            gl: { type: string }
            hl: { type: string }
            page: { type: integer }
            device: { type: string, enum: [desktop, ios] }
            tbs: { type: string }
        html: { type: string, description: "The page. Without js, the bytes Google's server sent; with js=true, the DOM after the page's JavaScript ran and the AI Overview was opened out." }
        url: { type: string, nullable: true, description: "The results page's URL." }
        empty: { type: boolean, description: "true when Google answered with a results page that has no results." }
        rendered: { type: boolean, description: "Whether JavaScript ran (js=true)." }
        aio:
          type: object
          description: "With js=true: what happened to the AI Overview."
          properties:
            present: { type: boolean, description: "Google showed an AI Overview." }
            expanded: { type: boolean, description: "Show more took. On iOS, the tap went through (the mobile Show more lifts a height limit and adds no content)." }
            sourcesOpened: { type: boolean, description: "Every source is on show: Show all took, or there was nothing left to open (see showAll)." }
            showAll: { type: string, enum: [pressed, absent, complete, failed], description: "What the Show all step found: pressed; absent, no Show all to press (short lists, the mobile layout); complete, every source already on screen; failed, the press took nowhere with sources still hidden." }
            sourcesBefore: { type: integer }
            sources: { type: integer, description: "Unique source links on the page." }
            sourcesVisible: { type: integer, description: "Source links on screen." }
        aiOverview:
          type: object
          nullable: true
          description: "With js=true on desktop: the overview parsed from the captured page. null on iOS, where the overview is in html only."
          properties:
            text: { type: string }
            markdown: { type: string }
            sources: { type: array, items: { type: object, additionalProperties: true } }
        bytes: { type: integer, description: "Size of html in bytes (UTF-8)." }
        fetchedAt: { type: string, format: date-time }
        source: { type: string, enum: ["searlo.tech(google_serp_html)", "searlo.tech(google_serp_rendered)"] }
        credits:
          type: object
          properties: { used: { type: number } }
    RankCheckResult:
      type: object
      properties:
        keyword: { type: string }
        domain: { type: string, description: "The registrable domain matched." }
        position: { type: integer, nullable: true }
        url: { type: string, nullable: true }
        title: { type: string, nullable: true }
        found: { type: boolean }
        search_depth: { type: integer, description: "The depth requested, which is what gives found=false its meaning." }
        organic_count: { type: integer }
        pages_fetched: { type: integer }
        gl: { type: string }
        hl: { type: string }
        device: { type: string }
        serp_features: { type: array, items: { type: string } }
        competitors_above: { type: array, maxItems: 20, items: { type: string } }
        checked_at: { type: string, format: date-time }
        cached: { type: boolean }
    AsyncTaskSpec:
      type: object
      required: [endpoint]
      properties:
        endpoint:
          type: string
          description: >
            What to run. keywords/*, domain/*, ai/aimode and ai/grok also pass
            validation, but those endpoints are switched off in production, and ai/perplexity is paused.
          enum: [serp, news/serp, rankings/check, ai/chatgpt, ai/perplexity, ai/copilot, ai/gemini, web, maps, shopping, goto]
        request:
          type: object
          additionalProperties: true
          description: >
            The endpoint's parameters. Must include `q` (ai/* tasks also accept
            `prompt`), except for goto, which takes `url` or `urls`.
            rankings/check reads `keyword` and `domain` but still needs `q`.
            web reads the search service's own names (`num`, `start`,
            `country`, `language`); maps and shopping read `num` for the result
            count. For serp, news/serp and rankings/check a country `location`
            is applied as gl; a city or state fails the task at once
            (CITY_LOCATION_UNAVAILABLE, refunded).
        priority: { type: integer, minimum: 1, maximum: 100, default: 50, description: "Higher runs first." }
        idempotencyKey: { type: string, maxLength: 128, description: "A repeat returns the existing task with no new charge." }
        webhook:
          type: object
          required: [url]
          description: >
            POSTed when the task completes or fails. Any 2xx is delivered;
            otherwise it is retried after 5 minutes, 30 minutes, 2 hours and
            12 hours.
          properties:
            url: { type: string, format: uri, description: "HTTPS, resolving to a public address." }
            secret:
              type: string
              maxLength: 256
              description: >
                Signs each delivery: X-Searlo-Signature is v1= followed by the
                hex HMAC-SHA256 of "<X-Searlo-Timestamp>.<raw body>".
    AsyncTask:
      type: object
      properties:
        taskId: { type: string, format: uuid }
        endpoint: { type: string }
        status: { type: string, enum: [QUEUED, PROCESSING, COMPLETED, FAILED, CANCELLED] }
        priority: { type: integer }
        attempts: { type: integer }
        creditsHeld: { type: number }
        creditsUsed: { type: number }
        creditsRefunded: { type: boolean }
        request: { type: object, additionalProperties: true }
        result:
          type: object
          additionalProperties: true
          description: Present when COMPLETED. Oversized results lose their bulkiest top-level fields, listed in `_trimmed`.
        error:
          type: object
          description: Present when FAILED.
          properties:
            code: { type: string }
            message: { type: string }
            retryable: { type: boolean }
        webhook:
          type: object
          properties:
            url: { type: string }
            attempts: { type: integer }
            lastStatus: { type: integer, nullable: true }
            deliveredAt: { type: string, format: date-time, nullable: true }
        queuedAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        durationMs: { type: integer, nullable: true }
        expiresAt: { type: string, format: date-time }
    AsyncTaskReply:
      type: object
      properties:
        success: { type: boolean }
        duplicate: { type: boolean }
        task: { $ref: "#/components/schemas/AsyncTask" }
    AsyncBatchReply:
      type: object
      properties:
        success: { type: boolean }
        accepted: { type: integer }
        total: { type: integer }
        tasks:
          type: array
          items:
            type: object
            properties:
              accepted: { type: boolean }
              duplicate: { type: boolean }
              taskId: { type: string }
              status: { type: string }
              creditsHeld: { type: number }
              error: { type: string, description: "Present when accepted is false." }
              endpoint: { type: string, description: "Present when accepted is false." }
    ErrorResponse:
      type: object
      properties:
        success: { type: boolean, example: false }
        code:
          description: >
            Stable machine-readable name of the failure. Strings:
            VALIDATION_ERROR, INVALID_JSON, INVALID_IDEMPOTENCY_KEY,
            CITY_LOCATION_UNAVAILABLE, UNKNOWN_LOCATION, INVALID_DOMAIN,
            UNAUTHORIZED, INSUFFICIENT_CREDITS, FORBIDDEN,
            FEATURE_NOT_AVAILABLE, API_KEY_SCOPE_FORBIDDEN, NOT_FOUND,
            IDEMPOTENCY_REQUEST_IN_PROGRESS, PAYLOAD_TOO_LARGE,
            QUERY_NOT_SERVED, COUNTRY_UNAVAILABLE, IDEMPOTENCY_KEY_REUSED,
            TARGET_NOT_ALLOWED, AT_CAPACITY, BUSY, VERIFIER_BUSY,
            INTERNAL_ERROR, UPSTREAM_UNAVAILABLE, UPSTREAM_RATE_LIMITED,
            WARMING, UPSTREAM_TIMEOUT, CREDIT_SYSTEM_UNAVAILABLE,
            REVIEWS_WITHHELD, SERVICE_UNAVAILABLE, UPSTREAM_FAILED. The
            rate-limit 429s use numbers: 4201 (account), 4202 (API key), 4203
            (searches in flight). Some 4xx carry their name in errorCode
            instead (see errorCode). No error is charged.
          oneOf:
            - { type: string }
            - { type: integer }
          example: CITY_LOCATION_UNAVAILABLE
        message: { type: string, description: "For people; may be reworded. Branch on code." }
        retryable: { type: boolean, description: "Whether the same request can succeed later." }
        retryAfterSec: { type: integer, description: "Seconds to wait before a retry (also the Retry-After header)." }
        errors:
          type: array
          description: Validation errors, one per field.
          items:
            type: object
            properties:
              field: { type: string }
              message: { type: string }
        suggestions: { type: array, items: { type: string }, description: "UNKNOWN_LOCATION: close matches." }
        location: { type: string, description: "CITY_LOCATION_UNAVAILABLE: the place the request named." }
        errorCode:
          type: string
          description: >
            The name on errors that carry it here instead of in code, so read
            code and fall back to errorCode: PAGE_OUT_OF_RANGE (with maxPage),
            DATE_FILTER_UNAVAILABLE, INVALID_REQUEST, PLACE_NOT_FOUND,
            TARGET_REFUSED (with targetStatus), TARGET_UNREACHABLE (with
            targetError), INSUFFICIENT_CREDITS on POST /extract. Also set with
            code on 4203 (CONCURRENCY_LIMIT), FEATURE_NOT_AVAILABLE,
            TARGET_NOT_ALLOWED, REVIEWS_WITHHELD and every 503.
        retryAfter: { type: integer, description: "Seconds to wait, on 429 4201 to 4203 and on a retryable 503." }
        maxPage: { type: integer, description: "PAGE_OUT_OF_RANGE: the last page that can be served at this limit." }
        targetStatus: { type: integer, description: "TARGET_REFUSED: the site's own HTTP status." }
        targetError: { type: string, description: "TARGET_UNREACHABLE: why the site could not be fetched." }
        details:
          type: object
          description: "INSUFFICIENT_CREDITS: required, available and shortfall."
          additionalProperties: { type: number }
        topUpUrl: { type: string, description: "INSUFFICIENT_CREDITS: a link to buy more credits." }
