openapi: 3.1.0
info:
  title: Seer API
  version: "1.0"
  summary: Programmatic access to the same four surfaces the Seer dashboard searches.
  description: |
    The Seer API gives you direct access to the same surface the dashboard
    uses: breach search, device-record intelligence, people search, and
    monitors, over plain JSON-over-HTTPS.

    ### Authentication

    Send your API key on every request, either as a bearer token:

    ```
    Authorization: Bearer ib_…
    ```

    Or in the dedicated header:

    ```
    X-API-Key: ib_…
    ```

    Issue and rotate keys from `/account` in the dashboard. Direct API access
    requires a **Premium or Team** subscription or a custom plan an admin has
    granted API access to. Marketing preview disables API-key use and issuance
    for everyone; existing keys are retained and can still be revoked.
    Every key is fenced by an **IP allowlist** of at most 5 entries, each a single IP or a CIDR range.
    An empty allowlist means
    the key is refused, so any-IP access has to be opted into explicitly with
    `0.0.0.0/0` and `::/0`.

    Two further conditions are checked in middleware, before your request
    reaches a handler:

    - The account's email must be verified, or the call is a `403` with code
      `email_verification_required`.
    - Every key-authenticated request spends one lookup from the same pooled
      daily allowance the dashboard searches use. Once that pool is spent the
      call is a `429` with no `code` field. There is no separate API-call
      allowance.

    Free session reads such as usage and saved records do not spend lookups;
    running a query does.

    ### Metering

    Read `metering` from `GET /search/stats` before any counter that call
    returns, because it decides what `lookups` means:

    - `pooled` - every paid tier and any custom plan an admin authors. One
      `lookups` pool is spent by breach search, device search, people search
      and API requests alike, and it resets daily.
    - `per_surface` - the free tier only. `lookups` narrows to breach and
      device searches, `people_searches` and `social_resolves` each hold their
      own allowance, and there are `unlocks`. The three search counters reset
      daily, 10 apiece; `unlocks` holds 2 and runs on its own weekly clock.
    - When `marketing_mode` is true, `metering` is `pooled` and `period` is
      `minute`: your breach, device, people and social dashboard searches use
      the same allowance of 10 searches per UTC calendar minute. Browser-level
      limits also apply when browser protection is enabled. Paid usage counters
      stay separate. Preview is dashboard-only: API keys, all result exports,
      device ZIP downloads and monitor webhooks are disabled, including for
      paid accounts and admins.
      Existing paid time is held during preview; new plans are charged at
      purchase and their paid terms start afterward. Turning preview off
      restores normal entitlements and resumes held time.

    The independent `dashboard_bot_protection` switch controls customer session
    requests in both normal and preview modes. When enabled, protected dashboard
    data/actions require a server-verified, 30-minute Turnstile grant bound to the
    session and stable signed device ID, not the visitor's IP or User-Agent.
    Session HTTP calls require the trusted web bridge; social WebSockets also
    require the configured frontend Origin. **API-key authentication bypasses
    this check** and needs no Turnstile token, dashboard session or device cookie.
    Existing plan, email, IP-allowlist and account-quota restrictions still apply.
    Social intelligence is dashboard-only, not available through an API key.

    When server-issued device IDs are enabled, default Free accounts also share
    daily search and weekly unlock allowances across accounts using the same
    server-issued `ib_device` cookie. Usage
    responses report the greater of account and browser usage. Signing out
    does not reset the browser allowance; clearing cookies does not reset an
    existing account's usage. Normal paid/custom-plan allowances stay separate.
    Cookie clients should retain backend Set-Cookie responses. This cookie is
    an abuse-control identifier, not an authentication credential.
    Turning this protection off stops issuing or checking IDs and enforcing
    browser-bound quotas; account quotas still apply. Existing cookies and
    browser usage are retained for re-enabling, subject to normal expiry and
    quota resets. `GET /auth/device` returns `204` with `X-Browser-Identity:
    enabled` or `disabled`; a disabled response deliberately issues no cookie.

    A limit field reads the same way across the API: `0` means the surface is
    not on the plan and the request is refused outright, `-1` means no cap, and
    anything above `0` is the cap for that counter's period. Two limits are
    exceptions worth learning before you branch on them:

    - `people_limit` is `0` on every pooled plan, where it means people search
      spends the `lookups` pool - not that the surface is locked.
    - `unlocks_limit` is `0` on every paid plan, where it means the plan has no
      unlock mechanism at all - not that unlocks are unlimited.

    Counters run on two clocks, so the call reports two reset instants.
    `resets_at` is the last second of the current search period, the cadence
    `period` names, and it governs the search counters.
    `unlocks_reset_at` is the last second of the current unlock period, the
    cadence `unlocks_period` names. Both are authoritative: use them as given
    rather than recomputing a boundary from either one.

    ### Unlocks

    Free-tier breach rows and masked device rows arrive censored, each carrying
    a `record_id`. `POST /unlocks` spends one of the 2 unlocks the free tier
    gets each week to reveal one of those records in full, permanently: the
    grant survives the weekly unlock reset and applies from any later query
    that reaches the record. That weekly clock is the unlock allowance's own;
    it is not the daily clock the free search counters run on. It lifts
    value masking only, so it never opens the `ai_analysis` or `ai_chat` device
    tabs.

    ### Response envelope

    Every endpoint returns the same JSON envelope:

    ```json
    {
      "success": true,
      "data": { /* endpoint-specific payload */ }
    }
    ```

    On error, `success` is `false`, `data` is omitted, and `error` carries a
    human-readable message. Some errors also include a machine-readable
    `code` that's safe to branch on: `quota_exceeded`, `hourly_limit`,
    `file_access_limit`, `plan_upgrade_required`, `upgrade_required`,
    `feature_not_enabled`, `email_verification_required`, `monitor_limit`,
    `monitor_surface_not_allowed`, `monitor_first_seen_not_allowed`,
    `monitor_webhook_invalid`, `monitor_webhook_not_allowed`,
    `monitor_webhook_key_required`, `marketing_mode`,
    `dashboard_verification_required`, `dashboard_verification_unavailable`,
    and `bot_check_failed`.

    `code` is omitted whenever it is empty, so many errors carry no `code` at
    all and have to be told apart by status and message. Two of the codes
    read alike but are not interchangeable: device raw search, the two
    model-backed tabs and `POST /unlocks` all send `plan_upgrade_required`,
    while `upgrade_required` is sent by exactly one endpoint here,
    `POST /people/profile`.

    ### Rate limits

    - Global: 120 req/min per IP, on every `/api` route
    - Search / stealer / people endpoints: 60 req/min per IP, on
      top of the global limiter
    - `/monitors/*`, `/search/stats` and `/stats/compromised` carry only the
      120 req/min global limiter, not the 60 req/min one
    - Request bodies are capped at 1 MiB on every `/api` route
    - Outside marketing preview, search allowances reset daily on every tier,
      and the free tier's 2 record unlocks reset weekly. Preview instead uses
      your 10-search allowance across all search services per UTC calendar minute.
      Query `GET /search/stats` for live usage, ceilings, and both reset
      instants.

  contact:
    name: Seer support
    url: https://seer.to/support
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://seer.to/api
    description: Production

tags:
  - name: Search
    description: |
      Search breach records by email, username, password, hash, IP, phone,
      domain, or name. Wildcards on Pro and up, bulk on paid plans.
  - name: Stealer
    description: |
      Search device records for credentials, cookies, and saved logins captured
      from compromised machines. Every plan can inspect the file-archive
      browser; below Premium its structure and counts are real and its values
      are masked. Row masking is per record, so a row the caller has unlocked
      reads in full on a plan that masks by default.
  - name: Unlocks
    description: |
      Spend one unlock to reveal a single censored record in full. Free tier
      only; the paid tiers have nothing to reveal.
  - name: People
    description: |
      Public-records aggregation - names, addresses, phones, relatives, emails.
      Free returns who matched with phones, addresses, relatives and emails
      withheld and counted; paid plans return the records whole.
  - name: Monitors
    description: |
      Continuous watchers on assets you own. Email/webhook fan-out when new
      hits surface.
  - name: Account
    description: Read your plan limits, live usage counters, and your own exposure.

security:
  - bearerAuth: []
  - apiKeyHeader: []

paths:
  /site/access:
    get:
      tags: [Account]
      summary: Read public preview and dashboard protection status
      operationId: siteAccess
      security: []
      description: |
        Reports marketing preview and dashboard bot protection state without
        exposing signup controls or private configuration. No authentication is
        required. The response is not cacheable.
      responses:
        "200":
          description: Current preview mode, dashboard protection state and preview search ceiling.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          marketing_mode: { type: boolean }
                          dashboard_bot_protection:
                            type: boolean
                            description: Enables session-only dashboard verification independently of preview. Does not challenge API-key clients.
                          searches_per_minute: { type: integer, const: 10 }
        "503": { $ref: "#/components/responses/ServiceDown" }

  # -----------------------------------------------------------------
  # Search
  # -----------------------------------------------------------------
  /search:
    post:
      tags: [Search]
      summary: Search breach records
      operationId: searchBreaches
      description: |
        Search breach records by a single term.
        Use `wildcard: true` to enable `*` and `?` patterns (Pro and up).

        Below the first paid tier the row values come back censored. Every row
        carries a `record_id` and an `unlocked` flag either way, and the
        envelope carries the unlock balance, so a censored row can be revealed
        one at a time through `POST /unlocks`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SearchRequest" }
            examples:
              email:
                summary: Single email
                value:
                  type: email
                  term: ceo@acme.io
              wildcard:
                summary: Wildcard domain (Pro and up)
                value:
                  type: email
                  term: "*@acme.io"
                  wildcard: true
                  limit: 100
      responses:
        "200":
          description: Search results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.SearchResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/PlanGate" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /search/check:
    post:
      tags: [Search]
      summary: Existence-only check
      operationId: searchCheck
      description: |
        Returns only the count and source breakdown, useful for ATO or fraud
        signal at signup time when you do not need the leaked records
        themselves. The call is metered: it spends one lookup from the same
        `lookups` allowance as `POST /search`. It is cheaper in bandwidth, not
        in quota.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, term]
              properties:
                type: { $ref: "#/components/schemas/SearchType" }
                term:
                  type: string
                  example: user@example.com
      responses:
        "200":
          description: Yes/no answer plus source count.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          found: { type: boolean }
                          databases: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /search/bulk:
    post:
      tags: [Search]
      summary: Bulk breach records grouped by term
      operationId: searchBulk
      description: |
        Return breach records for each term, not just an existence signal.
        Read the account's term ceiling from `bulk_max_terms` in
        `GET /search/stats`; normal plan and marketing-preview limits apply.
        Each attempted term spends one lookup from the current allowance.
        Outer whitespace, blank terms and exact repeats are removed before
        the ceiling and quota checks. Internal spaces, commas, semicolons and
        case remain intact. The web form accepts one term per line.

        Every normalized term has a result entry. Successful terms carry their
        returned records, including source metadata, with the same masking,
        unlock and takedown rules as single breach search. A failed search is
        not a no-hit result. When quota, cancellation or the overall deadline
        stops a batch, remaining terms have `status: not_searched` and a
        machine-readable `code`; they are not charged. Completed results are
        retained. `quota_exceeded: true` identifies an allowance stop.
        Per-term timeout is 8s; the whole call has a 90s ceiling.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, terms]
              properties:
                type: { $ref: "#/components/schemas/SearchType" }
                terms:
                  type: array
                  items: { type: string }
                  description: |
                    Up to the effective `bulk_max_terms` unique nonblank entries.
                    Each string is one intact term, not a delimiter-separated list.
                wildcard:
                  type: boolean
                  default: false
            examples:
              batch:
                value:
                  type: email
                  terms:
                    - alice@acme.io
                    - bob@acme.io
                    - carol@acme.io
      responses:
        "200":
          description: One result entry per normalized unique input term.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.BulkSearchResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/PlanGate" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }

  /results/export:
    post:
      tags: [Account]
      summary: Authorize saving already-returned results
      operationId: authorizeResultExport
      description: |
        Requires an active paid entitlement, including covered team seats and
        custom deals. No per-account flag is needed for ordinary result exports.
        The user DTO's `can_export` reports this entitlement. Free accounts and
        admin roles alone do not qualify. Marketing preview disables every
        export surface, including for paid users, admins and explicit grants.
        No request body is needed and no result data is uploaded.
        This endpoint does not fetch, unmask, format or return search records
        and consumes no search quota. The web client saves its held results
        locally as JSON or CSV after authorization succeeds.

        Export scope is the records already returned, not unseen search pages.
        Bulk exports include all records in the active term filter, including
        records on other local pages and unsuccessful terms. JSON preserves
        nested data; CSV expands bulk records into individual rows, uses JSON
        Pointer column paths, retains arrays as JSON cells, and puts common
        query/graph context on the first row only. Spreadsheet formula cells
        are escaped. Wide CSV exports exceeding 512 columns must use JSON.
        Social JSON and CSV omit unavailable-platform lists, including nested
        graph metadata; matched profiles and availability counts are retained.
        Export does not change the data's existing masking or filtering.
        This action grant cannot recall data already sent to a browser.
      parameters:
        - name: surface
          in: query
          required: true
          description: The search surface whose held results will be saved.
          schema:
            type: string
            enum: [breaches, bulk, social, devices, people]
      responses:
        "200":
          description: Current account may use result export; private, no-store.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          allowed: { type: boolean, enum: [true] }
        "400":
          description: A recognized surface query parameter is required.
        "401":
          description: Valid authentication is required.
        "403":
          description: Active paid entitlement required (plan_upgrade_required), or all exports are disabled during marketing preview (marketing_mode).
          content:
            application/json:
              example: { success: false, error: "result exports require an active paid plan", code: plan_upgrade_required }

  /me/exposure:
    get:
      tags: [Account]
      summary: Your own breach and stealer exposure
      operationId: selfExposure
      description: |
        Breach and stealer hit counts for the authenticated account's own email
        address. The counts are computed once and then persisted on the account,
        so later calls read the stored figures rather than hitting upstream
        again. This endpoint consumes no quota.
      responses:
        "200":
          description: |
            Exposure counts. Also returned, with `has_email: false` and zero
            counts, when the account has no email address on file.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          breach_count: { type: integer }
                          stealer_count: { type: integer }
                          checked_at:
                            type: string
                            format: date-time
                            description: |
                              Omitted until both the breach leg and the stealer
                              leg have completed; a leg that failed is retried
                              on the next call.
                          has_email: { type: boolean }

  /search/stats:
    get:
      tags: [Account]
      summary: Live usage and plan limits
      operationId: searchStats
      responses:
        "200":
          description: |
            Usage counters plus the ceilings they reset against. Read `metering`
            first: it decides whether `lookups` is a pool shared by every
            surface or a breach-and-device figure. Then read the two clocks:
            `period` and `resets_at` govern the search counters, while
            `unlocks_period` and `unlocks_reset_at` govern `unlocks`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.UsageStats"

  /stats/compromised:
    get:
      tags: [Search]
      summary: Aggregate breach metrics
      operationId: compromisedMetrics
      description: Cached server-side for 1 hour. Aggregate metrics over the breach records reachable through this endpoint's search.
      responses:
        "200":
          description: Aggregate metrics.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        description: Opaque payload - counts, timeline, breach catalogue.
                        additionalProperties: true
        "503": { $ref: "#/components/responses/ServiceDown" }

  # -----------------------------------------------------------------
  # Stealer
  # -----------------------------------------------------------------
  /stealer/search:
    post:
      tags: [Stealer]
      summary: Search stealer logs
      operationId: stealerSearch
      description: |
        Query device records for hits on the given identifier. Every plan gets
        the same page of 100 rows. Free and Pro mask the credential values by
        default, Premium and Team return them, and the top-level `masked` says
        which of the two this plan is.

        Masking is applied per record, not per response: a `masked: true` page
        still returns real values for any row this caller has unlocked, so a
        single page can mix the two. Branch on each row's `unlocked` flag. Rows
        also carry `record_id`, which is what an unlock is spent against.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, term]
              properties:
                type:
                  $ref: "#/components/schemas/StealerSearchType"
                term:
                  type: string
                  minLength: 3
                offset:
                  type: integer
                  default: 0
                first_seen_only:
                  type: boolean
                  default: false
                  description: |
                    Admin-granted feature (`stealer_first_seen`). Collapses the
                    term to one row per host/login/password credential, keeping
                    its earliest capture, and `count` becomes the number of
                    credentials rather than rows. The collapse reads the oldest
                    500 rows of the term; `matched`, `scanned` and
                    `scan_truncated` report what it covered.
                sort:
                  type: string
                  enum: [newest, oldest]
                  description: |
                    Admin-granted feature (`stealer_sort_order`); 403
                    `sort_not_enabled` without it. Omit to take the default,
                    which is `newest`, or `oldest` when `first_seen_only` is
                    set. The response echoes the order that answered.
            examples:
              email:
                value:
                  type: email
                  term: ceo@acme.io
              domain:
                value:
                  type: domain
                  term: acme.io
      responses:
        "200":
          description: |
            Device-record hits. `masked` reports the plan default; each row's
            `unlocked` flag reports whether that row is masked in fact.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.StealerSearchResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/FeatureNotEnabled" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/raw:
    post:
      tags: [Stealer]
      summary: Raw query - regex or wildcard (Premium and Team only)
      operationId: stealerRaw
      description: |
        Freeform query against device-record files with regex or wildcard
        syntax. Premium and Team only; every lower plan, Pro included, gets a
        `403` with code `plan_upgrade_required` before the query runs.
        Pick one: passing both `regex: true` and `wildcard: true` is a 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query:
                  type: string
                  minLength: 3
                offset:
                  type: integer
                  default: 0
                regex:
                  type: boolean
                wildcard:
                  type: boolean
            examples:
              regex:
                value:
                  query: "support@.*\\.ai"
                  regex: true
              wildcard:
                value:
                  query: "support@*.ai"
                  wildcard: true
      responses:
        "200":
          description: Raw match results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.StealerRawResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/PlanUpgradeRequired" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/file/{id}/tree:
    get:
      tags: [Stealer]
      summary: File-archive directory tree
      operationId: stealerFileTree
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^[a-zA-Z0-9_-]+$"
          description: Stealer-log file identifier.
      responses:
        "200":
          description: Opaque tree structure, available on every plan.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/file/{id}/export:
    get:
      tags: [Stealer]
      summary: Download a whole stealer log as a ZIP
      operationId: stealerFileExport
      description: |
        Streams a ZIP mirroring the original archive structure - every file
        under the upstream tree - plus a `_metadata.json` sidecar carrying the
        structured tab data. Requires both an active paid entitlement and the
        existing per-account `export` flag issued by an admin. An admin role
        does not bypass either check. Marketing preview disables ZIP downloads,
        even for paid and explicitly flagged users.

        The response is streamed, so headers go out before the upstream fetches
        finish. A fetch that fails partway does not fail the call: the ZIP still
        completes and `_metadata.json` records what was missed under
        `file_errors`.

        Ceilings, all recorded in `_metadata.json` rather than raised as errors:
        5000 files from the tree, 10 MiB per file, 200 MiB per bundle. Each
        distinct log counts against the same hourly file-access cap as the read
        routes.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^[a-zA-Z0-9_-]+$"
          description: Stealer-log file identifier.
      responses:
        "200":
          description: |
            ZIP archive, sent as `Content-Type: application/zip` with a
            `Content-Disposition: attachment` filename of `stealer-log-{id}.zip`.
          content:
            application/zip:
              schema:
                type: string
                format: binary
        "400": { $ref: "#/components/responses/BadRequest" }
        "403":
          description: Requires an active paid entitlement (plan_upgrade_required) and an explicit export flag (feature_not_enabled); disabled during preview (marketing_mode).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/file/{id}/path/{path}:
    get:
      tags: [Stealer]
      summary: Read a file from the archive
      operationId: stealerFilePath
      description: |
        Available on every plan. Below Premium the file keeps its real path,
        byte count, line count and line breaks, while every content character
        is masked. The response carries `masked: true` in that case.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: path
          name: path
          required: true
          schema: { type: string }
          description: |
            Slash-separated path inside the archive (e.g. `Soft/Chrome/Login Data.txt`).
            `..` segments are rejected.
      responses:
        "200":
          description: |
            File contents. Either the parsed JSON payload, or a
            `{path, content, size, masked}` object for raw text files.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        oneOf:
                          - type: object
                            properties:
                              path: { type: string }
                              content: { type: string }
                              size: { type: integer }
                          - type: object
                            additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/file/{id}/{tab}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string }
      - in: path
        name: tab
        required: true
        schema:
          type: string
          enum:
            - credentials
            - cookies
            - autofills
            - system
            - accounts
            - software
            - corporate
            - phrases
            - payments
            - browser_history
            - raw
            - search
            - ai_analysis
            - ai_chat
    get:
      tags: [Stealer]
      summary: Browse a tab on a stealer-log file (read-only tabs)
      operationId: stealerFileTabGet
      description: |
        Available on every plan. Free and Pro receive the same keys, row counts
        and totals with sensitive leaf values masked; Premium and Team receive
        the values. `ai_analysis` and `ai_chat` are the exceptions: those two
        model-backed tabs return `403 plan_upgrade_required` below Premium, and
        an unlock does not open them - each is a paid language-model call with
        no censored form to serve, so there is nothing a unit could buy.
        Some tabs are GET-only (`system`, `accounts`, `software`, `phrases`,
        `payments`, `raw`). For paginated tabs use POST and pass filter, limit
        and offset in the body.
      responses:
        "200":
          description: Opaque tab payload.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/PlanUpgradeRequired" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }
    post:
      tags: [Stealer]
      summary: Browse a tab with filters
      operationId: stealerFileTabPost
      description: |
        Same value-access rule as the GET form: full structure on every plan,
        masked values below Premium, with `ai_analysis` and `ai_chat` still
        gated and still out of reach of an unlock. Use this for paginated or
        filterable tabs (`credentials`, `cookies`, `autofills`,
        `browser_history`, `search`).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                search:
                  type: string
                  description: Filter results by substring match.
                limit:
                  type: integer
                offset:
                  type: integer
                  default: 0
                ignoreExpired:
                  type: boolean
                  description: Cookies tab only.
                ignoreDuplicates:
                  type: boolean
                  default: true
                  description: Cookies tab only.
                summary_only:
                  type: boolean
                  description: Browser history tab only.
                entries_only:
                  type: boolean
                  description: Browser history tab only.
                view:
                  type: string
                  description: Browser history tab only.
                categories:
                  type: array
                  items: { type: string }
                browsers:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: Opaque tab payload.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/PlanUpgradeRequired" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/first-seen:
    get:
      tags: [Stealer]
      summary: First time a credential surfaced
      operationId: stealerFirstSeen
      description: |
        Returns the earliest device-record document that mentions the term, or
        `null` if there are no matches. Cached for 24h server-side and available
        on every plan; sensitive values inside the document are masked below
        Premium while the date and source name remain readable.
      parameters:
        - in: query
          name: type
          required: true
          schema: { $ref: "#/components/schemas/StealerSearchType" }
        - in: query
          name: term
          required: true
          schema:
            type: string
            minLength: 3
      responses:
        "200":
          description: First-seen document or null.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        oneOf:
                          - type: "null"
                          - type: object
                            additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/source/{id}:
    get:
      tags: [Stealer]
      summary: Source metadata for one log
      operationId: stealerSource
      description: |
        Summary metadata for a single device record, for callers that arrive at
        a log by id without having gone through a search first. Available on
        every plan and cached server-side for 24h. Sensitive values are masked
        below Premium; `data` is `null` when the id resolves to nothing.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            pattern: "^[a-zA-Z0-9_-]+$"
          description: Stealer-log file identifier.
      responses:
        "200":
          description: Source metadata, or null.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        oneOf:
                          - type: "null"
                          - type: object
                            additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/FileAccessLimit" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /stealer/limits:
    get:
      tags: [Account]
      summary: Stealer-specific limits and usage
      operationId: stealerLimits
      responses:
        "200":
          description: Plan + counter snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.StealerLimits"

  # -----------------------------------------------------------------
  # Unlocks
  # -----------------------------------------------------------------
  /unlocks:
    post:
      tags: [Unlocks]
      summary: Reveal one censored record
      operationId: unlockRecord
      description: |
        Spend one unlock to reveal a single censored record in full. The grant
        is permanent: it survives the weekly unlock reset and applies to that
        record from any later query that reaches it, so a row revealed once
        stays revealed.

        Two things that are easy to get wrong:

        - `charged: false` means the record was already held. Holding it again
          costs nothing, so a double click or a retry after a dropped response
          is safe.
        - An unlock lifts value masking only. It does not open the
          `ai_analysis` or `ai_chat` file-browser tabs, which stay refused
          below the Premium file-browser entitlement because each is a paid
          language-model call with no censored form to serve.

        Authentication is the same as on the other `/api` routes. The call
        writes a grant and reaches no upstream, so it is not metered as a
        search and carries only the global rate limiter.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UnlockRequest" }
            examples:
              breach:
                summary: A breach row
                value:
                  kind: breach
                  record_id: "0f3c9a1d5b7e4c20"
                  term: ceo@acme.io
                  query_type: email
              device:
                summary: A device row
                value:
                  kind: device
                  record_id: "AbC123_x-9"
      responses:
        "200":
          description: |
            The record is held. The balance rides along on every outcome,
            refusals included, so the control that was just pressed can redraw
            its remaining count without a second call.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.UnlockResponse"
        "400":
          description: |
            The body would not parse, `kind` is neither `breach` nor `device`,
            or `record_id` is empty or longer than 128 characters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                invalidKind:
                  value: { success: false, error: "invalid unlock kind" }
                invalidRecord:
                  value: { success: false, error: "invalid record id" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            The plan has no unlock mechanism at all. Every paid tier answers
            this way: paid rows are not censored, and Pro's masked device
            browser is the Premium upsell rather than something to spend units
            on. The code is the same `plan_upgrade_required` the device gates
            send.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { success: false, error: "this plan does not use unlocks", code: plan_upgrade_required }
        "429":
          description: |
            The unlock allowance is spent. It refills on its own weekly clock,
            reported as `unlocks_reset_at` by `GET /search/stats`, rather than
            with the search counters. Unlike the other quota refusals this one
            still carries `data`, with `unlocks` and `unlocks_limit`, so the
            caller can redraw the balance without a second request.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          unlocks: { type: integer }
                          unlocks_limit: { type: integer }
              example:
                success: false
                error: "unlock allowance reached"
                code: quota_exceeded
                data: { unlocks: 5, unlocks_limit: 5 }

  # -----------------------------------------------------------------
  # People
  # -----------------------------------------------------------------
  /people/search:
    post:
      tags: [People]
      summary: Search public records
      operationId: peopleSearch
      description: |
        Aggregated lookups against US public-records sources. Public records are
        published filings, so every plan receives whole records - addresses,
        phones, relatives, emails, and a `profile_id` to drill into. Nothing on
        this surface is censored on any plan, and the envelope's `censored` is
        always `false`.

        The search is metered rather than gated: it spends one unit of the
        people allowance, which is the shared `lookups` pool under `pooled`
        metering and the separate `people_searches` counter under
        `per_surface`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type: { $ref: "#/components/schemas/PeopleSearchType" }
                first_name: { type: string }
                last_name:  { type: string }
                city:       { type: string }
                state:      { type: string }
                zip_code:   { type: string }
                phone:      { type: string }
                street:     { type: string }
                email:      { type: string }
            examples:
              name:
                value:
                  type: name
                  first_name: John
                  last_name: Doe
                  state: CA
              phone:
                value:
                  type: phone
                  phone: "+14155550101"
      responses:
        "200":
          description: Search results; whole records on paid plans, contact details counted on free.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.PeopleSearchResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }
        "503": { $ref: "#/components/responses/ServiceDown" }

  /people/profile:
    post:
      tags: [People]
      summary: Drill into a profile
      operationId: peopleProfile
      description: |
        Resolve a `profile_id` (returned in search results) into the full
        profile document. Paid plans only (free gets `403 upgrade_required`);
        it spends one unit of the people allowance the same way the search
        itself does. A `profile_id` is valid for an hour after the
        search that issued it; an expired (`410 profile_expired`) or
        unrecognised (`400 invalid_profile`) id is refused before anything is
        spent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profile_id]
              properties:
                profile_id:
                  type: string
                  description: Opaque token from a People search result.
      responses:
        "200":
          description: Full profile. Structure varies by record.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        additionalProperties: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "410":
          description: The `profile_id` has expired (`profile_expired`). Search again for a fresh id.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EnvelopeBase" }
        "403": { $ref: "#/components/responses/UpgradeRequired" }
        "429": { $ref: "#/components/responses/QuotaExceeded" }

  /people/geocode:
    post:
      tags: [People]
      summary: Geocode a US address
      operationId: peopleGeocode
      description: |
        Resolves a structured US address to map coordinates - the lookup behind
        the profile map. US addresses only, so there is no country field.
        Unknown JSON fields are rejected and the body is capped at 8 KiB.
        Consumes no quota.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                street:   { type: string }
                city:     { type: string }
                state:    { type: string }
                zip_code: { type: string }
      responses:
        "200":
          description: Coordinates for the address.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          lat: { type: number, format: double }
                          lng: { type: number, format: double }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404":
          description: The address parsed but has no known coordinates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { success: false, error: "address coordinates not found" }
        "502":
          description: The upstream geocoder failed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { success: false, error: "address map is temporarily unavailable" }
        "503":
          description: The geocoder is not configured.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { success: false, error: "address map is temporarily unavailable" }

  # -----------------------------------------------------------------
  # Monitors
  # -----------------------------------------------------------------
  /monitors:
    get:
      tags: [Monitors]
      summary: List your monitors
      operationId: monitorsList
      responses:
        "200":
          description: All monitors owned by the authenticated user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Envelope.MonitorList"
    post:
      tags: [Monitors]
      summary: Create a monitor
      operationId: monitorsCreate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset, asset_type]
              properties:
                asset:
                  type: string
                  description: The thing to watch - email, username, domain, or IP.
                asset_type:
                  $ref: "#/components/schemas/MonitorAssetType"
                surface:
                  type: string
                  enum: [all, stealer]
                  default: all
                  description: |
                    Which surfaces the monitor watches. `all` covers the breach
                    index and the stealer logs. `stealer` watches stealer logs
                    only and is an admin-granted mode.
                notify_email:
                  type: boolean
                  default: false
                notify_webhook:
                  type: string
                  format: uri
                  description: |
                    URL for webhook delivery. Both `https://` and `http://` are
                    accepted. Webhook delivery itself is Premium and Team only.
                notify_webhook_secret:
                  type: string
                  description: |
                    Shared secret for the webhook. Carries the integration key
                    when the webhook is a PagerDuty endpoint, in which case it
                    is required.
                stealer_first_seen_only:
                  type: boolean
                  default: false
                  description: Admin-granted feature. Only notify for stealer credentials this monitor has not recorded before.
            examples:
              domain:
                value:
                  asset: acme.io
                  asset_type: domain
                  notify_email: true
                  notify_webhook: https://hooks.acme.io/breach
      responses:
        "200":
          description: Monitor created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope.Monitor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/MonitorGate" }

  /monitors/{id}:
    parameters:
      - in: path
        name: id
        required: true
        schema:
          type: string
          description: ObjectID hex.
    get:
      tags: [Monitors]
      summary: Fetch a single monitor
      operationId: monitorsGet
      responses:
        "200":
          description: Monitor object.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope.Monitor" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Monitors]
      summary: Update monitor settings
      operationId: monitorsUpdate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status:
                  type: string
                  enum: [active, paused]
                notify_email:
                  type: boolean
                notify_webhook:
                  type: string
                  description: Pass empty string to clear.
                notify_webhook_secret:
                  type: string
                  description: |
                    Shared secret for the webhook, or the PagerDuty integration
                    key. Pass empty string to clear.
                stealer_first_seen_only:
                  type: boolean
                  description: Only notify for stealer credentials this monitor has not recorded before.
      responses:
        "200":
          description: Updated monitor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Envelope.Monitor" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/MonitorGate" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Monitors]
      summary: Delete a monitor
      operationId: monitorsDelete
      responses:
        "200":
          description: Deletion ack.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/EnvelopeBase"
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          message: { type: string, example: "monitor deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

# -----------------------------------------------------------------
# Reusable components
# -----------------------------------------------------------------
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ib_*
      description: |
        `Authorization: Bearer ib_…`

        Keys are issued on Premium and on team plans, and rotated from
        `/account` in the dashboard. On any other plan every key-authenticated
        call is a `403` with code `plan_upgrade_required`, including calls made
        with a key issued before the plan changed.
    apiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Equivalent to bearer auth - pick whichever you prefer.

  schemas:
    # ----- Envelope -----
    EnvelopeBase:
      type: object
      properties:
        success: { type: boolean, example: true }
        data:    { description: "Endpoint-specific payload (see schema)." }
      required: [success]

    Error:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          example: invalid search type
        code:
          type: string
          description: |
            Machine-readable error tag. Omitted whenever it is empty, which is
            most generic errors, so branch on status too.
          enum:
            - quota_exceeded
            - hourly_limit
            - file_access_limit
            - plan_upgrade_required
            - upgrade_required
            - feature_not_enabled
            - marketing_mode
            - dashboard_verification_required
            - dashboard_verification_unavailable
            - bot_check_failed
            - email_verification_required
            - monitor_limit
            - monitor_surface_not_allowed
            - monitor_first_seen_not_allowed
            - monitor_webhook_invalid
            - monitor_webhook_not_allowed
            - monitor_webhook_key_required
      required: [success, error]

    # ----- Enums -----
    SearchType:
      type: string
      enum:
        - email
        - username
        - password
        - full_name
        - ip_address
        - phone_number
        - hash
        - domain

    StealerSearchType:
      type: string
      enum:
        - email
        - username
        - domain
        - password
        - ip
        - email_domain

    PeopleSearchType:
      type: string
      enum: [name, phone, address, email]

    MonitorAssetType:
      type: string
      enum: [email, username, domain, ip]

    PlanID:
      type: string
      description: Starter is a retired legacy id and is not a checkout target.
      enum: [free, basic, starter, professional, premium, team]
    # ----- Search request/response -----
    SearchRequest:
      type: object
      required: [type, term]
      properties:
        type:
          $ref: "#/components/schemas/SearchType"
        term:
          type: string
          example: ceo@acme.io
        wildcard:
          type: boolean
          default: false
          description: Pro and up only.
        limit:
          type: integer
        offset:
          type: integer
          default: 0
        filter:
          type: string
          enum: [use, ignore]
          description: |
            How `databases` is applied, not a filter on the rows. `use` searches
            only the named sources; `ignore` searches everything except them.

            Sending it without `databases` is a `400`: using an empty allowlist
            can only match nothing. Sending `databases` without it is treated as
            `use`, since an allowlist is what an unqualified list means.

            Paid plans only.
        databases:
          type: string
          description: |
            Comma-separated source names, for example `website.com,website.org`.
            Paired with `filter` to say whether they are the only sources
            searched or the only ones skipped. Paid plans only.
        format:
          type: string
        remove_duplicates:
          type: boolean
          description: Dedupe by email/password/hash.

    SearchResultRow:
      type: object
      additionalProperties: true
      description: |
        A row carries the fields the breached source held, so what is populated
        varies by source. Absent data arrives as an empty string rather than as
        a missing key.

        The data keys are `email`, `username`, `password`, `hash`, `full_name`,
        `ip_address`, and `phone_number`. Provenance is nested under `source`,
        as `source.name` and `source.date`. Rows also carry an opaque `id` for
        the record, and further keys may appear without notice, which is why
        this schema stays open.

        Below the first paid tier the data fields come back censored: `password`
        and `hash` are replaced with `********` and the identifier fields are
        prefix-censored. The field you searched on is never masked, and
        `sensitive_fields` is stripped on every plan including the top one.

        Anything the source held outside the known keys arrives under
        `other_fields`. That map is no longer dropped for censored callers: it
        comes back with the credential-ish and identity-ish subkeys removed
        (password, hash, salt, secret, token, ssn, dob, card, cvv, pin,
        security answer, mother's maiden name and the like, including inside
        nested objects and objects inside lists) and the rest readable. A
        sensitive subkey is deleted outright, never partially shown.

        Two named properties are called out below because integrators need
        them to drive unlocks; the schema stays open for the rest.
      properties:
        record_id:
          type: string
          description: |
            Stable id for this breach record. Breach rows have no upstream id,
            so this is a content digest of the row. Present on every plan; pass
            it to `POST /unlocks` to reveal the row.
        unlocked:
          type: boolean
          description: |
            Whether this caller has already revealed this record. An unlocked
            row carries real values even when the response says
            `censored: true`.

    Envelope.SearchResponse:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                results:
                  type: array
                  items: { $ref: "#/components/schemas/SearchResultRow" }
                found: { type: integer }
                databases: { type: integer }
                censored:
                  type: boolean
                  description: |
                    True when sensitive fields were redacted for the caller's
                    plan. It describes the plan, not every row: a row this
                    caller has unlocked carries real values regardless, so read
                    each row's `unlocked` flag as well.
                plan_id: { $ref: "#/components/schemas/PlanID" }
                duplicates_removed: { type: integer }
                response_time_ms: { type: integer }
                unlocks:
                  type: integer
                  description: Record unlocks spent in the current unlock period.
                unlocks_limit:
                  type: integer
                  description: |
                    Unlock allowance for the current unlock period, the weekly
                    clock `unlocks_period` names. `0` means the plan has no
                    unlock mechanism at all, which is every paid tier, since
                    paid rows are not censored in the first place.

    # ----- Bulk -----
    BulkResultEntry:
      type: object
      properties:
        term:      { type: string }
        found:     { type: boolean }
        count:     { type: integer }
        databases: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/SearchResultRow" }
        status:    { type: string, enum: [success, error, not_searched] }
        code:
          type: string
          description: Why a term was not searched; absent on successful terms.
          enum: [quota_exceeded, quota_check_failed, bulk_timeout, bulk_cancelled]
        error:     { type: string }

    Envelope.BulkSearchResponse:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                total:    { type: integer }
                found:    { type: integer }
                results:
                  type: array
                  items: { $ref: "#/components/schemas/BulkResultEntry" }
                censored: { type: boolean }
                plan_id:  { $ref: "#/components/schemas/PlanID" }
                quota_exceeded:
                  type: boolean
                  description: |
                    True when the lookup allowance ran out part-way through
                    the batch. Unattempted terms remain in `results` with
                    `status: not_searched`; completed records are retained.
                unlocks:
                  type: integer
                  description: Record unlocks spent in the current unlock period.
                unlocks_limit:
                  type: integer
                  description: |
                    Unlock allowance for the current unlock period. `0` means
                    the plan has no unlock mechanism at all, which is every
                    paid tier - and bulk search itself is paid-only, so this is
                    `0` in practice.

    # ----- Usage stats -----
    Envelope.UsageStats:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                plan: { $ref: "#/components/schemas/PlanID" }
                marketing_mode:
                  type: boolean
                  description: |
                    True when temporary marketing access is active. Your search
                    usage draws from the 10-search minute allowance, not paid counters.
                metering:
                  type: string
                  enum: [pooled, per_surface]
                  description: |
                    Read this before any counter below, because it decides what
                    `lookups` actually counts.

                    `pooled` - every paid tier (Pro, Premium, Team) and any
                    custom plan an admin authors. One `lookups` pool is spent by
                    breach search, device search, people search and API requests
                    alike, and it resets daily.

                    `per_surface` - the free tier only. Each surface holds its
                    own small allowance: `lookups` narrows to mean breach and
                    device searches only, `people_searches` is its own counter,
                    `social_resolves` is its own, and `unlocks` exists. The
                    three search counters reset daily, 10 apiece; `unlocks`
                    holds 5 and resets weekly.

                    During marketing preview, `pooled` also includes social
                    resolves and the allowance resets every UTC calendar minute.
                period:
                  type: string
                  enum: [minute, daily, weekly, monthly]
                  description: |
                    The search reset cadence. Normal plans use `daily`;
                    marketing preview uses `minute`. It does not govern the
                    separate `unlocks_period` clock.
                unlocks_period:
                  type: string
                  enum: [daily, weekly, monthly]
                  description: |
                    The cadence the `unlocks` counter resets on, deliberately
                    not the search cadence: it reads `weekly` while `period`
                    reads `daily`. Branch on this field rather than assuming
                    the unlock balance refills alongside the searches.
                lookups: { type: integer }
                lookups_limit: { type: integer, description: "-1 = unlimited" }
                people_searches:
                  type: integer
                  description: |
                    People searches spent in the current search period. It only
                    moves under `per_surface` metering; a pooled plan charges
                    people search to `lookups` and leaves this at zero.
                people_limit:
                  type: integer
                  description: |
                    `0` on every pooled plan, and there it means people search
                    spends the `lookups` pool - not that the surface is locked.
                    Under `per_surface` metering it is the real cap for the
                    search period (10 a day on free). `-1` = unlimited.
                social_resolves: { type: integer }
                social_limit: { type: integer, description: "-1 = unlimited" }
                unlocks:
                  type: integer
                  description: Record unlocks spent in the current unlock period.
                unlocks_limit:
                  type: integer
                  description: |
                    `0` on every paid plan, and there it means the plan has no
                    unlock mechanism at all - not that unlocks are unlimited.
                    Paid rows are not censored, so there is nothing to reveal.
                    The free tier gets 2 a week, on the weekly clock
                    `unlocks_period` names. `-1` = unlimited.
                monitors_limit:
                  type: integer
                  description: Saved-monitor ceiling, a hard cap rather than a counter that resets on either clock.
                wildcard:
                  type: boolean
                  description: "Whether this account may pass `wildcard: true`."
                full_results:
                  type: boolean
                  description: |
                    Whether breach and device row values are unmasked. It says
                    nothing about people search, which is uncensored on every
                    plan. Access to values inside the per-device file browser is
                    reported separately by `file_browser`.
                people_full_profile:
                  type: boolean
                  description: |
                    Whether this account may resolve a `profile_id` into the
                    full profile document. The drill-in is metered rather than
                    plan-gated: it spends one unit of the people allowance,
                    which is the `lookups` pool under `pooled` metering and
                    `people_searches` under `per_surface`.
                bulk_max_terms: { type: integer, description: "0 = bulk disabled" }
                social_max_depth:
                  type: integer
                  description: |
                    A capability rather than a counter: how many levels the
                    recursive social resolver may expand for this account, `1`
                    on the free tier and `3` on paid. It bounds the reach of a
                    single resolve instead of holding an allowance, so it never
                    depletes and never resets on either clock. One submitted
                    resolve spends one unit against `social_limit` however
                    many levels it reaches.
                file_browser:
                  type: string
                  enum: ["", full]
                  description: |
                    Empty means the per-device browser returns full structure
                    with masked values. `full` means the values are returned.
                raw_search: { type: boolean }
                monitor_webhooks: { type: boolean }
                api_access: { type: boolean }
                last_reset: { type: string, format: date-time }
                resets_at:
                  type: string
                  format: date-time
                  description: |
                    The last second of the current search period, the cadence
                    `period` names. It governs the search counters only. It is
                    authoritative: use it as given rather than recomputing a
                    boundary from it.
                unlocks_reset_at:
                  type: string
                  format: date-time
                  description: |
                    The last second of the current unlock period, the cadence
                    `unlocks_period` names, and so the instant the unlock
                    allowance returns. It is a different instant from
                    `resets_at` and is authoritative in the same way.

    # ----- Stealer -----
    StealerPreviewRow:
      type: object
      description: |
        Masked value shape returned by a plan that masks device values by
        default (free and Pro). Premium and Team get the same row structure
        with real values, and so does any single row this caller has unlocked,
        so one page can mix masked and unmasked rows.
      properties:
        record_id:
          type: string
          description: |
            Stable id for this device record - the provider's `stealerlog_id`.
            Present on every plan; pass it to `POST /unlocks` to reveal the row.
        unlocked:
          type: boolean
          description: |
            Whether this caller has already revealed this record. An unlocked
            row carries real values even when the response says `masked: true`.
        url: { type: string, description: "Scheme and host retained; path masked." }
        origin: { type: string }
        country: { type: string }
        browser: { type: string }
        os: { type: string }
        time_ingested: { type: string, format: date-time }
        login: { type: string, description: "Censored unless caller searched by email/username." }
        password_locked: { type: boolean }

    Envelope.StealerSearchResponse:
      description: |
        Successful response. `masked` says whether this plan masks device values
        by default: `true` on free and Pro, `false` on Premium and Team.

        Masking is applied per record, not per response. A `masked: true` page
        still returns real values for any row this caller has unlocked, so
        branch on each row's `unlocked` flag rather than on the top-level one.
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              oneOf:
                - type: object
                  description: Response from a plan that does not mask by default.
                  properties:
                    count: { type: integer }
                    results:
                      type: array
                      items:
                        type: object
                        additionalProperties: true
                        description: |
                          Open row shape. Two named properties are called out
                          because integrators need them to drive unlocks.
                        properties:
                          record_id:
                            type: string
                            description: "Stable id for this device record - the provider's `stealerlog_id`."
                          unlocked:
                            type: boolean
                            description: Whether this caller has already revealed this record.
                    pagination: { type: object, additionalProperties: true }
                    plan_id: { $ref: "#/components/schemas/PlanID" }
                    masked: { type: boolean, example: false }
                    first_seen_only: { type: boolean }
                    sort:
                      type: string
                      enum: [newest, oldest]
                      description: The order these rows were read in.
                    matched:
                      type: integer
                      description: |
                        First sightings only. Rows the term matched upstream,
                        which `count` credentials were collapsed from.
                    scanned:
                      type: integer
                      description: First sightings only. How many of those rows the collapse read.
                    scan_truncated:
                      type: boolean
                      description: |
                        First sightings only. True when `scanned` fell short of
                        `matched`: a credential first captured outside the
                        window read is missing from the answer.
                    cached: { type: boolean, description: Served from a page this caller already paid for. }
                    held:
                      type: array
                      items: { type: integer }
                      description: Offsets of this query that are still replayable for free.
                    unlocks: { type: integer, description: "Record unlocks spent in the current unlock period." }
                    unlocks_limit:
                      type: integer
                      description: |
                        Unlock allowance for the current unlock period, which
                        resets weekly. `0` means the plan has no unlock
                        mechanism at all, which is every paid tier.
                    response_time_ms: { type: integer }
                - type: object
                  description: |
                    Response from a plan that masks by default (free and Pro).
                    The counts and flags are the same; only the values are
                    withheld, so no unlock balance is carried here.
                  properties:
                    count: { type: integer }
                    results:
                      type: array
                      items: { $ref: "#/components/schemas/StealerPreviewRow" }
                    masked: { type: boolean }
                    first_seen_only: { type: boolean }
                    sort:
                      type: string
                      enum: [newest, oldest]
                    matched: { type: integer }
                    scanned: { type: integer }
                    scan_truncated: { type: boolean }
                    cached: { type: boolean }
                    held:
                      type: array
                      items: { type: integer }
                    response_time_ms: { type: integer }

    Envelope.StealerRawResponse:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                count: { type: integer }
                sources: { type: integer }
                results:
                  type: array
                  items:
                    type: object
                    additionalProperties: true
                pagination: { type: object, additionalProperties: true }
                raw: { type: boolean, example: true }
                response_time_ms: { type: integer }

    Envelope.StealerLimits:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                plan: { $ref: "#/components/schemas/PlanID" }
                hourly_cap:  { type: integer, description: "0 = no per-hour cap" }
                results_per_search: { type: integer, example: 100 }
                file_browser:
                  type: string
                  enum: ["", full]
                  description: |
                    `''` = working browser with masked values, used by Free and
                    Pro. `full` = real values, used by Premium and Team.
                raw_search: { type: boolean }

    # ----- People -----
    PersonRecord:
      type: object
      description: |
        Public records are published filings, so every plan receives the whole
        record. There is no censored variant and no per-record `censored` flag.
      properties:
        source:
          type: string
          description: |
            Which of our record sets this row was recovered from, as a stable
            ordinal (`source_1`, `source_2`). Two rows carrying different values
            are separate filings that agree, which is the half of this field
            worth reading; a lookup always reaches every set that accepts the
            identifier, so there is nothing here to select or tune.
        name: { type: string }
        full_name: { type: string }
        age: {}
        location: { type: string }
        profile_id: { type: string }
        aliases:
          type: array
          items: { type: string }
        past_locations:
          type: array
          items: { type: string }
        relatives:
          type: array
          items: {}
        phone_numbers:
          type: array
          items: {}
        current_address:
          type: object
          additionalProperties: true
        past_addresses:
          type: array
          items: {}
        emails:
          type: array
          items: {}

    Envelope.PeopleSearchResponse:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                records:
                  type: array
                  items: { $ref: "#/components/schemas/PersonRecord" }
                total_results: { type: integer }
                results_on_page: { type: integer }
                censored:
                  type: boolean
                  example: false
                  description: |
                    Always `false` on this surface. People search is not
                    censored on any plan; the field is kept for envelope shape.
                plan_id: { $ref: "#/components/schemas/PlanID" }

    # ----- Unlocks -----
    UnlockRequest:
      type: object
      required: [kind, record_id]
      properties:
        kind:
          type: string
          enum: [breach, device]
          description: |
            Which surface the record came from. The two surfaces address a
            record differently, so the kind is part of its identity rather
            than a hint.
        record_id:
          type: string
          maxLength: 128
          description: |
            The `record_id` the row carried - a content digest for breach rows,
            the provider's `stealerlog_id` for device rows.
        term:
          type: string
          description: |
            The query the reader was looking at when they spent the unit. Audit
            trail only; it is never read back into a gate, so it neither widens
            nor narrows the grant.
        query_type:
          type: string
          description: The search type behind that query. Audit trail only.

    Envelope.UnlockResponse:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                unlocks:
                  type: integer
                  description: Unlocks spent in the current unlock period, counted after this call.
                unlocks_limit:
                  type: integer
                  description: Unlock allowance for the unlock period; 2 a week on the free tier.
                kind: { type: string, enum: [breach, device] }
                record_id: { type: string }
                unlocked: { type: boolean, example: true }
                charged:
                  type: boolean
                  description: |
                    Whether this call spent a unit. `false` means the record was
                    already held, which costs nothing, so a double click or a
                    retry after a dropped response is safe.

    # ----- Monitors -----
    Monitor:
      type: object
      properties:
        id: { type: string }
        user_id: { type: string }
        asset: { type: string }
        asset_type: { $ref: "#/components/schemas/MonitorAssetType" }
        status: { type: string, enum: [active, paused] }
        surface: { type: string, enum: [all, stealer] }
        notify_email: { type: boolean }
        notify_webhook: { type: string }
        notify_webhook_secret: { type: string }
        stealer_first_seen_only: { type: boolean }
        last_checked: { type: string, format: date-time }
        last_hit_count: { type: integer }
        last_source_preview:
          type: array
          items: { type: string }
        last_stealer_count: { type: integer }
        last_stealer_preview:
          type: array
          items: { type: string }
        hits_total: { type: integer }
        last_hit_at: { type: string, format: date-time }
        last_notified_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Envelope.Monitor:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data: { $ref: "#/components/schemas/Monitor" }

    Envelope.MonitorList:
      allOf:
        - $ref: "#/components/schemas/EnvelopeBase"
        - type: object
          properties:
            data:
              type: object
              properties:
                monitors:
                  type: array
                  items: { $ref: "#/components/schemas/Monitor" }
                limit: { type: integer }
                used: { type: integer }
                webhooks_allowed:
                  type: boolean
                  description: |
                    Whether the plan allows webhook delivery at all. Premium and
                    Team only.

  responses:
    BadRequest:
      description: The request was malformed or invalid.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            invalidType:
              value: { success: false, error: "invalid search type" }
            shortTerm:
              value: { success: false, error: "search term must be at least 3 characters" }

    PlanGate:
      description: |
        Your plan doesn't include this feature. These gates send no `code` at
        all, and `code` is omitted when empty, so the key is absent from the
        JSON entirely: branch on the 403 status and not on a code.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            wildcard:
              value:
                success: false
                error: "wildcard search requires Pro or higher"
            sourceFilter:
              value:
                success: false
                error: "source filtering requires a paid plan"

    PlanUpgradeRequired:
      description: |
        The model-backed device tabs and raw archive search are Premium and
        Team only. The rest of the per-device browser opens on every plan.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            aiAnalysis:
              value: { success: false, error: "file browser requires Premium", code: plan_upgrade_required }
            rawSearch:
              value: { success: false, error: "raw stealer search requires Premium", code: plan_upgrade_required }

    UpgradeRequired:
      description: |
        The account does not hold the full-profile entitlement. Every declared
        plan carries it, free included, so this is reserved for a custom plan an
        admin has authored without it - read `people_full_profile` from
        `GET /search/stats` to know in advance. The code here is
        `upgrade_required`, not the longer `plan_upgrade_required` the device
        gates send.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "viewing full profiles is not available on this plan", code: upgrade_required }

    Unauthorized:
      description: The request carried no valid session or API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "unauthorized" }

    FeatureNotEnabled:
      description: |
        The feature is granted per account by an admin rather than by plan, and
        this account doesn't hold the grant.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            firstSeen:
              value: { success: false, error: "first-sighting search is not enabled for your account", code: feature_not_enabled }
            export:
              value: { success: false, error: "export is not enabled for your account", code: feature_not_enabled }

    QuotaExceeded:
      description: |
        An allowance was exhausted, or a per-hour cap was hit. Which allowance
        depends on `metering`: a pooled plan drains one `lookups` pool, while
        the free tier can run out on one surface with the others still open.
        The same code covers counters that sit on different clocks, so read
        `resets_at` and `unlocks_reset_at` from `GET /search/stats` to learn
        when the allowance you hit comes back.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            search:
              value: { success: false, error: "search allowance reached", code: quota_exceeded }
            people:
              value: { success: false, error: "people search allowance reached for this period", code: quota_exceeded }
            hourly:
              value: { success: false, error: "hourly stealer limit reached", code: hourly_limit }

    FileAccessLimit:
      description: |
        Too many distinct stealer logs opened within one clock hour. Re-opening
        a log already touched in the same hour does not count against it.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "hourly file access limit reached", code: file_access_limit }

    MonitorGate:
      description: A monitor setting your plan or account doesn't allow.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            monitorLimit:
              value: { success: false, error: "monitor limit reached for your plan", code: monitor_limit }
            webhookNotAllowed:
              value: { success: false, error: "webhook delivery is not available on your plan", code: monitor_webhook_not_allowed }
            surfaceNotAllowed:
              value: { success: false, error: "stealer-only monitors are not enabled for your account", code: monitor_surface_not_allowed }
            firstSeenNotAllowed:
              value: { success: false, error: "first-sighting stealer alerts are not enabled for your account", code: monitor_first_seen_not_allowed }

    NotFound:
      description: Resource doesn't exist or isn't owned by the authenticated user.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "monitor not found" }

    ServiceDown:
      description: This surface is disabled or unreachable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "service temporarily unavailable" }
