> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsurfaced.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Start a competitor scan

> Starts a scan and holds the request for up to ~110 seconds waiting for completion (sync-with-wait). Fast basic-tier scans return the full result inline as 200. A scan that FAILS inside the window also returns 200, carrying the terminal `PartnerScanFailedOutput` with its `error` block — so a 200 means a terminal payload is enclosed, not that the scan succeeded; branch on `status`.

If the wait budget expires the scan is still running, and you get a 202: a full preliminary payload when at least one provider has already reported, otherwise a minimal processing stub. Both carry `poll_after_ms` and `client_ref`. Poll the status endpoint or wait for the terminal webhook (`scan.completed` or `scan.failed`).



## OpenAPI

````yaml https://www.getsurfaced.ai/api/partner/openapi.json post /api/partner/competitor-scan
openapi: 3.1.0
info:
  title: Surfaced Partner API
  version: 1.0.0
  description: >-
    Competitor Scan API for Surfaced partners. Start a scan of a company's
    AI-search visibility, poll for the result, and (optionally) receive signed
    webhooks via progressive delivery.


    ## Progressive delivery


    A scan can produce up to two payload generations, distinguished by
    `revision`:


    - **Preliminary** (`status: "preliminary"`, `revision: 1`) — a best-effort
    mid-run snapshot with a live-computed `geo_score`/`band` from whatever
    observations exist so far. Returned inline on the 202 POST response (and 200
    GET polls) once at least one provider has reported, and pushed once to your
    `callback_url` as `scan.preliminary`. There is no retry backstop for the
    preliminary push — it is opportunistic.

    - **Final** (`status: "completed"`) — guaranteed for every scan that
    produces a result. Delivered as `scan.completed` with `revision: 2` when a
    preliminary was delivered first, otherwise `revision: 1`.


    Deduplicate on `(scan_id, revision)`.


    ## Failed scans


    A scan that produces no result ends as `status: "failed"`. It is terminal:
    nothing about it will change. `error.code` says which way it failed —
    `all_providers_failed` (we queried every provider you asked for and every
    call errored) or `analysis_failed` (the scan died before any provider was
    queried).


    Every scan that reaches an end state gets exactly ONE terminal signal, so
    you never have to wait on a timeout to find out which happened:


    - **`scan.completed`** — a result exists. `data` is a `PartnerScanOutput`.

    - **`scan.failed`** — no result exists. `data` is a
    `PartnerScanFailedOutput`, and it is a DIFFERENT shape: `geo_score`, `band`,
    `competitors` and `score_confidence` are ABSENT (not null), and a required
    `error` block carries a stable `code` (e.g. `all_providers_failed`), a human
    `message`, and `providers_failed`.


    That omission is deliberate and worth designing around. A failed scan means
    *we could not reach the models*, which is a different statement from *this
    company has no AI visibility* — and a `null` score is far too easily
    coalesced into `0` and rendered as the second. If your integration turns a
    scan into copy, branch on the event name (or on the presence of
    `data.error`) before reading any result field.


    **Every channel carries the same failure payload.** If you poll instead of
    using webhooks, `GET /api/partner/competitor-scan/{id}` returns **200** with
    that identical `PartnerScanFailedOutput` — `error` block included — and so
    does the create endpoint when a scan fails inside its sync-wait window. You
    never need a `callback_url` to find out why a scan failed.


    So HTTP 200 on either endpoint means *a terminal payload is enclosed*, not
    *the scan succeeded*. Check `status` (or the presence of `error`) before
    reading any score field. 202 now means the scan is still running, and
    carries `poll_after_ms`. Keep the legacy rule too: an absent `poll_after_ms`
    means stop, which now only occurs on a degraded path where we know a scan is
    terminal but could not assemble its payload.


    A failed scan is not billed and does not consume your monthly allowance.


    ## Score confidence


    `score_confidence` reflects provider coverage behind `geo_score`: the full
    basic trio (chatgpt + gemini + perplexity) reporting = `high`; partial
    coverage degrades it to `reduced` or `low`.


    ## Sandbox


    Test keys (`gsk_test_...`) exercise the full request validation, then
    short-circuit to deterministic mock responses: no scan runs, nothing is
    persisted, no rate limit is consumed, and responses carry `sandbox: true`.
    Same input always yields the same output, so integration tests can assert
    exact values.
servers:
  - url: https://www.getsurfaced.ai
    description: Production
security:
  - partnerApiKey: []
tags:
  - name: Scans
    description: Start and poll competitor scans.
  - name: History
    description: Scan history and reconciliation.
  - name: Usage
    description: Monthly usage rollups, quota headroom, and billing.
paths:
  /api/partner/competitor-scan:
    post:
      tags:
        - Scans
      summary: Start a competitor scan
      description: >-
        Starts a scan and holds the request for up to ~110 seconds waiting for
        completion (sync-with-wait). Fast basic-tier scans return the full
        result inline as 200. A scan that FAILS inside the window also returns
        200, carrying the terminal `PartnerScanFailedOutput` with its `error`
        block — so a 200 means a terminal payload is enclosed, not that the scan
        succeeded; branch on `status`.


        If the wait budget expires the scan is still running, and you get a 202:
        a full preliminary payload when at least one provider has already
        reported, otherwise a minimal processing stub. Both carry
        `poll_after_ms` and `client_ref`. Poll the status endpoint or wait for
        the terminal webhook (`scan.completed` or `scan.failed`).
      operationId: createCompetitorScan
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerScanRequest'
            example:
              company:
                url: https://example.nl
                country: NL
                industry: e-commerce
                industry_code: '52203060'
              tier: basic
              callback_url: https://partner.example.com/webhooks/surfaced
              client_ref: acct_1234
      responses:
        '200':
          description: >-
            The scan reached a TERMINAL state inside the sync-wait window. Two
            bodies: a full result (`status: "completed"`), or a
            `PartnerScanFailedOutput` with a required `error` block when the
            scan failed. Check `status` — or the presence of `error` — before
            reading any score field; a 200 says a terminal payload is enclosed,
            not that the scan succeeded. The failed body echoes `client_ref`
            like every other response from this endpoint. Test keys (gsk_test_)
            always return 200 with a deterministic completed mock plus `sandbox:
            true`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PartnerScanOutput'
                  - description: >-
                      Terminal failure: PartnerScanFailedOutput plus the
                      client_ref echo.
                    allOf:
                      - $ref: '#/components/schemas/PartnerScanFailedOutput'
                      - type: object
                        properties:
                          client_ref:
                            type:
                              - string
                              - 'null'
              examples:
                completed:
                  summary: Scan completed inside the sync-wait window
                  value:
                    scan_id: 6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c
                    tier: basic
                    status: completed
                    company:
                      url: https://example.nl
                      name: Example B.V.
                      country: NL
                    generated_at: '2026-07-14T10:32:11.000Z'
                    llms_used:
                      - openai
                      - gemini
                      - perplexity
                    prompts_count: 6
                    geo_score: 42
                    band: emerging
                    competitors:
                      - name: Bol.com
                        domain: bol.com
                        priority_score: 92
                        mentions: 18
                        prompt_consistency: 0.87
                        llm_consistency: 0.91
                        avg_rank: 1.4
                    coverage:
                      providers_requested:
                        - openai
                        - gemini
                        - perplexity
                      providers_reported:
                        - openai
                        - gemini
                        - perplexity
                      providers_missing: []
                    revision: 1
                    score_confidence: high
                    meta:
                      latency_ms: 48211
                failed:
                  summary: Scan failed inside the sync-wait window
                  value:
                    scan_id: 6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c
                    tier: basic
                    status: failed
                    company:
                      url: https://example.nl
                      name: Example B.V.
                      country: NL
                    generated_at: '2026-07-14T10:34:52.000Z'
                    llms_used:
                      - openai
                      - gemini
                      - perplexity
                    prompts_count: 6
                    coverage:
                      providers_requested:
                        - openai
                        - gemini
                        - perplexity
                      providers_reported: []
                      providers_missing:
                        - openai
                        - gemini
                        - perplexity
                    revision: 1
                    error:
                      code: all_providers_failed
                      message: >-
                        Every provider call for this scan failed, so no
                        visibility result could be produced. This scan is not
                        billed and does not consume your monthly allowance.
                      providers_failed:
                        - openai
                        - gemini
                        - perplexity
                    meta:
                      latency_ms: 61044
                    client_ref: acct_1234
        '202':
          description: >-
            Scan STILL RUNNING after the sync-wait budget — the only thing a 202
            means here. Two possible bodies: a full preliminary payload (at
            least one provider has reported — real, if partial, numbers), or a
            minimal processing stub (nothing to snapshot yet). Both echo
            `client_ref` and both carry `poll_after_ms`. A terminal failure is a
            200, not a 202 — see "Failed scans" in the API description.
          content:
            application/json:
              schema:
                oneOf:
                  - description: >-
                      Preliminary result: full PartnerScanOutput (status
                      "preliminary") plus poll_after_ms + client_ref.
                    allOf:
                      - $ref: '#/components/schemas/PartnerScanOutput'
                      - type: object
                        required:
                          - poll_after_ms
                        properties:
                          poll_after_ms:
                            type: integer
                            examples:
                              - 3000
                          client_ref:
                            type:
                              - string
                              - 'null'
                  - $ref: '#/components/schemas/ProcessingStub'
              examples:
                preliminary:
                  summary: Preliminary snapshot (some providers reported)
                  value:
                    scan_id: 6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c
                    tier: basic
                    status: preliminary
                    company:
                      url: https://example.nl
                      name: Example B.V.
                      country: NL
                    generated_at: null
                    llms_used:
                      - openai
                      - gemini
                      - perplexity
                    prompts_count: 6
                    geo_score: 38
                    band: emerging
                    competitors:
                      - name: Bol.com
                        domain: bol.com
                        priority_score: 92
                        mentions: 18
                        prompt_consistency: 0.87
                        llm_consistency: 0.91
                        avg_rank: 1.4
                    coverage:
                      providers_requested:
                        - openai
                        - gemini
                        - perplexity
                      providers_reported:
                        - openai
                        - perplexity
                      providers_missing:
                        - gemini
                    revision: 1
                    score_confidence: reduced
                    meta:
                      latency_ms: null
                    poll_after_ms: 3000
                    client_ref: acct_1234
                processing:
                  summary: Processing stub (no observations yet)
                  value:
                    scan_id: 6f1d2c3b-4a5e-4f60-9b7c-8d9e0f1a2b3c
                    tier: full
                    status: processing
                    poll_after_ms: 3000
                    client_ref: acct_1234
        '400':
          description: >-
            Invalid JSON, failed request validation (including company.url
            containing userinfo), invalid llm_set slug, or company.url resolving
            to an unscannable domain (code: unscannable_domain — a consumer mail
            provider like gmail.com; not raised for a platform/marketplace
            domain like booking.com, which is a legitimate competitor scan
            target).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidBody:
                  value:
                    error: company.url is required
                    code: invalid_request_body
                userinfo:
                  value:
                    error: >-
                      company.url must not contain userinfo (user@host). Send
                      the company website URL.
                    code: url_contains_userinfo
                fullTierOnly:
                  value:
                    error: LLM "copilot" is only available in the Full tier
                    code: invalid_llm_set
                unscannableDomain:
                  summary: company.url resolves to a consumer mail provider
                  value:
                    error: >-
                      gmail.com is an email provider, not a company website.
                      Enter the website you want to check instead.
                    code: unscannable_domain
                    domain: gmail.com
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            Partner suspended, or the requested tier is not enabled for this
            partner.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                suspended:
                  value:
                    error: partner_suspended
                    code: partner_suspended
                tierNotAllowed:
                  value:
                    error: tier_not_allowed
                    code: tier_not_allowed
                    detail: >-
                      This partner is not entitled to the "full" tier. Allowed:
                      basic.
        '429':
          description: >-
            Either the per-partner hourly scan rate limit was exceeded, or (when
            the partner has an optional overage hard cap configured) the monthly
            scan limit — included allowance plus overage cap — has been reached.
            The monthly limit resets on the 1st of the month (UTC).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                rateLimited:
                  value:
                    error: Rate limited
                    code: rate_limited
                monthlyLimitReached:
                  value:
                    error: monthly_scan_limit_reached
                    code: monthly_scan_limit_reached
                    detail: >-
                      Monthly scan limit reached (500 included + 100 overage).
                      The limit resets on the 1st of the month (UTC). Contact us
                      to raise it.
        '502':
          description: The scan could not be started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: Scan failed
                code: scan_failed
        '503':
          $ref: '#/components/responses/NotEnabled'
components:
  schemas:
    PartnerScanRequest:
      type: object
      required:
        - company
      properties:
        company:
          type: object
          required:
            - url
          properties:
            url:
              type: string
              format: uri
              description: >-
                The company's own website, authoritative for business identity,
                industry, market and output language. Third-party profiles or
                listings (such as Behance or G2) are not supported as company
                websites. Non-root Behance and G2 URLs are rejected with HTTP
                400; their root URLs can be used to scan the platforms
                themselves. Must start with http:// or https://. Must not
                contain userinfo (no user@host — e.g. https://someone@gmail.com
                is rejected, since the part before @ is valid URL syntax but
                resolves to a host you didn't intend). Consumer mail provider
                domains (gmail.com, outlook.com, icloud.com, and similar) are
                refused with 400 unscannable_domain because they cannot be a
                company website — this is a marketplace/competitor scanning API,
                not scanning your own site, so a mail domain here is always a
                mistake, not a legitimate competitor.
            name:
              type: string
              maxLength: 500
              description: >-
                Optional submitted company name, retained for reference. Does
                not override website identity or cause a submitted-name mismatch
                rejection.
            country:
              type: string
              maxLength: 100
              description: >-
                Optional submitted country, retained for reference. The scan
                infers its market from the website using the standard scan
                process. Does not override that market or skip website analysis.
            language:
              type: string
              maxLength: 20
              description: >-
                Optional submitted language, retained for reference. Output
                language is inferred from the website using the standard scan
                process, not overridden by this field.
            industry:
              type: string
              maxLength: 200
              description: >-
                Optional industry label, retained for reference without
                overriding website analysis. A numeric classification code is
                accepted but ignored for analysis — the scan derives the
                industry itself — and is echoed back unchanged as
                `company.industry_code` in the result. If you have both, send
                the label here and the code in `industry_code`.
            industry_code:
              type: string
              maxLength: 50
              description: >-
                Optional numeric industry classification code from your own
                taxonomy, sent alongside the label in `industry`. Never used for
                analysis; stored and echoed back unchanged as
                `company.industry_code` in the result. Takes precedence over a
                code-shaped `industry` value.
        tier:
          description: >-
            "basic" (default) covers the core AI assistant set. "full" adds the
            consumer AI search surfaces — Bing Copilot, Google AI Mode, and
            Google AI Overviews (copilot, google_ai_mode, google-aio). "instant"
            and "non-instant" are accepted legacy aliases of "basic" and "full"
            respectively. An array requests multiple packages: ["basic", "full"]
            runs both. Full/both requires per-partner entitlement (403
            tier_not_allowed otherwise). Billing follows the surfaces actually
            requested: a full request whose llm_set includes none of the
            full-only surfaces is billed as basic.
          oneOf:
            - type: string
              enum:
                - basic
                - full
                - instant
                - non-instant
            - type: array
              items:
                type: string
                enum:
                  - basic
                  - full
                  - instant
                  - non-instant
        llm_set:
          type: array
          items:
            type: string
            enum:
              - chatgpt
              - claude
              - gemini
              - perplexity
              - deepseek
              - grok
              - copilot
              - google_ai_mode
              - google-aio
          description: >-
            Explicit provider slugs to dispatch. Omit for the tier default:
            basic = [chatgpt, gemini, perplexity]; full/both = the basic default
            plus [copilot, google_ai_mode, google-aio]. The basic package
            accepts only the 6 assistant slugs (chatgpt, claude, gemini,
            perplexity, deepseek, grok); the 3 consumer-search surfaces are
            Full-tier only and are rejected with 400 on basic requests.
        callback_url:
          type: string
          format: uri
          description: >-
            Webhook URL for progressive delivery. When set, you receive up to
            two signed webhook fires per scan: an optional best-effort
            `scan.preliminary`, then exactly one terminal fire —
            `scan.completed` if the scan produced a result, `scan.failed` if it
            did not. See the webhooks section; note the two terminal events
            carry DIFFERENT payload shapes.
        client_ref:
          type: string
          maxLength: 500
          description: >-
            Opaque reference echoed back on 202 responses, for request
            correlation.
    PartnerScanOutput:
      type: object
      description: >-
        The single scan result contract. Returned by POST (sync completion), GET
        status polls, and inside both webhook events — partners always see this
        one shape regardless of entry point. Sandbox (test-key) responses add a
        top-level `sandbox: true` extension field.
      required:
        - scan_id
        - tier
        - status
        - company
        - generated_at
        - llms_used
        - prompts_count
        - geo_score
        - band
        - competitors
        - coverage
        - revision
        - score_confidence
        - meta
      properties:
        scan_id:
          type: string
          format: uuid
          description: Stable scan identifier.
        tier:
          type: string
          enum:
            - basic
            - full
          description: >-
            Effective tier of the scan. A request for both packages reports as
            "full" (the run includes the Full-tier surfaces). See the request
            `tier` field for how this can differ from the tier you requested
            (EXPX-1582 billing-follows-what-ran).
        status:
          type: string
          description: >-
            Scan lifecycle status. "completed" = final result. "preliminary" =
            mid-run snapshot: geo_score/band are computed live from the
            observations collected so far (best-effort, may change). "failed" =
            terminal: no result will be produced (the scan is not billed and
            does not consume your monthly allowance) — the body is a
            PartnerScanFailedOutput and its required `error` block carries the
            machine-readable reason, on the status endpoint and the
            `scan.failed` webhook alike. Other values (e.g. "processing") mirror
            internal lead state.
          examples:
            - completed
            - preliminary
            - processing
            - failed
        company:
          type: object
          required:
            - url
            - name
            - country
            - industry_code
          properties:
            url:
              type: string
              description: The scanned company URL.
            name:
              type:
                - string
                - 'null'
              description: Resolved company/brand name.
            country:
              type:
                - string
                - 'null'
              description: Resolved company country.
            industry_code:
              type:
                - string
                - 'null'
              description: >-
                The request's company.industry_code, or company.industry when
                that was a numeric classification code rather than a label —
                echoed back unchanged. Such codes are kept but never fed to
                analysis. Null when neither was sent.
            identity_status:
              type: string
              enum:
                - site_verified
                - externally_correlated
                - manual_confirmed
              description: >-
                How business identity was established. externally_correlated
                covers fetched external evidence and the gated domain-only
                provider-research fallback used when first-party and cited pages
                could not be retrieved. Absent on immutable legacy deliveries.
            identity_confidence:
              type: string
              enum:
                - high
                - medium
                - low
              description: >-
                Confidence in the resolved business identity and activity
                context. This is independent from score_confidence, which
                measures scan-provider coverage.
            context:
              $ref: '#/components/schemas/PartnerCompanyContext'
        generated_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When the final result was assembled. Null while the scan is in
            flight.
        llms_used:
          type: array
          items:
            type: string
          description: >-
            Provider IDs dispatched for this scan. NOTE: these are provider IDs,
            not request slugs — the request slug "chatgpt" appears here as
            "openai"; all other slugs are identical to their provider ID.
        prompts_count:
          type: integer
          description: Number of prompts run per provider.
        geo_score:
          type:
            - number
            - 'null'
          description: >-
            Overall GEO visibility score (0-100). On a preliminary payload this
            is a live mid-run computation and may change in the final result.
            Null until at least one provider has reported.
        band:
          type:
            - string
            - 'null'
          description: Qualitative band derived from geo_score (e.g. "emerging").
        competitors:
          type: array
          items:
            $ref: '#/components/schemas/PartnerScanCompetitor'
          description: >-
            At most 10 eligible competitor groups on newly generated results,
            strongest first. Eligibility, grouping and priority scores use the
            full assessed pool and all observation denominators before this
            presentation limit. Fewer than 10 may qualify; all accepted member
            scopes within each returned group are retained. competitor_quality
            counts describe the full assessment, not this list length.
            Previously saved deliveries remain unchanged and may contain more
            than 10 groups.
        competitor_quality:
          type: object
          description: >-
            Business identity and competitor eligibility, separate from provider
            coverage. New-version unavailable or unresolved assessments never
            return unassessed competitors. Legacy results preserve historical
            lists and are explicitly unassessed. Preliminary results may have no
            assessment yet.
          required:
            - version
            - state
            - identity
            - accepted
            - rejected
            - unresolved
            - assessment_id
          properties:
            version:
              type:
                - string
                - 'null'
            state:
              type: string
              enum:
                - legacy_unassessed
                - available
                - partial
                - unavailable
                - identity_unresolved
                - historical_unavailable
            identity:
              type: string
              enum:
                - resolved
                - unresolved
                - unassessed
            accepted:
              type: integer
              minimum: 0
            rejected:
              type: integer
              minimum: 0
            unresolved:
              type: integer
              minimum: 0
            assessment_id:
              type:
                - string
                - 'null'
            historical:
              type: string
              enum:
                - preserved
                - unavailable
        coverage:
          $ref: '#/components/schemas/Coverage'
        revision:
          type: integer
          description: >-
            Delivery revision identifying the payload generation. 1 =
            preliminary (or a final with no prior preliminary delivery); 2 =
            final after a preliminary was already delivered. Deduplicate
            deliveries on (scan_id, revision) — the same scan_id can
            legitimately arrive twice with different revisions.
        score_confidence:
          type: string
          enum:
            - high
            - reduced
            - low
          description: >-
            Provider coverage behind geo_score; competitor_quality separately
            reports identity and relevance. The scan achieved. "high" — the scan
            covered the full set of surfaces the score is calibrated on.
            "reduced" — coverage was partial; treat the score as indicative and
            consider re-scanning. "low" — significant coverage gaps; the score
            should not be relied on. See coverage.providers_missing for exactly
            which requested surfaces did not report. On a preliminary payload
            this reflects what has reported so far and can only improve in the
            final revision.
        meta:
          type: object
          required:
            - latency_ms
          properties:
            latency_ms:
              type:
                - integer
                - 'null'
              description: >-
                End-to-end scan wall-clock in milliseconds: from the moment the
                scan began dispatching to the moment it was finalized — i.e. how
                long you waited. This is NOT a per-provider or vendor API
                latency and should not be compared with one. The surfaces run in
                parallel, so the value is set by the slowest surface plus our
                own orchestration, and it is always greater than or equal to any
                single surface's own response time. When a scan is missing one
                of the two timestamps this falls back to the slowest single
                surface response observed in the run, which is a LOWER BOUND on
                the wall-clock rather than the wall-clock itself. Null while a
                scan has produced no observations yet.
            degraded_surfaces:
              type: array
              items:
                type: string
              description: >-
                DEPRECATED in favor of coverage.providers_missing (which covers
                every provider, not just Full-tier extras). Full-tier surfaces
                that returned zero non-errored observations. Absent for
                basic-tier scans and when empty.
    PartnerScanFailedOutput:
      type: object
      description: >-
        Terminal-failure payload — what a scan that produced NO result looks
        like. Delivered on EVERY channel: the `scan.failed` webhook, a 200 from
        the status endpoint, and a 200 from the create endpoint when a scan
        fails inside its sync-wait window.


        This is a DIFFERENT shape from PartnerScanOutput, not a
        PartnerScanOutput with empty values. `geo_score`, `band`, `competitors`
        and `score_confidence` are ABSENT, not null: a scan that failed has no
        score, and a null score is too easily rendered as `0` — i.e. as the
        confident claim "this company has no AI visibility", when the truth is
        "we could not reach the models". If your integration produces copy from
        a scan, branch on `error` (or on the event name) before reading any
        result field.


        `score_confidence` is absent for a second reason: it qualifies how far
        to trust `geo_score`, and there is no `geo_score` here to qualify.


        Everything retained is either identifying (`scan_id`, `tier`, `company`,
        `revision`) or diagnostic: `llms_used` + `prompts_count` are what we
        attempted, `coverage` is what came back, `generated_at` is when we gave
        up, and `meta.latency_ms` is how long you waited for the failure.
      required:
        - scan_id
        - tier
        - status
        - company
        - generated_at
        - llms_used
        - prompts_count
        - coverage
        - revision
        - error
        - meta
      properties:
        scan_id:
          type: string
          format: uuid
          description: Stable scan identifier.
        tier:
          type: string
          enum:
            - basic
            - full
          description: >-
            Effective tier of the scan — derived the same way as on a successful
            scan.
        status:
          type: string
          const: failed
          description: >-
            Always the literal "failed". The same value the status endpoint
            reports for this scan, so the polling and webhook channels agree.
        company:
          type: object
          required:
            - url
            - name
            - country
            - industry_code
          properties:
            url:
              type: string
              description: The scanned company URL.
            name:
              type:
                - string
                - 'null'
              description: Resolved company/brand name.
            country:
              type:
                - string
                - 'null'
              description: Resolved company country.
            industry_code:
              type:
                - string
                - 'null'
              description: >-
                The request's company.industry_code, or company.industry when
                that was a numeric classification code rather than a label —
                echoed back unchanged. Such codes are kept but never fed to
                analysis. Null when neither was sent.
            context:
              $ref: '#/components/schemas/PartnerCompanyContext'
        generated_at:
          type:
            - string
            - 'null'
          format: date-time
          description: When the scan was given up on — NOT when a result was assembled.
        llms_used:
          type: array
          items:
            type: string
          description: >-
            Provider IDs we REQUESTED for this scan. Named for symmetry with the
            success payload; on a failed scan none of them delivered a usable
            answer.
        prompts_count:
          type: integer
          description: Number of prompts attempted per provider.
        coverage:
          $ref: '#/components/schemas/Coverage'
        revision:
          type: integer
          description: >-
            Same monotonic generation counter as the success payload, so you can
            deduplicate every fire — including this terminal one — on (scan_id,
            revision) with one rule. 2 if a scan.preliminary payload had already
            reached you, otherwise 1.
        error:
          type: object
          description: >-
            Machine-readable failure verdict. Always present and never null on
            this payload — the reason `scan.failed` is a distinct event. Safe to
            branch on `code` alone.
          required:
            - code
            - message
            - providers_failed
          properties:
            code:
              type: string
              enum:
                - all_providers_failed
                - analysis_failed
                - needs_business_context
              description: >-
                Stable failure code. `all_providers_failed` — every provider
                call for this scan errored, so no result could be produced.
                Treat an unrecognised code as a generic failure rather than as a
                result.
            message:
              type: string
              description: >-
                Human-readable elaboration for your logs and support flows.
                Stable per `code`; do not parse it — branch on `code`.
            providers_failed:
              type: array
              items:
                type: string
              description: >-
                Provider IDs that were requested and never reported
                successfully. Equal to coverage.providers_missing for an
                all_providers_failed scan; duplicated here so an error handler
                that reads only `error` is self-contained.
            reason:
              type: string
              enum:
                - website_unreachable
                - website_access_denied
                - website_content_unreadable
                - offering_unclear
                - identity_unverified
                - evidence_insufficient
                - temporary_analysis_failure
                - unknown
              description: >-
                Optional safe diagnostic, separate from the stable code.
                Retrieval problems (unreachable, access denied, unreadable) are
                distinct from insufficient business evidence or unclear
                offering. unknown makes no claim about the cause. Historical
                saved payloads may omit guidance.
            action:
              type: string
              enum:
                - check_company_url
                - check_website_access
                - check_website_content
                - retry_scan
                - contact_support
              description: >-
                Suggested next step. Check the URL, public access or business
                content as indicated; contact_support should include the scan
                ID. retry_scan means start a new scan after a short delay, not
                keep polling this terminal scan.
            retryable:
              type: boolean
              description: >-
                True only when retained diagnostics verify
                temporary_analysis_failure. A generic analysis_failed code,
                missing evidence, unknown cause or failure of every provider
                does not by itself establish that retrying is appropriate. False
                means an automatic retry is not recommended, not that recovery
                is impossible.
            detail:
              type: string
              description: >-
                Fixed public explanation of the reason and suggested action.
                Never raw internal errors. The existing code and message remain
                unchanged; branch on code/reason/action, not this text.
        meta:
          type: object
          required:
            - latency_ms
          properties:
            latency_ms:
              type:
                - integer
                - 'null'
              description: >-
                End-to-end wall-clock in milliseconds from the start of dispatch
                to the moment the scan was given up on — i.e. how long you
                waited for this failure. Null when the run produced no
                observations at all. No `degraded_surfaces` here: with nothing
                reported, every requested surface is degraded (see coverage).
    ProcessingStub:
      type: object
      description: >-
        Minimal acknowledgement: the scan is still running and has zero
        successful observations so far, so there is nothing to snapshot yet.
        Poll GET /api/partner/competitor-scan/{id} after poll_after_ms.


        In normal operation a terminal state — completed or failed — arrives as
        a 200 with a payload, so this stub means the scan is still going. Keep
        honouring the one legacy signal anyway: if `poll_after_ms` is ABSENT,
        stop polling. That happens only on a degraded path (we know the scan is
        terminal but could not assemble its payload), and it is still the honest
        instruction when it does.
      required:
        - scan_id
        - tier
        - status
      properties:
        scan_id:
          type: string
          format: uuid
        tier:
          type: string
          enum:
            - basic
            - full
        status:
          type: string
          examples:
            - processing
            - failed
        poll_after_ms:
          type: integer
          description: >-
            Suggested minimum delay before the next status poll. Present
            whenever the scan is still running. Absent means the status is
            terminal and polling would never change the answer — stop.
          examples:
            - 3000
        client_ref:
          type:
            - string
            - 'null'
          description: >-
            Echo of the request client_ref (POST response only; absent on GET
            status stubs).
    Error:
      type: object
      required:
        - error
        - code
      properties:
        error:
          type: string
          description: >-
            Human-readable message. Free text that may interpolate per-request
            values (a domain, a tier label) — do not branch on it; branch on
            `code`.
        detail:
          type: string
          description: >-
            Optional human-readable elaboration (present on tier_not_allowed and
            monthly_scan_limit_reached).
        code:
          type: string
          enum:
            - unauthorized
            - partner_suspended
            - not_enabled
            - invalid_json
            - invalid_request_body
            - url_contains_userinfo
            - tier_not_allowed
            - invalid_llm_set
            - unscannable_domain
            - rate_limited
            - monthly_scan_limit_reached
            - invalid_limit
            - month_from_to_conflict
            - invalid_month
            - invalid_date
            - invalid_date_range
            - not_found
            - scan_failed
            - temporarily_unavailable
          description: >-
            Machine-readable failure slug, present on EVERY non-2xx response.
            Closed set; branch on this, never on `error`.
        domain:
          type: string
          description: >-
            The registrable domain that triggered an unscannable_domain
            rejection. Only present alongside code: "unscannable_domain".
    PartnerCompanyContext:
      type: object
      description: >-
        Informational context for new website-authoritative requests. Submitted
        metadata does not override website analysis. Absent on historical
        deliveries; saved payloads are never enriched retroactively. This
        comparison does not reject a scan or establish competitor relevance.
      required:
        - authority
        - submitted
        - resolved
        - name_comparison
      properties:
        authority:
          type: string
          const: website
        submitted:
          type: object
          required:
            - name
            - industry
            - country
            - language
          properties:
            name:
              type:
                - string
                - 'null'
            industry:
              type:
                - string
                - 'null'
            country:
              type:
                - string
                - 'null'
            language:
              type:
                - string
                - 'null'
          description: >-
            Retained request values; omitted or unavailable values are null.
            Industry classification codes remain in company.industry_code.
        resolved:
          type: object
          required:
            - name
            - industry
            - country
            - language
          properties:
            name:
              type:
                - string
                - 'null'
            industry:
              type:
                - string
                - 'null'
            country:
              type:
                - string
                - 'null'
            language:
              type:
                - string
                - 'null'
          description: >-
            Saved ready business profile used for this run, not the submitted
            hints. All values are null when the identity/evidence gate rejected
            the profile or it is unavailable. Country is the scan's analysis
            market, not a claim of headquarters location; language is the scan's
            analysis language.
        name_comparison:
          type: object
          required:
            - status
            - basis
            - action
          properties:
            status:
              type: string
              enum:
                - exact
                - similar_name
                - verified_alias
                - different_name
                - unknown
              description: >-
                exact means names agree after case, Unicode, spacing and
                punctuation normalization. similar_name is string resemblance
                only. verified_alias requires an explicit alias statement
                retained from a first-party page; bare model aliases do not
                qualify. different_name is informational and does not imply the
                URL or resolved business is wrong. unknown means a name is
                missing or the bounded comparison cannot be made.
            basis:
              type: string
              enum:
                - normalized_name
                - normalized_edit_similarity
                - first_party_alias_statement
                - missing_submitted_name
                - missing_resolved_name
                - comparison_limit
            action:
              type: string
              enum:
                - use_resolved_company
                - review_company_context
                - none
              description: >-
                use_resolved_company: retain the website-resolved context.
                review_company_context: optionally compare that context with the
                intended business; low resemblance alone does not require URL
                review or resubmission. none: no comparison-based action. These
                suggestions do not certify identity or the competitor list.
            similarity:
              type: number
              minimum: 0
              maximum: 1
              description: >-
                Optional deterministic string resemblance, NOT a probability or
                identity confidence. Normalized edit similarity ignores common
                legal suffixes; scores of at least 0.8 with at least four
                characters on both sides yield similar_name. Exact normalized
                equality is 1. Omitted for verified aliases and unknown
                comparisons. Computed locally without an extra model or network
                call, with names bounded to 240 characters.
    PartnerScanCompetitor:
      type: object
      required:
        - name
        - domain
        - priority_score
        - mentions
        - prompt_consistency
        - llm_consistency
        - avg_rank
      properties:
        id:
          type: string
          description: >-
            Stable assessed offer or resolver-group identifier; absent on
            historical deliveries.
        name:
          type: string
          description: >-
            Offer name, or joined member names for a resolver group; grouping
            does not establish that members are aliases.
        domain:
          type:
            - string
            - 'null'
          description: >-
            Hostname of the separately assessed official page, when supported.
            Does not retain hosted project or product paths; use website_url for
            navigation.
        website_url:
          type:
            - string
            - 'null'
          format: uri
          description: >-
            Exact supplied page selected as the candidate's official presence in
            the saved assessment. Nullable when unclear; does not fall back to a
            lookup, publisher or reseller host. Preserves hosted project/product
            paths. For groups, null when member selections differ. Historical
            deliveries may omit it; clients should prefer this URL over
            constructing a link from domain.
        scoped_alternative:
          type: string
          description: >-
            The evidenced offering or activity this alternative can provide for
            the same audience, based on its saved assessment. Describes the
            specific shared audience action or outcome, including meaningful
            partial alternatives. In a multi-member group this is one
            representative member; scoped_alternatives preserves every member
            separately. May be absent on historical deliveries.
        scope_limitation:
          type: string
          maxLength: 500
          description: >-
            Material caveat, scope limit or unresolved fact recorded in the
            accepted alternative's saved assessment. Absent when no material
            limitation was recorded or on historical deliveries; absence does
            not confirm that every fact is known.
        scoped_alternatives:
          type: array
          description: >-
            Separately accepted offerings in a resolver group. Every member
            retains its own scope, limitation and separately assessed official
            page; group membership is not semantic eligibility or verified alias
            identity. The scalar scope and limitation represent the first listed
            member, while metrics count the union of eligible occurrences.
          items:
            type: object
            required:
              - candidate_id
              - name
              - domain
              - scoped_alternative
            properties:
              candidate_id:
                type: string
              name:
                type: string
              domain:
                type:
                  - string
                  - 'null'
              website_url:
                type:
                  - string
                  - 'null'
                format: uri
              scoped_alternative:
                type: string
              scope_limitation:
                type: string
                maxLength: 500
        priority_score:
          type: number
          description: >-
            Composite prominence of the union of eligible member occurrences;
            higher = more prominent. A grouped row counts at most once per
            original answer.
        mentions:
          type: integer
          description: Total mentions across all prompts and LLMs.
        prompt_consistency:
          type: number
          description: Share of prompts in which this competitor appeared (0-1).
        llm_consistency:
          type: number
          description: Share of LLMs that surfaced this competitor (0-1).
        avg_rank:
          type:
            - number
            - 'null'
          description: Average list position when mentioned (lower = ranked higher).
    Coverage:
      type: object
      description: >-
        Per-scan provider request/response coverage — how much of what was
        requested actually reported a real (non-errored) observation. Present on
        BOTH preliminary and final output. While status is "preliminary",
        providers_missing reads as "still pending"; once status is "completed"
        it reads as "failed" (the run is done, so anything still missing never
        arrived). All values are provider IDs (see llms_used), not request
        slugs.
      required:
        - providers_requested
        - providers_reported
        - providers_missing
      properties:
        providers_requested:
          type: array
          items:
            type: string
          description: >-
            Every provider dispatched for this scan (verbatim run
            configuration).
        providers_reported:
          type: array
          items:
            type: string
          description: Providers with at least one successful (non-errored) observation.
        providers_missing:
          type: array
          items:
            type: string
          description: providers_requested minus providers_reported.
  responses:
    Unauthorized:
      description: Missing, malformed, or revoked API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Unauthorized
            code: unauthorized
    NotEnabled:
      description: The partner scan API is not enabled in this environment.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_enabled
            code: not_enabled
  securitySchemes:
    partnerApiKey:
      type: http
      scheme: bearer
      description: >-
        Partner API key as a Bearer token: `Authorization: Bearer gsk_live_...`.
        Live keys (`gsk_live_`) run real scans; test keys (`gsk_test_`) run
        request validation then return deterministic sandbox mocks (`sandbox:
        true`, nothing persisted, no rate-limit consumption). Keys are issued
        per partner and are revokable.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.