{
  "openapi": "3.1.0",
  "info": {
    "title": "Sirveil Exposure API",
    "version": "1.4.0",
    "summary": "Check one domain, or scan an identity across the whole exposure surface.",
    "description": "Two operations, at two very different scales.\n\n`POST /api/v1/verify` answers one question in about a second: **is this identity indexed on\nthis domain?**\n\n`POST /api/v1/scan` runs the full pipeline — search planning, retrieval across the broker\nregistry, breach and public-record lanes, entity resolution and ranking — and returns every\nexposure it can attribute to the identity. It takes **roughly a minute** and sometimes\nseveral; see that operation for the timeout you must configure.\n\n### The verify contract is three states, not five\n\n`indexed`, `not_indexed`, `indeterminate`.\n\nAn exposure product eventually wants two more — `indexed_not_live` and `not_indexed_live` —\nwhich distinguish \"the broker still serves this page\" from \"the search engine has a stale\nentry for a page that now 404s\". Telling those apart requires fetching the page. We have not\nbuilt confirm-fetch, so we do not return those states, because doing so would assert a fetch\nwe never performed. Every response repeats this in `contract.limitation`.\n\n`indeterminate` is a real answer and not an error. It means the search succeeded and the\nevidence was too weak to call either way. A caller that treats it as `not_indexed` is\nreporting an absence we did not observe.\n\n### Versions ship inside the response\n\nEvery response carries `pipeline_version`, `linkage_weights_version` and\n`calibration_version`, on errors as well as successes, so any answer can be reproduced\nagainst the build that produced it.\n\nWhat you were charged is on your usage record and your invoice, not in the response body.\nA response body is not an accounting document: it is produced before the call is metered,\nand a figure printed there would be the one number a caller could not reconcile against\nanything.",
    "contact": {
      "name": "Sirveil",
      "url": "https://ai.sirveil.ai"
    },
    "license": {
      "name": "Commercial",
      "url": "https://ai.sirveil.ai/docs/api"
    }
  },
  "servers": [
    {
      "url": "https://ai.sirveil.ai",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Verify",
      "description": "Single-domain indexing checks."
    },
    {
      "name": "Scan",
      "description": "Full exposure scan for one identity."
    },
    {
      "name": "Account",
      "description": "Free operations about your own key. Never billed."
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "paths": {
    "/api/v1/verify": {
      "post": {
        "tags": [
          "Verify"
        ],
        "operationId": "verifyIndexed",
        "summary": "Is this identity indexed on this domain?",
        "description": "Issues one `site:`-scoped search against the named domain, normalises the results,\nmatches them against the declared identity and returns a state with its evidence.\n\nTypical latency is 1–2 seconds (p95 measured at 1,463 ms).\n\nThe call performs **no database write**: no profile, no dossier, no stored result. The\nnarrower true statement about the identity itself is that it is used to build the search\nquery and is held in an in-memory result cache, partitioned to your account, for a few\nminutes so that a repeated question is answered consistently. It is not written to disk\nand not retained beyond that window.\n\n**A cache hit is billed and consumes quota exactly like a fresh call.** The cache makes a\nrepeat answer *consistent*, not *free* — what it saves is our provider cost, not your\ncharge. It is also per-instance and short-lived (15 minutes, 800 entries, in the memory of\none warm process), so whether a repeat is served from it is not something you can predict\nor rely on. Do not build a cost model around it.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyRequest"
              },
              "example": {
                "identity": {
                  "firstName": "Jane",
                  "lastName": "Doe",
                  "email": "jane.doe@example.com",
                  "city": "Austin",
                  "state": "TX"
                },
                "domain": "whitepages.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A verdict. Note that `indeterminate` is a 200, not an error.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/VerifyResult"
                },
                "examples": {
                  "indexed": {
                    "summary": "indexed — a matching listing was found",
                    "value": {
                      "state": "indexed",
                      "observed_at": "2026-08-13T09:14:22.104Z",
                      "domain": "whitepages.com",
                      "evidence": {
                        "url": "https://www.whitepages.com/name/Jane-Doe/Austin-TX/example",
                        "title": "Jane Doe, Austin TX — Address, Phone, Age",
                        "snippet": "Jane Doe, age 40s, Austin TX. Current address, phone numbers and relatives.",
                        "retrieved_at": "2026-08-13T09:14:21.880Z"
                      },
                      "confidence": 0.87,
                      "calibrated_probability": 0.91,
                      "linkage": {
                        "bits": 14.2,
                        "probability": 0.94,
                        "calibrated": true,
                        "weights_version": "linkage-v1"
                      },
                      "explains": [
                        "Issued 1 email-scoped query against whitepages.com.",
                        "3 of 10 results were on the requested domain.",
                        "Matched on first name, last name, city and state."
                      ],
                      "contract": {
                        "states": [
                          "indexed",
                          "not_indexed",
                          "indeterminate"
                        ],
                        "limitation": "THREE STATES ONLY. This endpoint reads a search index; it does not fetch the page. It therefore cannot distinguish an indexed-and-live listing from a stale index entry, and \"not_indexed\" means absent from the search index — not proven absent from the site. The confirm-fetch states (indexed_not_live, not_indexed_live) are NOT implemented and are never returned."
                      },
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt",
                      "meta": {
                        "identifier_used": "email",
                        "results_from_domain": 3,
                        "results_total": 10,
                        "latency_ms": 1180,
                        "search_ok": true
                      }
                    }
                  },
                  "indeterminate": {
                    "summary": "indeterminate — searched, evidence too weak to call. NOT not_indexed.",
                    "value": {
                      "state": "indeterminate",
                      "observed_at": "2026-08-13T09:16:05.550Z",
                      "domain": "spokeo.com",
                      "evidence": {
                        "url": "https://www.spokeo.com/Jane-Doe/Texas/example",
                        "title": "Jane Doe in Texas — Spokeo",
                        "snippet": "We found several people named Jane Doe in Texas.",
                        "retrieved_at": "2026-08-13T09:16:05.201Z"
                      },
                      "confidence": 0.31,
                      "calibrated_probability": 0.28,
                      "linkage": {
                        "bits": 4.1,
                        "probability": 0.42,
                        "calibrated": true,
                        "weights_version": "linkage-v1"
                      },
                      "explains": [
                        "Issued 1 name-anchored query against spokeo.com.",
                        "1 of 10 results was on the requested domain.",
                        "Matched on last name and state only; no corroborating identifier.",
                        "Evidence was too weak to call either way. This is NOT `not_indexed`."
                      ],
                      "contract": {
                        "states": [
                          "indexed",
                          "not_indexed",
                          "indeterminate"
                        ],
                        "limitation": "THREE STATES ONLY. This endpoint reads a search index; it does not fetch the page. It therefore cannot distinguish an indexed-and-live listing from a stale index entry, and \"not_indexed\" means absent from the search index — not proven absent from the site. The confirm-fetch states (indexed_not_live, not_indexed_live) are NOT implemented and are never returned."
                      },
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt",
                      "meta": {
                        "identifier_used": "name_only",
                        "results_from_domain": 1,
                        "results_total": 10,
                        "latency_ms": 1402,
                        "search_ok": true
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The request could not be parsed, or the identity/domain was unusable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or unknown API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_key": {
                    "summary": "A revoked or unknown key",
                    "value": {
                      "error": "invalid_key",
                      "detail": "That API key is not recognised, or it has been revoked.",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/SpendCeiling"
          },
          "403": {
            "$ref": "#/components/responses/Suspended"
          },
          "429": {
            "$ref": "#/components/responses/RateOrQuota"
          },
          "500": {
            "description": "The verification could not be completed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/scan": {
      "post": {
        "tags": [
          "Scan"
        ],
        "operationId": "scanIdentity",
        "summary": "Find every exposure attributable to this identity.",
        "description": "Runs the full pipeline: builds a profile from what you supply, plans and issues searches\nacross the data-broker registry and the open web, checks breach and public-record lanes,\nresolves candidates to the identity, ranks them, and returns one card per distinct\nexposure with the fields that exposure reveals and the URL each field was read from.\n\n### Configure your client timeout before you call this\n\nMeasured wall clock **on this endpoint**: **135 / 144 / 154 / 155 / 203 seconds**\n(n=5, median 154). The internal pipeline measures faster — 46 / 61 / 66 / 74 / 118, median\n66 — and that figure is quoted here only to be explicit that it is NOT what you should\nplan against: a public scan stores nothing, so it starts cold every time. Set your client\nread timeout to **at least 240 seconds**. PowerShell's `Invoke-RestMethod` defaults to\n**100 seconds**, which is below the measured median of 154 — that is the exact failure\nmeasured on go-live day: the client saw a network error, the server served and billed.\nThe defaults in `axios` (0, but 5s in many wrappers), Python `requests` behind a session\nadapter, and most fetch wrappers will abort mid-scan the same way. A dropped connection\nis not a refund. If your timeout is under 240 seconds, send `Prefer: respond-async`.\n\nIf your caller sits behind an API gateway with a fixed response budget, the synchronous\nshape will not fit and you must use the asynchronous shape below. The two ceilings that\nmatter, from each vendor's own documentation rather than from our estimate:\n\n- **RapidAPI** terminates at **180 seconds** and returns 504. That is both the default and\n  the maximum a provider may configure. Our median of 154 s fits; our measured maximum of\n  203 s does not, so the synchronous shape would 504 on the slow tail.\n- **AWS API Gateway** defaults to **29 seconds**, raisable to at most **300 seconds** by\n  Service Quotas request, and only for Regional and private REST APIs — HTTP APIs and\n  edge-optimized APIs cannot be raised at all.\n\nA 504 from a gateway is not a refund: the scan runs to completion on our side and is\nbilled. Use the asynchronous shape and the question does not arise.\n\n### Asynchronous: submit now, collect later\n\nSend `Prefer: respond-async` and you get **202** immediately with a `job_id` and\na `poll_url`. Your quota, rate limit and spend ceiling are all evaluated at submit, so a\n202 means the work is accepted and will be attempted; a refusal happens there and then,\nbefore anything is queued and before anything is charged.\n\nThen `GET /api/v1/jobs/{job_id}` until `status` is `succeeded` or `failed`. On success the\nresponse carries a `result` object that is **exactly** the body the synchronous shape\nreturns. Polling is free: it consumes no plan units and costs nothing.\n\nEvery job response publishes the current `queue` depth, your `position` in it, an\n`estimated_seconds`, and this deployment's `throughput` — so the wait is a number you can\nread rather than one you discover. Present capacity is **58 scans/hour**;\nsee `x-sirveil-async` for the constraint that sets it.\n\n**Retention.** The identity you submitted and the result are kept for 24 hours\nso you can collect them, then both are deleted and the job reports `expired`. The job\nrecord itself survives with its timings, so a late poll tells you what happened rather\nthan 404-ing. The synchronous shape still stores nothing at all.\n\n### Streaming\n\nSend `Accept: application/x-ndjson` and the response is newline-delimited JSON:\n`{\"type\":\"progress\",\"phase\":\"searching\",\"step\":4,\"steps\":7,\"at\":...}` while the scan runs,\nthen a single `{\"type\":\"result\",\"result\":{...}}` carrying **exactly** the body the\nsynchronous shape returns. On failure after the stream has opened you receive\n`{\"type\":\"error\",\"status\":503,\"error\":\"search_unavailable\",...}` — the HTTP status is\nalready 200 by then, so `status` carries the code the synchronous shape would have used.\n\nValidation and the search-availability preflight both run BEFORE the stream opens, so a\nbad request or an unavailable backend is still a real 400/503 with a JSON body.\n\n### What a scan does not do\n\nIt accumulates nothing across calls. There is no\ndossier, no history, and no `times_seen`: two scans of the same person are two\nindependent observations, and nothing from an earlier call is carried forward. The\nsynchronous shape performs **no database write** at all; the asynchronous shape has to\nhold your input and your answer for the 24-hour retention window described above, and\nthat is the only difference. It also does not fetch and confirm each listing — a card means\n\"this record is indexed and matches the identity you declared\", not \"this page is live\nnow\". Both caveats ship inside every response in `contract.limitation`.\n\nAn empty `cards` array is not proof of absence. Check `search_ok` and `degraded` first.",
        "parameters": [
          {
            "name": "Prefer",
            "in": "header",
            "required": false,
            "description": "Send `respond-async` (RFC 7240) to submit the scan as a job and receive 202 with a `job_id` instead of waiting. Omit it for the synchronous shape, which is the default and is unchanged.",
            "schema": {
              "type": "string",
              "enum": [
                "respond-async"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScanRequest"
              },
              "example": {
                "identity": {
                  "firstName": "Jane",
                  "lastName": "Doe",
                  "city": "Austin",
                  "state": "TX",
                  "postalCode": "78701",
                  "email": "jane.doe@example.com"
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The scan completed. `cards` may be empty — read `search_ok` and `degraded` before treating that as \"not exposed\".",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanResult"
                }
              },
              "application/x-ndjson": {
                "schema": {
                  "$ref": "#/components/schemas/ScanStreamEvent"
                }
              }
            }
          },
          "202": {
            "description": "Only when `Prefer: respond-async` was sent. The scan is queued; poll `poll_url`. Nothing has been charged yet — the job is metered when it runs.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                },
                "description": "Same value as `poll_url`, for clients that follow the header."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                },
                "examples": {
                  "accepted": {
                    "summary": "Queued. Poll `poll_url` until `status` is `succeeded` or `failed`.",
                    "value": {
                      "job_id": "0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "status": "queued",
                      "poll_url": "/api/v1/jobs/0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "estimated_seconds": 154,
                      "queue": {
                        "depth": 0,
                        "position": 1
                      },
                      "throughput": {
                        "capacity_per_hour": 58,
                        "drain_concurrency": 1,
                        "scan_seconds_median": 154,
                        "scan_seconds_max": 203,
                        "result_retention_hours": 24
                      },
                      "created_at": "2026-08-13T09:20:00.000Z",
                      "expires_at": "2026-08-14T09:20:00.000Z",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The body could not be parsed, `identity` was missing, `firstName`/`lastName` were absent, or a `seed` was supplied (this endpoint scans the identity you send).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or unknown API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "$ref": "#/components/responses/SpendCeiling"
          },
          "403": {
            "$ref": "#/components/responses/Suspended"
          },
          "429": {
            "$ref": "#/components/responses/RateOrQuota"
          },
          "500": {
            "description": "The scan could not be completed. Nothing is billed for a failed scan.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Search is unavailable, so the scan was refused (or stopped) rather than returning an empty result that would read as \"nothing found\". Nothing is billed. Retry shortly. Also returned as `async_not_available` if this deployment cannot accept jobs, in which case the synchronous shape still works.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "async_not_available": {
                    "summary": "The queue is not enabled here. Drop the Prefer header and run it synchronously.",
                    "value": {
                      "error": "async_not_available",
                      "detail": "Asynchronous scans are not enabled on this deployment. Nothing was charged. Send the same request without the `Prefer: respond-async` header to run the scan synchronously, and note that a synchronous scan takes about 154 seconds (up to 203).",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs/{job_id}": {
      "get": {
        "tags": [
          "Scan"
        ],
        "operationId": "getScanJob",
        "summary": "Poll an asynchronous scan.",
        "description": "Status of a job submitted with `Prefer: respond-async`, and its result once it has one.\n\n`status` is one of `queued`, `claimed` (running), `succeeded`, `failed`, `expired`.\nOn `succeeded` the `result` field carries exactly the body a synchronous scan returns.\n\nWhile a job is waiting, `queue.position` is your 1-based place in the queue and\n`estimated_seconds` is how long the answer should take from now. The queue is shared\nacross all customers, which is why the depth is reported as a whole rather than as your\nshare of it — publishing a per-account depth would give you a confidently wrong estimate.\n\n**Polling is free.** It bills nothing and consumes no plan units, so poll as often as the\nrate limit allows. There is no advantage to polling slowly.\n\n**A job id that is not yours returns 404, not 403.** Deliberately: a 403 would confirm the\nid exists, which turns a job id into a way to ask whether someone else is a customer.\n\nJobs `expired` after 24 hours keep their timings and lose their data. Poll before then.",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "description": "The `job_id` from the 202 response.",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The job, and its result if it has finished successfully.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/JobStatus"
                },
                "examples": {
                  "running": {
                    "summary": "Still waiting. `queue.position` and `estimated_seconds` say how long.",
                    "value": {
                      "job_id": "0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "status": "queued",
                      "poll_url": "/api/v1/jobs/0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "created_at": "2026-08-13T09:20:00.000Z",
                      "started_at": null,
                      "finished_at": null,
                      "estimated_seconds": 216,
                      "queue": {
                        "depth": 1,
                        "position": 2
                      },
                      "throughput": {
                        "capacity_per_hour": 58,
                        "drain_concurrency": 1,
                        "scan_seconds_median": 154,
                        "scan_seconds_max": 203,
                        "result_retention_hours": 24
                      },
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  },
                  "succeeded": {
                    "summary": "`result` is byte-identical to a synchronous scan body. One card shown; a real scan returns as many as it finds.",
                    "value": {
                      "job_id": "0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "status": "succeeded",
                      "poll_url": "/api/v1/jobs/0f6a1c2e-9d3b-4a71-9c55-3e1b2a4d8f10",
                      "created_at": "2026-08-13T09:20:00.000Z",
                      "started_at": "2026-08-13T09:21:03.000Z",
                      "finished_at": "2026-08-13T09:23:37.000Z",
                      "throughput": {
                        "capacity_per_hour": 58,
                        "drain_concurrency": 1,
                        "scan_seconds_median": 154,
                        "scan_seconds_max": 203,
                        "result_retention_hours": 24
                      },
                      "result": {
                        "scan_id": "b31d9e77-2f04-4c8a-8d61-6a0f5c7e2b19",
                        "observed_at": "2026-08-13T09:23:37.000Z",
                        "latency_ms": 154000,
                        "cards": [
                          {
                            "id": "card_1",
                            "rank": 1,
                            "tier": "A",
                            "data_type": "person_record",
                            "risk_category": "identity_theft",
                            "why_it_matters": "A people-search listing that ties your name to a current address and phone number is the starting point for most account-takeover attempts.",
                            "source": "example-broker.com",
                            "raw_data": {
                              "title": "Jane Doe, Austin TX — Address & Phone",
                              "snippet": "Jane Doe, Austin TX. Current address, phone numbers and relatives.",
                              "link": "https://example-broker.com/p/jane-doe-austin-tx"
                            },
                            "exposed_fields": [
                              {
                                "kind": "address",
                                "label": "Address",
                                "value": "1200 Example St, Austin, TX 78701",
                                "evidence": "declared",
                                "source_ref": "https://example-broker.com/p/jane-doe-austin-tx"
                              },
                              {
                                "kind": "age",
                                "label": "Age",
                                "value": "40s",
                                "evidence": "unverified",
                                "derived": "decade",
                                "source_ref": "https://example-broker.com/p/jane-doe-austin-tx"
                              },
                              {
                                "kind": "relative",
                                "label": "Relative",
                                "value": "J. D.",
                                "evidence": "unverified",
                                "source_ref": "https://example-broker.com/p/jane-doe-austin-tx"
                              }
                            ],
                            "sources": [
                              {
                                "name": "example-broker.com",
                                "link": "https://example-broker.com/p/jane-doe-austin-tx"
                              }
                            ],
                            "also_seen_on": [
                              {
                                "name": "example-directory.com",
                                "link": "https://example-directory.com/jane-doe"
                              }
                            ],
                            "match_probability": 0.9,
                            "match_probability_calibrated": true
                          }
                        ],
                        "metrics": {
                          "card_count": 1,
                          "distinct_exposures": 1,
                          "tier_a": 1,
                          "tier_b": 0,
                          "tier_c": 0
                        },
                        "degraded": false,
                        "search_ok": true,
                        "stopped_early": false,
                        "dropped_fields": [],
                        "contract": {
                          "limitation": "A scan reads search indexes and public sources at the moment you call it. It does NOT fetch and confirm every listing, so a card means \"this record was indexed and matches the identity you declared\", not \"this page is live now\". Card confidence is a probability, never a proof of identity, and an empty result is not proof of absence — check `search_ok` and `degraded` before reading one as such. Results are computed fresh for this call and not accumulated: two scans of the same person are two independent observations, and nothing from an earlier call is carried forward. If you submit a scan asynchronously (`Prefer: respond-async`), the identity you sent and the result are stored for 24 hours so that you can retrieve them, then both are deleted — the job record itself survives with its timings so a late poll can still tell you what happened. Nothing is carried into a later scan either way."
                        },
                        "pipeline_version": "v11.0.0",
                        "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                        "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                      },
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or unknown API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_key": {
                    "summary": "A revoked or unknown key",
                    "value": {
                      "error": "invalid_key",
                      "detail": "That API key is not recognised, or it has been revoked.",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Suspended"
          },
          "404": {
            "description": "No job with that id on your account. Also the answer for another account's job id, a malformed id, and a job purged after its retention window.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "job_not_found": {
                    "summary": "Unknown, expired, malformed — or somebody else's. All four look the same.",
                    "value": {
                      "error": "job_not_found",
                      "detail": "No job with that id. Job ids are scoped to your account, and results are kept for 24 hours.",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateOrQuota"
          },
          "503": {
            "description": "This deployment cannot hold jobs (`async_not_available`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/whoami": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "whoami",
        "summary": "Check a key, and see what is left on it.",
        "description": "Returns the account this key belongs to, its plan, the units left in the current quota\nwindow, the headroom left under the spend ceiling, and which billing rail owns the\naccount.\n\n**This call is free.** It consumes no plan units, makes no search-provider call and\ncosts nothing. Use it as a connection test, as a health check, and to read your own\nremaining quota before submitting a batch — there is no reason to ration it beyond the\nper-minute rate limit, which does apply.\n\nBecause it spends nothing, the quota and spend-ceiling refusals cannot fire on it: an\naccount that is out of units still gets a 200 here. That is deliberate. A customer at\ntheir ceiling is exactly the customer who needs to be able to ask what their ceiling is.\n\nIt reports nothing about any other account, and nothing about our costs.",
        "responses": {
          "200": {
            "description": "The account behind this key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WhoAmI"
                },
                "examples": {
                  "ok": {
                    "summary": "A Developer-plan account, part way through its window",
                    "value": {
                      "tenant": {
                        "id": "3f2b8c10-4a5d-4f7e-9b21-8c6d0e4a1b33",
                        "name": "Acme Integrations"
                      },
                      "plan": {
                        "id": "developer",
                        "label": "Developer"
                      },
                      "quota": {
                        "included_units": 14000,
                        "used_units": 412,
                        "remaining_units": 13588,
                        "window_resets_at": "2026-09-01T00:00:00.000Z"
                      },
                      "spend_ceiling": {
                        "ceiling_usd": 25,
                        "spent_usd": 4.12,
                        "remaining_usd": 20.88
                      },
                      "billing_rail": "stripe",
                      "channel": "api_key",
                      "rate_limit_per_minute": 60,
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or unknown API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "invalid_key": {
                    "summary": "A revoked or unknown key",
                    "value": {
                      "error": "invalid_key",
                      "detail": "That API key is not recognised, or it has been revoked.",
                      "pipeline_version": "v11.0.0",
                      "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                      "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/Suspended"
          },
          "429": {
            "description": "The per-minute burst limit. `quota_exceeded` cannot occur here — this endpoint consumes no units.",
            "headers": {
              "Retry-After": {
                "schema": {
                  "type": "integer"
                },
                "description": "Seconds to wait."
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your API key as a bearer token: `Authorization: Bearer sk_live_...`. Keys are prefixed `sk_live_` or `sk_test_` so a leaked key is identifiable on sight. We store only a hash — a lost key cannot be recovered, only revoked and reissued."
      }
    },
    "responses": {
      "SpendCeiling": {
        "description": "The account reached its spend ceiling — the dollar cap on what we will spend serving it in the current window. `error` is `spend_ceiling_reached`. Raise the ceiling or wait for the window to reset; the detail names the reset time. This is NOT the plan quota, which is a 429.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Suspended": {
        "description": "The account is suspended or closed. `error` is `tenant_suspended`. Not retryable — the key is valid and the account is not.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateOrQuota": {
        "description": "Two distinct refusals share this status and `error` tells them apart. `rate_limited` is the per-minute burst limit and clears in seconds. `quota_exceeded` means the plan units for this billing window are used up and it clears when the window turns over — note that a scan consumes far more units than a verification, so a small number of scans can exhaust a plan measured in units. `Retry-After` carries the honest wait for both, which for a quota refusal is the seconds remaining in the window. The plan allowance is a HARD CAP: exhausting it refuses calls, it does not bill overage. See `x-sirveil-pricing.quota_policy`.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds to wait."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "quota_exceeded": {
                "summary": "The plan window is used up. Retry-After is the seconds until it turns over.",
                "value": {
                  "error": "quota_exceeded",
                  "detail": "Monthly quota of 14000 units reached on the Developer plan — 14000 used, 0 remaining, and this call costs 1 unit. Resets 2026-09-01T00:00:00.000Z. Upgrade to continue.",
                  "pipeline_version": "v11.0.0",
                  "linkage_weights_version": "linkage-v1@2026-08-04T01:49:53.052Z@f3:u6:x1",
                  "calibration_version": "calibration-v1@2026-08-04T01:43:19.938Z@platt"
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "VerifyIdentity": {
        "type": "object",
        "required": [
          "firstName",
          "lastName"
        ],
        "description": "The identity you are asking about. `firstName` and `lastName` are the anchor and are always required. Beyond the name the fields are NOT equivalent: `phone`, `email` and `street` are IDENTIFIERS and at least one is needed for a decidable answer, while `middleName`, `city`, `state` and `dob` only sharpen the match. Name plus city and state is still `name_only` and still returns `indeterminate`. See `VerifyRequest`.",
        "properties": {
          "firstName": {
            "type": "string",
            "maxLength": 200
          },
          "lastName": {
            "type": "string",
            "maxLength": 200
          },
          "middleName": {
            "type": "string",
            "maxLength": 200
          },
          "city": {
            "type": "string",
            "maxLength": 200
          },
          "state": {
            "type": "string",
            "maxLength": 32,
            "description": "Two-letter code or full name."
          },
          "street": {
            "type": "string",
            "maxLength": 200,
            "description": "IDENTIFIER. Street address. Third in precedence after phone and email."
          },
          "phone": {
            "type": "string",
            "maxLength": 32,
            "description": "IDENTIFIER, highest precedence. Any format; reduced to 10 digits."
          },
          "email": {
            "type": "string",
            "maxLength": 200,
            "description": "IDENTIFIER, second in precedence after phone."
          },
          "dob": {
            "type": "string",
            "maxLength": 32,
            "description": "ISO date, or a year alone. Not an identifier."
          }
        }
      },
      "VerifyRequest": {
        "type": "object",
        "required": [
          "identity",
          "domain"
        ],
        "description": "AN IDENTIFIER IS REQUIRED FOR A DECIDABLE ANSWER. Supply at least one of `identity.phone`, `identity.email` or `identity.street`. This is a hard rule in the implementation, not a quality gradient: a request carrying only a name (with or without city and state) is classified `name_only` and returns `state: \"indeterminate\"` immediately, without reference to what the search found — because a name match alone cannot distinguish your subject from anyone who shares the name, and reporting it as `indexed` would be us guessing on your behalf. `firstName` and `lastName` are still required in addition, since the name is the anchor every candidate is scored against.",
        "properties": {
          "identity": {
            "$ref": "#/components/schemas/VerifyIdentity"
          },
          "domain": {
            "type": "string",
            "maxLength": 253,
            "description": "Bare hostname, e.g. `whitepages.com`. A full URL is accepted and reduced.",
            "examples": [
              "whitepages.com",
              "spokeo.com"
            ]
          },
          "include_query_plan": {
            "type": "boolean",
            "default": false,
            "description": "Echo the executed queries back in `query_plan`. For debugging an answer you did not expect. Honoured on paid plans only — on restricted internal keys the flag is accepted and `query_plan` is omitted, because the retrieval strategy is not something a such a key should be able to enumerate."
          }
        }
      },
      "Evidence": {
        "type": [
          "object",
          "null"
        ],
        "description": "The single best-matching result. Null when the domain returned nothing.",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri"
          },
          "title": {
            "type": "string"
          },
          "snippet": {
            "type": "string"
          },
          "retrieved_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "VerifyResult": {
        "type": "object",
        "required": [
          "state",
          "observed_at",
          "domain",
          "explains",
          "contract",
          "pipeline_version"
        ],
        "properties": {
          "state": {
            "type": "string",
            "enum": [
              "indexed",
              "not_indexed",
              "indeterminate"
            ],
            "description": "`indexed` — a matching listing was found. `not_indexed` — the domain was searched and nothing matched. `indeterminate` — searched, but the evidence was too weak to call. Do not collapse `indeterminate` into `not_indexed`."
          },
          "observed_at": {
            "type": "string",
            "format": "date-time"
          },
          "domain": {
            "type": "string"
          },
          "evidence": {
            "$ref": "#/components/schemas/Evidence"
          },
          "confidence": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Rubric confidence for the best candidate. 0 when there is none."
          },
          "calibrated_probability": {
            "type": [
              "number",
              "null"
            ],
            "description": "Confidence mapped through the calibration curve. Null when unavailable."
          },
          "linkage": {
            "type": [
              "object",
              "null"
            ],
            "description": "Declared-only linkage score — how much the matched fields identify one person.",
            "properties": {
              "bits": {
                "type": "number"
              },
              "probability": {
                "type": "number"
              },
              "calibrated": {
                "type": "boolean"
              },
              "weights_version": {
                "type": "string"
              }
            }
          },
          "explains": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Why this state, in order. Always populated — an unexplained verdict is not an answer."
          },
          "query_plan": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "description": "The exact queries issued. Present ONLY when a PAID plan set `include_query_plan: true` — it is for debugging a surprising verdict, and it is off by default, and available on every published plan, because the retrieval strategy is not part of the answer."
          },
          "contract": {
            "type": "object",
            "properties": {
              "states": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "limitation": {
                "type": "string",
                "description": "The standing caveat about the two unbuilt \"live\" states."
              }
            }
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          },
          "meta": {
            "type": "object",
            "properties": {
              "identifier_used": {
                "type": "string",
                "enum": [
                  "phone",
                  "email",
                  "street",
                  "name_only"
                ]
              },
              "results_from_domain": {
                "type": "integer"
              },
              "results_total": {
                "type": "integer"
              },
              "latency_ms": {
                "type": "integer"
              },
              "search_ok": {
                "type": "boolean",
                "description": "Did retrieval succeed. False is what separates `indeterminate` from `not_indexed`: we could not look, so absence was not observed."
              }
            }
          }
        }
      },
      "ScanIdentity": {
        "type": "object",
        "required": [
          "firstName",
          "lastName"
        ],
        "description": "Who to scan for. Only the name is required, and the scan degrades gracefully without the rest — but every field you add narrows retrieval and raises the confidence of the cards that come back. A value we cannot use is DROPPED rather than rejected, and its field name is returned in `dropped_fields` so a silently-ignored input is visible.",
        "properties": {
          "firstName": {
            "type": "string",
            "maxLength": 80
          },
          "lastName": {
            "type": "string",
            "maxLength": 80
          },
          "middleName": {
            "type": "string",
            "maxLength": 80
          },
          "namePrefix": {
            "type": "string",
            "maxLength": 10
          },
          "nameSuffix": {
            "type": "string",
            "maxLength": 10
          },
          "preferredName": {
            "type": "string",
            "maxLength": 80
          },
          "previousNames": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10
          },
          "aliases": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10
          },
          "email": {
            "type": "string",
            "maxLength": 254
          },
          "additionalEmails": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10
          },
          "phone": {
            "type": "string",
            "description": "Any format; the 10-digit NANP core is kept."
          },
          "additionalPhones": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10
          },
          "streetAddress1": {
            "type": "string",
            "maxLength": 120
          },
          "streetAddress2": {
            "type": "string",
            "maxLength": 120
          },
          "city": {
            "type": "string",
            "maxLength": 60
          },
          "state": {
            "type": "string",
            "description": "Two-letter code or full state name."
          },
          "postalCode": {
            "type": "string",
            "description": "ZIP or ZIP+4."
          },
          "country": {
            "type": "string",
            "default": "US"
          },
          "previousAddresses": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "type": "object",
              "properties": {
                "street": {
                  "type": "string"
                },
                "city": {
                  "type": "string"
                },
                "state": {
                  "type": "string"
                },
                "zip": {
                  "type": "string"
                }
              }
            },
            "description": "Prior addresses. These drive the historical search pass."
          },
          "previousLocations": {
            "type": "array",
            "maxItems": 10,
            "items": {
              "type": "object",
              "properties": {
                "city": {
                  "type": "string"
                },
                "state": {
                  "type": "string"
                }
              }
            },
            "description": "Prior city/state with no known street."
          },
          "dateOfBirth": {
            "type": "string",
            "description": "ISO `yyyy-mm-dd`."
          },
          "approximateAge": {
            "type": "integer",
            "minimum": 16,
            "maximum": 110
          },
          "employer": {
            "type": "string",
            "maxLength": 120
          },
          "jobTitle": {
            "type": "string",
            "maxLength": 120
          },
          "relatives": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10,
            "description": "Known relatives, used for corroboration. Relative names are MASKED to initials in the response — they are third parties and did not agree to be in an API payload."
          },
          "usernames": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 10
          }
        }
      },
      "ScanRequest": {
        "type": "object",
        "required": [
          "identity"
        ],
        "properties": {
          "identity": {
            "$ref": "#/components/schemas/ScanIdentity"
          }
        }
      },
      "ScanExposedField": {
        "type": "object",
        "required": [
          "kind",
          "label",
          "value"
        ],
        "description": "One piece of personal data this exposure reveals.",
        "properties": {
          "kind": {
            "type": "string",
            "examples": [
              "address",
              "phone",
              "age",
              "relative",
              "email"
            ]
          },
          "label": {
            "type": "string",
            "description": "Display label for `kind`."
          },
          "value": {
            "type": "string"
          },
          "evidence": {
            "type": "string",
            "enum": [
              "declared",
              "corroborated",
              "unverified"
            ],
            "description": "`declared` — matches what you supplied. `corroborated` — independently seen on another source. `unverified` — read from this page and not confirmed elsewhere."
          },
          "derived": {
            "type": "string",
            "description": "How the value was obtained when it was not stated outright, e.g. `decade` for an age band."
          },
          "source_ref": {
            "type": "string",
            "format": "uri",
            "description": "The page this value was read from. Always a URL when present."
          }
        }
      },
      "ScanCard": {
        "type": "object",
        "required": [
          "id",
          "rank",
          "tier",
          "data_type",
          "risk_category",
          "source",
          "exposed_fields"
        ],
        "description": "One distinct exposure.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Identifies this card WITHIN this response only. Deliberately not stable across calls: nothing is persisted, so there is no later call that could reference it."
          },
          "rank": {
            "type": "integer",
            "description": "Position in the ranked list. 1 is the most significant."
          },
          "tier": {
            "type": "string",
            "enum": [
              "A",
              "B",
              "C"
            ],
            "description": "Attribution strength. `A` — this is the subject. `B` — likely the subject. `C` — possibly the subject. Treat `C` as a lead, not a finding."
          },
          "data_type": {
            "type": "string",
            "examples": [
              "person_record",
              "breach",
              "court_record",
              "social_profile"
            ]
          },
          "risk_category": {
            "type": "string",
            "examples": [
              "identity_theft",
              "physical_safety",
              "harassment"
            ]
          },
          "why_it_matters": {
            "type": "string",
            "description": "Plain-language consequence of this exposure."
          },
          "source": {
            "type": "string",
            "description": "The site or broker exposing it. Theirs, not ours."
          },
          "raw_data": {
            "type": "object",
            "description": "The indexed page as retrieved.",
            "properties": {
              "title": {
                "type": "string"
              },
              "snippet": {
                "type": "string"
              },
              "link": {
                "type": "string",
                "format": "uri"
              }
            }
          },
          "exposed_fields": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScanExposedField"
            }
          },
          "provenance": {
            "type": "array",
            "description": "Field-level audit trail: which value came from which page.",
            "items": {
              "type": "object",
              "properties": {
                "field": {
                  "type": "string"
                },
                "value": {
                  "type": "string"
                },
                "source_ref": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "sources": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "name"
              ],
              "properties": {
                "name": {
                  "type": "string"
                },
                "link": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "also_seen_on": {
            "type": "array",
            "description": "Other sites carrying the same exposure, merged into this card.",
            "items": {
              "type": "object",
              "required": [
                "name"
              ],
              "properties": {
                "name": {
                  "type": "string"
                },
                "link": {
                  "type": "string",
                  "format": "uri"
                }
              }
            }
          },
          "neighborhood": {
            "type": "string",
            "description": "Area name when the exposure reveals a locality."
          },
          "predicted_relative": {
            "type": "boolean",
            "description": "This record is probably a relative of the subject rather than the subject."
          },
          "match_probability": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "How likely this record is about the subject, to one decimal place. ABSENT on an `attack_path` card, where `severity` is the meaningful field and there is no identity claim to express."
          },
          "match_probability_calibrated": {
            "type": "boolean",
            "description": "Whether `match_probability` came through the calibration curve or is the raw score."
          },
          "severity": {
            "type": "string",
            "enum": [
              "low",
              "medium",
              "high"
            ],
            "description": "Severity of a synthesised exposure chain. Only on `attack_path` cards."
          }
        }
      },
      "ScanResult": {
        "type": "object",
        "required": [
          "scan_id",
          "observed_at",
          "cards",
          "metrics",
          "contract",
          "pipeline_version"
        ],
        "properties": {
          "scan_id": {
            "type": "string",
            "format": "uuid",
            "description": "Identifies this response. Fresh per call, not derived from the identity, and not a handle for a later request — nothing is stored."
          },
          "observed_at": {
            "type": "string",
            "format": "date-time"
          },
          "latency_ms": {
            "type": "integer"
          },
          "cards": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScanCard"
            }
          },
          "metrics": {
            "type": "object",
            "properties": {
              "card_count": {
                "type": "integer"
              },
              "distinct_exposures": {
                "type": "integer"
              },
              "tier_a": {
                "type": "integer"
              },
              "tier_b": {
                "type": "integer"
              },
              "tier_c": {
                "type": "integer"
              }
            }
          },
          "degraded": {
            "type": "boolean",
            "description": "Part of retrieval failed. The card list is incomplete; absence proves nothing."
          },
          "search_ok": {
            "type": "boolean",
            "description": "Did retrieval work at all. `false` with zero cards means we could not look — not that there is nothing to find."
          },
          "stopped_early": {
            "type": "boolean",
            "description": "The run hit its time budget or an upstream error and returned what it had."
          },
          "dropped_fields": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Names of `identity` fields whose value could not be used and was ignored. Empty on a clean request. Check this if a scan came back thinner than you expected."
          },
          "coverage": {
            "type": [
              "object",
              "null"
            ],
            "description": "WHICH DOMAINS THIS RUN ACTUALLY ASKED. Read this before treating an empty result as an absence. `queried_empty` is a real negative — we asked and the domain holds nothing on this identity. `never_queried` is OUR gap: we did not ask, so the silence says nothing about the domain. The two are opposite in meaning and were previously indistinguishable in the response. Null on a run that predates coverage recording.",
            "properties": {
              "expected": {
                "type": "integer",
                "description": "Domains this run was expected to cover."
              },
              "queried_hit": {
                "type": "integer",
                "description": "Asked, and something came back."
              },
              "queried_empty": {
                "type": "integer",
                "description": "Asked, and every query returned zero results. A real negative."
              },
              "queried_failed": {
                "type": "integer",
                "description": "Asked, and the provider failed. We do not know what these hold."
              },
              "never_queried": {
                "type": "integer",
                "description": "Not asked in this run. Our gap, not the domain’s answer."
              },
              "never_queried_domains": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "The domains we did not ask, by name, so a gap you can see is a gap you can raise with us. Capped per response; see `never_queried_truncated`."
              },
              "never_queried_truncated": {
                "type": "boolean",
                "description": "`never_queried_domains` was capped and is not the whole list."
              }
            }
          },
          "contract": {
            "type": "object",
            "properties": {
              "limitation": {
                "type": "string",
                "description": "The standing caveats, shipped in every response."
              }
            }
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          }
        }
      },
      "ScanStreamEvent": {
        "description": "One line of the NDJSON stream. Exactly one `result` OR one `error` arrives last; every line before it is a `progress` event.",
        "oneOf": [
          {
            "type": "object",
            "required": [
              "type",
              "phase",
              "step",
              "steps"
            ],
            "properties": {
              "type": {
                "const": "progress"
              },
              "phase": {
                "type": "string",
                "enum": [
                  "starting",
                  "building_profile",
                  "planning_searches",
                  "searching",
                  "matching",
                  "assembling",
                  "finishing"
                ]
              },
              "step": {
                "type": "integer",
                "description": "1-based position of `phase`. Monotonic."
              },
              "steps": {
                "type": "integer"
              },
              "at": {
                "type": "integer",
                "description": "Epoch milliseconds."
              }
            }
          },
          {
            "type": "object",
            "required": [
              "type",
              "result"
            ],
            "properties": {
              "type": {
                "const": "result"
              },
              "result": {
                "$ref": "#/components/schemas/ScanResult"
              }
            }
          },
          {
            "type": "object",
            "required": [
              "type",
              "status",
              "error"
            ],
            "description": "A failure after the stream opened. `status` is the code the synchronous shape would return.",
            "properties": {
              "type": {
                "const": "error"
              },
              "status": {
                "type": "integer",
                "examples": [
                  402,
                  500,
                  503
                ]
              },
              "error": {
                "type": "string"
              },
              "detail": {
                "type": "string"
              }
            }
          }
        ]
      },
      "Throughput": {
        "type": "object",
        "description": "What this deployment can do, published rather than discovered. Read `capacity_per_hour` before you queue a batch: the queue is shared and a large submission delays your own later jobs as much as anyone else's.",
        "properties": {
          "capacity_per_hour": {
            "type": "integer",
            "description": "Upper bound on scans per hour: the worker is invoked once a minute and runs `drain_concurrency` scans per invocation. Computed from that schedule rather than observed under load, so treat it as a ceiling and not a rate — it makes no allowance for a slow supplier or a cold start. Size a batch against `estimated_seconds`, which is what your position in the queue actually implies."
          },
          "drain_concurrency": {
            "type": "integer",
            "description": "Scans run at once by the worker."
          },
          "scan_seconds_median": {
            "type": "integer",
            "description": "Measured median runtime of one scan."
          },
          "scan_seconds_max": {
            "type": "integer",
            "description": "Measured worst-case runtime of one scan."
          },
          "result_retention_hours": {
            "type": "integer"
          }
        }
      },
      "JobQueue": {
        "type": "object",
        "properties": {
          "depth": {
            "type": "integer",
            "description": "Jobs waiting, across all customers."
          },
          "position": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1-based place in that queue. Null once the job has started running."
          }
        }
      },
      "JobAccepted": {
        "type": "object",
        "required": [
          "job_id",
          "status",
          "poll_url",
          "estimated_seconds"
        ],
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued"
            ]
          },
          "poll_url": {
            "type": "string",
            "description": "Path to poll. Absolute path on this host."
          },
          "estimated_seconds": {
            "type": "integer",
            "description": "Expected seconds until the answer is ready, from now, including this job's own runtime."
          },
          "queue": {
            "$ref": "#/components/schemas/JobQueue"
          },
          "throughput": {
            "$ref": "#/components/schemas/Throughput"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "After this, the identity you sent and the result are deleted."
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          }
        }
      },
      "JobStatus": {
        "type": "object",
        "required": [
          "job_id",
          "status",
          "poll_url",
          "throughput"
        ],
        "properties": {
          "job_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "claimed",
              "succeeded",
              "failed",
              "expired"
            ],
            "description": "`claimed` means running. `expired` means the retention window passed and the data is gone — the record remains so a late poll is not a 404."
          },
          "poll_url": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "finished_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "estimated_seconds": {
            "type": "integer",
            "description": "Present only while the job is waiting or running."
          },
          "queue": {
            "$ref": "#/components/schemas/JobQueue"
          },
          "throughput": {
            "$ref": "#/components/schemas/Throughput"
          },
          "result": {
            "allOf": [
              {
                "$ref": "#/components/schemas/ScanResult"
              }
            ],
            "description": "Present only on `succeeded`, and byte-identical to the synchronous response body for the same run. There is one shaper and one output."
          },
          "error": {
            "type": "string",
            "description": "Present on `failed` and `expired`.",
            "examples": [
              "scan_failed",
              "search_unavailable",
              "claim_abandoned",
              "result_expired"
            ]
          },
          "detail": {
            "type": "string"
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          }
        }
      },
      "WhoAmI": {
        "type": "object",
        "required": [
          "tenant",
          "plan",
          "quota",
          "spend_ceiling",
          "billing_rail",
          "channel"
        ],
        "description": "Everything this key can be told about its own account. Deliberately nothing about any other account, and nothing about our cost of goods.",
        "properties": {
          "tenant": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "plan": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "examples": [
                  "free",
                  "developer",
                  "team",
                  "compliance",
                  "marketplace"
                ]
              },
              "label": {
                "type": "string"
              }
            }
          },
          "quota": {
            "type": "object",
            "description": "Plan units for the current billing window. A scan costs more than one.",
            "properties": {
              "included_units": {
                "type": "integer"
              },
              "used_units": {
                "type": "integer"
              },
              "remaining_units": {
                "type": "integer"
              },
              "window_resets_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "spend_ceiling": {
            "type": "object",
            "description": "The dollar cap on what we will spend serving this account in the window, and how much of it is gone. Hitting it is a 402, which is a different refusal from the quota 429.",
            "properties": {
              "ceiling_usd": {
                "type": "number"
              },
              "spent_usd": {
                "type": "number"
              },
              "remaining_usd": {
                "type": "number"
              }
            }
          },
          "billing_rail": {
            "type": "string",
            "enum": [
              "stripe",
              "aws",
              "azure",
              "rapidapi",
              "none"
            ],
            "description": "Which marketplace owns this account's money. Anything other than `stripe` means the self-serve checkout and billing portal answer 409 for you by design — your marketplace handles the subscription."
          },
          "channel": {
            "type": "string",
            "enum": [
              "api_key",
              "rapidapi"
            ],
            "description": "How this request proved who it was."
          },
          "rate_limit_per_minute": {
            "type": "integer"
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "Errors carry the same version stamps as successes, so a rejected request can be traced to the build that rejected it.",
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable code.",
            "examples": [
              "invalid_json",
              "identity_required",
              "identity_incomplete",
              "seed_not_supported",
              "missing_key",
              "invalid_key",
              "tenant_suspended",
              "rate_limited",
              "quota_exceeded",
              "spend_ceiling_reached",
              "search_unavailable",
              "verify_failed",
              "scan_failed",
              "async_not_available",
              "enqueue_failed",
              "job_not_found",
              "claim_abandoned",
              "result_expired",
              "tenant_unavailable",
              "capacity_reached",
              "async_required"
            ]
          },
          "detail": {
            "type": "string",
            "description": "Human-readable explanation."
          },
          "pipeline_version": {
            "type": "string"
          },
          "linkage_weights_version": {
            "type": "string"
          },
          "calibration_version": {
            "type": "string"
          }
        }
      }
    }
  },
  "x-sirveil-pricing": {
    "skus": [
      {
        "id": "verification",
        "label": "Verification",
        "price_usd": 0.1,
        "endpoint": "/v1/verify",
        "quota_units": 1
      },
      {
        "id": "scan",
        "label": "Full scan",
        "price_usd": 0.35,
        "endpoint": "/v1/scan",
        "quota_units": 100
      }
    ],
    "plans": [],
    "billing_model": "metered",
    "billing_note": "Sold per served call at the SKU prices above, through AWS Marketplace: AWS invoices the usage on the buyer's own AWS bill against the two locked dimensions `verification` and `scan`. There is no subscription, no minimum, and no included-units allowance to size in advance. Every shape of a scan — synchronous, streaming and asynchronous — is one metered call; polling a job meters nothing. A failed call is never metered.",
    "units_note": "Allowances, where an account holds one, are denominated in units rather than calls. One verification is 1 unit; one scan is 100 units, because it costs us roughly that much more to serve. Metered AWS Marketplace subscriptions carry no allowance and are billed per served call at the SKU prices above.",
    "quota_policy": "hard_cap",
    "quota_policy_note": "An account that holds an included-units allowance is a HARD CAP, not a soft one. When the units for a billing window are used up, further calls are refused with HTTP 429 and `error: \"quota_exceeded\"` until the window turns over. There is no overage, no automatic upgrade and no charge beyond what was agreed in advance — an unattended loop cannot produce a bill nobody signed off. AWS Marketplace subscriptions, which is how this API is sold, are the metered case and carry no allowance at all: they are billed per served call at the published SKU prices ($0.10 verification, $0.35 scan) and never return `quota_exceeded`. A separate spend ceiling bounds OUR cost of goods rather than the customer invoice; it refuses with `402 spend_ceiling_reached` and is raised on request.",
    "marketplace_quota_policy": "metered"
  },
  "x-sirveil-channels": [
    {
      "id": "stripe",
      "label": "Stripe (direct)",
      "kind": "direct",
      "published": true,
      "bills": "sirveil",
      "we_report_usage": true,
      "report_rail": "stripe",
      "dimensions": null,
      "landing": "/signup",
      "note": "Self-serve API keys. Sirveil invoices via Stripe Billing."
    },
    {
      "id": "aws",
      "label": "AWS Marketplace",
      "kind": "metering",
      "published": true,
      "bills": "aws",
      "we_report_usage": true,
      "report_rail": "aws",
      "dimensions": [
        "verification",
        "scan"
      ],
      "landing": "/aws/register",
      "note": "BatchMeterUsage. Dimension names locked at product creation."
    },
    {
      "id": "azure",
      "label": "Azure Marketplace",
      "kind": "metering",
      "published": false,
      "bills": "azure",
      "we_report_usage": true,
      "report_rail": "azure",
      "dimensions": [
        "verification",
        "scan"
      ],
      "landing": "/azure/landing",
      "note": "SaaS Fulfilment v2 + batchUsageEvent are BUILT. The LISTING DOES NOT EXIST — Partner Center shows 0 public and 0 private offers for Sirveil-sirveil, verified in-console 2026-08-18 21:15 PDT. Unpublished until an offer is created and certified. azure_subscription_id is Microsoft's SaaS GUID and the metering resourceId. Same two dimensions as AWS."
    },
    {
      "id": "rapidapi",
      "label": "RapidAPI",
      "kind": "proxy",
      "published": true,
      "bills": "rapidapi",
      "we_report_usage": false,
      "report_rail": null,
      "dimensions": null,
      "landing": null,
      "note": "Proxy: X-RapidAPI-Proxy-Secret authenticates the gateway, X-RapidAPI-User is the consumer. RapidAPI bills. We never BatchMeter. api_usage rows are attribution, quota and refusal only."
    }
  ],
  "x-sirveil-async": {
    "submit_header": "Prefer: respond-async",
    "poll_path": "/api/v1/jobs/{job_id}",
    "capacity_per_hour": 58,
    "drain_concurrency": 1,
    "scan_seconds_median": 154,
    "scan_seconds_max": 203,
    "result_retention_hours": 24,
    "measured_scan_seconds": [
      135,
      144,
      154,
      155,
      203
    ],
    "throughput_note": "One scan takes 154 seconds at the median and up to 203. The worker is invoked once a minute and runs `drain_concurrency` scans per invocation, which is what sets capacity at 58 per hour. A batch of 20 jobs therefore takes roughly 21 minutes for the last one to finish. If you need more than this, tell us before you queue the batch rather than after — the limit is a deployment configuration, not a property of the product.",
    "polling_note": "Polling is not billed and consumes no plan units. Poll as often as your rate limit allows.",
    "retention_note": "The submitted identity and the result are deleted 24 hours after submission. The job record survives with its timings so a late poll reports `expired` rather than 404."
  }
}