> ## 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.

# Terminal scan failure guaranteed

> Fired once per scan when the scan ends without a result and a callback_url was supplied — the terminal counterpart to scan.completed. Exactly one of the two arrives for any scan that reaches an end state, so treat either as your signal to stop waiting.

The payload is a PartnerScanFailedOutput: a DIFFERENT shape from scan.completed's, with the score fields absent rather than null and a required `error` block. Do not write a handler that reads `data.geo_score` across both events — branch on the event name (or on the presence of `data.error`) first. This separation is the point of the event: a failed scan means we could not reach the models, which is not the same statement as a scan that found no visibility, and the two need different copy downstream.

Such a scan is not billed and does not consume your monthly allowance.

Signature verification is identical to scan.completed — same secret, same scheme over the exact raw body bytes. Deduplicate on (scan_id, revision).



## OpenAPI

````yaml https://www.getsurfaced.ai/api/partner/openapi.json webhook scan.failed
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: {}
components:
  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.