openapi: 3.1.0
info:
  title: Verinode API
  version: "1.0.0"
  summary: Two APIs, one platform - Verinode IQ for operators, Verinode HQ for networks.
  description: |
    Verinode exposes two REST APIs on the same host, told apart by the kind of
    key you present:

    - **Verinode IQ (Operator API)** - an operator's own data. Push documents,
      events, and records in; read jobs, vendors, and clients back out; export;
      subscribe to webhooks. Keys `vn_live_…` / `vn_test_…`. (The third ingestion
      primitive, alongside Connect (OAuth) and Flow (routed exports).) Tagged
      Meta / Documents / Records / Events / Export / Webhooks below.
    - **Verinode HQ (Network API)** - a network's aggregated intelligence. Read
      network health, the member roster, and compliance posture; export the
      whole snapshot. Read-only. Keys `vn_group_live_…`. Tagged **Network (HQ)**
      below.

    HQ sits on top of IQ: members contribute data up through IQ, HQ aggregates it
    across the network. An operator never sees the network; a network never sees
    a member's raw data. Neither API exposes peer benchmark numbers, and IQ meters
    AI-powered work on the same Intelligence Unit balance as the rest of the
    platform.

    ## Authentication

    Every request carries a bearer API key:

    ```
    Authorization: Bearer vn_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    ```

    Keys are minted in the dashboard under **Settings → Developer** ,  not through
    this API ,  and the secret is shown exactly once. Minting requires the **admin**
    role on the account, so there is no way to bootstrap the first key
    programmatically. Scopes are fixed at creation and cannot be edited ,  to change
    them, mint a new key. There are three kinds:

    - `vn_live_…` (IQ) ,  act on your real operator data. Requires a paid membership
      (`403 api_not_entitled` without one).
    - `vn_test_…` (IQ) ,  act as a separate **sandbox operator**. Free on any plan,
      never charges Intelligence Units, and never touches live data, the
      intelligence layer, or your benchmarks. The sandbox is an **empty tenant, not
      a copy of your account**: it starts with no records, so `GET /jobs` on a fresh
      test key correctly returns `{ "data": [] }`. Write into it to give yourself
      fixtures. Document extraction on a test key runs the real pipeline and is
      capped at 25/day (`429 sandbox_quota`).
    - `vn_group_live_…` (HQ) ,  read a network's aggregated intelligence. Minted by
      a network admin; requires an active HQ subscription. Carries network scopes
      (`network:read`, `members:read`, `compliance:read`, `exports:read`).

    **This API is server-side only.** It returns no CORS headers, so a browser
    `fetch` cannot call it ,  and an API key in frontend code is a leaked
    credential. Call it from your backend.

    ## Conventions

    - **Base URL:** `https://api.verinode.ai/v1`
    - **IDs** are UUID strings. **Timestamps** are ISO 8601 UTC. **Money** is a
      decimal string (e.g. `"18450.00"`), never a float.
    - **Request tracing:** every response carries a `Verinode-Request-Id` header.
      Quote it when contacting support.
    - **Rate limits:** every *authenticated* response carries `RateLimit-Limit`,
      `RateLimit-Remaining`, and `RateLimit-Reset` (seconds). Responses rejected
      before the key resolves ,  `401`, `403 insufficient_scope`, and the pre-auth
      per-IP `429` ,  carry no `RateLimit-*` headers, because there is no operator
      to report a quota for. Exceeding a limit returns `429` with `Retry-After`.
    - **Idempotency:** `Idempotency-Key` is honored on exactly four endpoints ,
      `POST /documents`, `POST /jobs`, `POST /clients`, and
      `POST /webhook-endpoints`. On those, a retry replays the first response
      instead of re-executing (no double charge, no duplicate record). Reusing a
      key with a *different* body returns `422 idempotency_key_reuse` rather than
      the earlier response.

      The header is **ignored** on `POST /events`, `POST /events/batch`, and
      `POST /exports` ,  sending it there is silently a no-op, not a safety net.
      Events have their own, better dedupe: give each event an `external_id` and
      re-sending it is a no-op that comes back `status: "duplicate"`. **An event
      with no `external_id` is written again on every retry**, so a retried batch
      double-writes exactly those events. For exports, poll
      `GET /exports/{id}`; a second create while one is in flight is refused with
      `409 export_in_progress`.
  contact:
    name: Verinode Developer Support
    url: https://docs.verinode.ai
  x-status-page: https://status.verinode.ai

servers:
  - url: https://api.verinode.ai/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Meta
    description: Connectivity and key introspection.
  - name: Documents
    description: Push documents through the extraction pipeline and poll results.
  - name: Records
    description: Create and read structured records directly.
  - name: Events
    description: Import activity events to feed process mining.
  - name: Export
    description: Export your own data asynchronously.
  - name: Webhooks
    description: Receive real-time events at your own endpoints.
  - name: Network (HQ)
    description: >-
      Verinode HQ. Read a network's aggregated intelligence with a group key
      (vn_group_live_). Aggregates + roster + compliance only, never a member's
      raw data.

paths:
  /ping:
    get:
      tags: [Meta]
      summary: Verify a key
      description: Authenticates the key and echoes its resolved identity. The fastest way to confirm setup.
      operationId: ping
      responses:
        "200":
          description: The key is valid.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
            RateLimit-Limit: { $ref: "#/components/headers/RateLimitLimit" }
            RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
            RateLimit-Reset: { $ref: "#/components/headers/RateLimitReset" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ping" }
              example:
                ok: true
                operator_id: "ade775da-48cb-45e1-8357-d93000858cbc"
                mode: "test"
                scopes: ["read", "keys", "write", "ingest"]
                sandbox: true
                request_id: "req_7db52edac8044d5d88c3947b71b3ce77"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api-keys:
    get:
      tags: [Meta]
      summary: List your API keys
      description: |
        Returns metadata for every key on your account. Secrets are **never**
        returned ,  only the prefix and status. Requires the `keys` scope.
        Keys are created and revoked in the dashboard, not through this API.
      operationId: listApiKeys
      security:
        - bearerAuth: [keys]
      responses:
        "200":
          description: The key list.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ApiKey" }
                required: [data]
              example:
                data:
                  - id: "3a7eb141-cd3f-438c-a20e-68f5a1d532ea"
                    name: "Manage Keys Test"
                    mode: "test"
                    prefix: "vn_test_rJXtvI"
                    scopes: ["read", "keys", "write", "ingest"]
                    last_used_at: "2026-07-10T17:53:55.415+00:00"
                    created_at: "2026-07-10T17:52:31.392689+00:00"
                    revoked: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /documents:
    post:
      tags: [Documents]
      summary: Ingest a document
      description: |
        Upload a document (invoice, estimate, scope sheet, photo, spreadsheet)
        for extraction through the same pipeline as email and in-app uploads.
        Processing is **asynchronous**: this returns `202` immediately with an
        ingestion id ,  poll `GET /ingestions/{id}` for the result and receipt.

        Send the file two ways:

        - `multipart/form-data` with a `file` part (the filename rides along), or
        - `application/json` with **`filename` (required)** + base64
          `content_base64`. Omitting `filename` on the JSON lane is a
          `400 missing_file` however valid the bytes are ,  the extension is how
          the content type is resolved when `content_type` is omitted.

        Accepts PDF, JPEG, PNG, HEIC, CSV, XLSX/XLS, DOCX/DOC, TXT, up to 25 MB.
        Requires the `ingest` scope. 300/hour.

        **Billing:** live keys are charged **3 IU per document, at accept** ,  when this endpoint
        returns `202` and the document is queued ,  NOT when extraction runs and
        NOT on success. A document that extracts nothing useful, or fails
        outright, is **not refunded**: a `202` means the Intelligence Units are
        already spent. An exhausted balance returns `402` before anything is
        queued. Because the charge lands at accept, `Idempotency-Key` matters
        more here than anywhere else ,  a retry without one is a second charge.

        Test keys are free but run the same real extraction pipeline, so they are
        capped at 25 documents/day per sandbox (`429 sandbox_quota`).
      operationId: ingestDocument
      security:
        - bearerAuth: [ingest]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
              required: [file]
          application/json:
            schema: { $ref: "#/components/schemas/DocumentJsonUpload" }
            example:
              filename: "water-mitigation-invoice.pdf"
              content_type: "application/pdf"
              content_base64: "JVBERi0xLjcKJ..."
      responses:
        "202":
          description: Accepted for extraction.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestionAccepted" }
              example:
                id: "b1f2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                status: "processing"
                filename: "water-mitigation-invoice.pdf"
                request_id: "req_9fa15e0140a44895b091b6110b8afba1"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/IuExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "415": { $ref: "#/components/responses/UnsupportedMediaType" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /ingestions/{id}:
    get:
      tags: [Documents]
      summary: Get an ingestion
      description: |
        Returns the status of a document ingestion and, once complete, the
        **extraction receipt** ,  a humanized record of what Verinode detected,
        saved, and normalized. Requires the `ingest` scope.
      operationId: getIngestion
      security:
        - bearerAuth: [ingest]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The ingestion.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ingestion" }
              example:
                id: "b1f2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
                status: "completed"
                filename: "water-mitigation-invoice.pdf"
                created_at: "2026-07-10T18:02:11.000Z"
                completed_at: "2026-07-10T18:02:47.000Z"
                receipt:
                  document_type: "Water Mitigation Invoice"
                  completeness: 92
                  extracted_fields: ["Vendor Name", "Invoice Total", "Line Items"]
                  saved: ["Job Costs", "Vendor"]
                  transforms:
                    - field: "Invoice Total"
                      note: "Currency Normalized applied"
                  warnings: []
                error: null
                request_id: "req_dd6259535c1f4f0eb96d2cdfbbf2a52b"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /events:
    post:
      tags: [Events]
      summary: Import an event
      description: |
        Record a single process-mining event ,  the classic case / activity /
        timestamp triple in restoration vocabulary. Feeds Tier 1 process
        discovery, and when the activity is one of the seven canonical
        milestones (`assigned`, `estimate_submitted`, `carrier_approved`,
        `started`, `completed`, `billed`, `paid`) it also fills the job's
        milestone date (fill-only, never overwriting an existing date).
        Requires the `write` scope. Free, no Intelligence Units. Idempotent
        per `external_id` + activity + case.
      operationId: createEvent
      security:
        - bearerAuth: [write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventInput" }
            example:
              activity: "moisture_reading_taken"
              occurred_at: "2026-07-09T14:30:00Z"
              case: { job_id: "7f3c2e1a-0000-0000-0000-000000000000" }
              domain: "field"
              resource: "tech:jane-r"
              value: 12.4
              unit: "percent_wme"
              attributes: { room: "master_bath", material: "drywall" }
              external_id: "dash-evt-991042"
      responses:
        "201":
          description: Event recorded.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "200":
          description: Duplicate ,  this event was already recorded (idempotent no-op).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventAccepted" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: The case reference did not resolve (unknown or ambiguous job).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /events/batch:
    post:
      tags: [Events]
      summary: Import a batch of events
      description: |
        Import up to 1,000 events in one call. Per-event independence: one
        malformed event never fails the batch, so a backfill gets partial
        progress. For backfills larger than 1,000 events use the async NDJSON
        import (coming soon). Requires the `write` scope. Free.
      operationId: createEventBatch
      security:
        - bearerAuth: [write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                events:
                  type: array
                  maxItems: 1000
                  items: { $ref: "#/components/schemas/EventInput" }
              required: [events]
      responses:
        "200":
          description: Batch processed. Inspect per-event results.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventBatchResult" }
              example:
                accepted: 983
                rejected: 17
                results:
                  - { index: 0, status: "created", id: "b1f2c3d4-0000-0000-0000-000000000000" }
                  - { index: 1, status: "duplicate", id: null }
                  - { index: 2, status: "rejected", error: { code: "case_not_found", message: "No job matches that claim_number." } }
                request_id: "req_9fa15e0140a44895b091b6110b8afba1"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "413":
          description: More than 1,000 events in the batch.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs:
    get:
      tags: [Records]
      summary: List jobs
      description: Cursor-paginated list of your jobs. Requires the `read` scope.
      operationId: listJobs
      security:
        - bearerAuth: [read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/UpdatedSince"
      responses:
        "200":
          description: A page of jobs.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Records]
      summary: Create a job
      description: |
        Create a job record directly, without a source document. Fields pass
        through the schema validator; `operator_id`, `id`, `source`, and
        timestamps are server-owned and ignored if sent. Provenance is stamped
        `source: "api"`. Requires the `write` scope. Free (no AI runs).
      operationId: createJob
      security:
        - bearerAuth: [write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobCreate" }
            example:
              client_name: "Riverside Restoration LLC"
              status: "open"
              property_type: "residential"
              peril_token: "water"
              rcv_total: "18450.00"
              deductible: "1000.00"
              date_started: "2026-07-01"
      responses:
        "201":
          description: |
            Created. The body is the created job, serialized exactly as
            `GET /jobs/{id}` returns it ,  so you can see what the schema
            validator actually stored (coercions, defaults) without a second
            round trip. `resource` and `request_id` are included alongside.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Job"
                  - $ref: "#/components/schemas/CreatedEnvelope"
              example:
                id: "c7d8e9f0-1a2b-3c4d-5e6f-7a8b9c0d1e2f"
                status: "open"
                status_label: "Open"
                client_name: "Riverside Restoration LLC"
                property_type: "residential"
                property_type_label: "Residential"
                peril: "water"
                peril_label: "Water"
                financials: { rcv_total: "18450.00", deductible: "1000.00" }
                dates: { started: "2026-07-01" }
                source: "api"
                created_at: "2026-07-17T12:00:00.000Z"
                updated_at: "2026-07-17T12:00:00.000Z"
                resource: "jobs"
                request_id: "req_5f0e140a44895b091b6110b8afba19fa"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /jobs/{id}:
    get:
      tags: [Records]
      summary: Retrieve a job
      description: |
        Fetch one job by id, serialized identically to its row in
        `GET /jobs`. Requires the `read` scope.

        Returns `404` both when no such job exists and when it belongs to
        another operator ,  the two are deliberately indistinguishable, so ids
        cannot be probed to learn what exists outside your account.
      operationId: getJob
      security:
        - bearerAuth: [read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The job.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /clients:
    get:
      tags: [Records]
      summary: List clients
      description: Cursor-paginated list of your clients, contact fields decrypted. Requires the `read` scope.
      operationId: listClients
      security:
        - bearerAuth: [read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/UpdatedSince"
      responses:
        "200":
          description: A page of clients.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Client" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Records]
      summary: Create a client
      description: |
        Create a client record directly. PII fields (name, contact) are
        encrypted at rest on write. `operator_id`, `id`, `source`, and timestamps
        are server-owned. Provenance is stamped `source: "api"`. Requires the
        `write` scope. Free (no AI runs).
      operationId: createClient
      security:
        - bearerAuth: [write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ClientCreate" }
            example:
              name: "Acme Property Management"
              client_type: "commercial"
              contact_name: "Dana Reyes"
              contact_email: "dana@acmepm.example"
              contact_phone: "+1-555-0142"
      responses:
        "201":
          description: |
            Created. The body is the created client, serialized exactly as
            `GET /clients/{id}` returns it. `resource` and `request_id` are
            included alongside.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Client"
                  - $ref: "#/components/schemas/CreatedEnvelope"
              example:
                id: "d8e9f0a1-2b3c-4d5e-6f7a-8b9c0d1e2f3a"
                name: "Acme Property Management"
                client_type: "commercial"
                client_type_label: "Commercial"
                status: "active"
                status_label: "Active"
                contact: { name: "Dana Reyes", email: "dana@acmepm.example", phone: "+1-555-0142" }
                created_at: "2026-07-17T12:00:00.000Z"
                updated_at: "2026-07-17T12:00:00.000Z"
                resource: "clients"
                request_id: "req_140a44895b091b6110b8afba19fa5f0e"
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /clients/{id}:
    get:
      tags: [Records]
      summary: Retrieve a client
      description: |
        Fetch one client by id, serialized identically to its row in
        `GET /clients`. Requires the `read` scope.

        Returns `404` both when no such client exists and when it belongs to
        another operator ,  the two are deliberately indistinguishable, so ids
        cannot be probed to learn what exists outside your account.
      operationId: getClient
      security:
        - bearerAuth: [read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The client.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Client" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /vendors:
    get:
      tags: [Records]
      summary: List vendors
      description: |
        Cursor-paginated list of your vendors. Read-only in v1 ,  creating a
        vendor requires catalog resolution and is a follow-up. Requires the
        `read` scope.
      operationId: listVendors
      security:
        - bearerAuth: [read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/UpdatedSince"
      responses:
        "200":
          description: A page of vendors.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Vendor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /vendors/{id}:
    get:
      tags: [Records]
      summary: Retrieve a vendor
      description: |
        Fetch one vendor by id, serialized identically to its row in
        `GET /vendors` (read-only in v1, like the list). Requires the `read` scope.

        Returns `404` both when no such vendor exists and when it belongs to
        another operator ,  the two are deliberately indistinguishable, so ids
        cannot be probed to learn what exists outside your account.
      operationId: getVendor
      security:
        - bearerAuth: [read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The vendor.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Vendor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /exports:
    post:
      tags: [Export]
      summary: Request a full-account export
      description: |
        Kick off an asynchronous export of your own data. Omit `resources` to
        export everything supported. Returns `202` with an export job ,  poll
        `GET /v1/exports/{id}` until it completes, then download. The artifact is
        encrypted at rest and expires after 7 days. `read` scope, free. One
        export at a time, up to 4 per day.
      operationId: createExport
      security:
        - bearerAuth: [read]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                resources:
                  type: array
                  items: { type: string }
                  description: Resource names to include (e.g. jobs, vendors, clients). Omit for all supported.
                format: { type: string, enum: [json], default: json }
            example:
              resources: ["jobs", "clients", "vendors"]
      responses:
        "202":
          description: Export queued.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExportAccepted" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: An export is already in progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /exports/{id}:
    get:
      tags: [Export]
      summary: Get an export
      description: Poll an export job's status, row counts, and download URL. `read` scope.
      operationId: getExport
      security:
        - bearerAuth: [read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The export job.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ExportJob" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /exports/{id}/download:
    get:
      tags: [Export]
      summary: Download an export
      description: |
        Stream the decrypted export artifact (a JSON bundle of `manifest` +
        per-resource arrays). Available only while the export is `completed` and
        unexpired. `read` scope.
      operationId: downloadExport
      security:
        - bearerAuth: [read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The export artifact.
          content:
            application/json:
              schema:
                type: object
                description: "{ manifest, resources: { jobs: [...], ... } }"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The export is not ready yet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "410":
          description: The export has expired.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /webhook-endpoints:
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: |
        Register an HTTPS URL to receive real-time events. The signing `secret`
        is returned **once** ,  store it to verify the `Verinode-Signature`
        header. Omit `events` to receive all. `webhooks` scope.
      operationId: createWebhookEndpoint
      security:
        - bearerAuth: [webhooks]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointInput" }
            example:
              url: "https://example.com/verinode/webhooks"
              events: ["job.created", "ingestion.completed"]
              description: "Sync jobs to our BI"
      responses:
        "201":
          description: Endpoint created (secret shown once).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointCreated" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: Endpoint limit reached.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      description: List your endpoints. Secrets are never returned. `webhooks` scope.
      operationId: listWebhookEndpoints
      security:
        - bearerAuth: [webhooks]
      responses:
        "200":
          description: Your endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /webhook-endpoints/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      security:
        - bearerAuth: [webhooks]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  deleted: { type: boolean }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /outbound-events:
    get:
      tags: [Webhooks]
      summary: List outbound events (poll fallback)
      description: |
        Your recent outbound events ,  the same payloads webhooks deliver ,  for
        partners who poll instead of receiving pushes. Cursor-paginated.
        `webhooks` scope.
      operationId: listOutboundEvents
      security:
        - bearerAuth: [webhooks]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/OutboundEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ── Verinode HQ (Network API) - group keys (vn_group_live_) ────────────────
  /network/ping:
    get:
      tags: [Network (HQ)]
      summary: Verify a group key
      description: Authenticates the group key and echoes the network it resolves to. The fastest way to confirm HQ setup.
      operationId: networkPing
      responses:
        "200":
          description: The group key is valid.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NetworkPing" }
              example:
                ok: true
                group_id: "b2f1c0de-0000-4a1b-9c2d-000000000001"
                tier: "premier"
                scopes: ["network:read", "members:read", "compliance:read", "exports:read"]
                request_id: "req_9c1f…"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/summary:
    get:
      tags: [Network (HQ)]
      summary: Network-health snapshot
      description: |
        The group's latest network snapshot: membership counts, compliance rate,
        reputation and action-rate composites, and margin percentiles. Sensitive
        metrics are suppressed (`null`) until a cohort is large enough to protect
        any single member; a `k_anon` block reports which thresholds were met.
        Requires `network:read`.
      operationId: networkSummary
      security:
        - bearerAuth: [network:read]
      responses:
        "200":
          description: The latest snapshot (or null if none yet).
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/NetworkSummary"
                      - type: "null"
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/summary/history:
    get:
      tags: [Network (HQ)]
      summary: Snapshot history
      description: |
        The network-health snapshot series, most recent first, each row
        k-anon-gated identically to the latest snapshot. The time-series for a
        network's own warehouse. Cursor is a `snapshot_date`. Requires
        `network:read`.
      operationId: networkSummaryHistory
      security:
        - bearerAuth: [network:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of snapshots.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkSummary" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/members:
    get:
      tags: [Network (HQ)]
      summary: Member roster
      description: |
        Cursor-paginated roster of the group's own members: location, owner,
        city, state, membership status, and open/invite dates. Never a member's
        raw business data, contact details, or operator linkage. Requires
        `members:read`.
      operationId: networkMembers
      security:
        - bearerAuth: [members:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of members.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkMember" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/compliance:
    get:
      tags: [Network (HQ)]
      summary: Compliance posture
      description: |
        Cursor-paginated per-member compliance posture, by name: cert type,
        status, expiration, and carrier-program eligibility. Roster and status
        only. Requires `compliance:read`.
      operationId: networkCompliance
      security:
        - bearerAuth: [compliance:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of compliance rows.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkComplianceRow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/signals:
    get:
      tags: [Network (HQ)]
      summary: Network signals
      description: |
        The network pattern layer: how many members hit each signal (e.g. 12 of
        30 on vendor cost drift), by severity and domain. Counts only, never
        member attribution. Every token ships a humanized `_label`.
        Cursor-paginated. Requires `network:read`.
      operationId: networkSignals
      security:
        - bearerAuth: [network:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of signals.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkSignal" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/programs:
    get:
      tags: [Network (HQ)]
      summary: Program adoption
      description: |
        Adoption rates across the network by program (carrier / TPA / vendor):
        active vs adopted members and adoption percentage. Rates only, never
        which member declined. Cursor-paginated. Requires `network:read`.
      operationId: networkPrograms
      security:
        - bearerAuth: [network:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of program-adoption rows.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkProgram" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/process:
    get:
      tags: [Network (HQ)]
      summary: Process aggregates
      description: |
        Network-wide process-mining medians (jobs, recruiting, safety,
        supplements): stage-to-stage median / p25 / p75 days and sample size.
        The cross-network industry benchmark columns are stripped (the moat).
        Cursor-paginated. Requires `network:read`.
      operationId: networkProcess
      security:
        - bearerAuth: [network:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of process aggregates.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/NetworkProcessRow" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /network/export:
    get:
      tags: [Network (HQ)]
      summary: Export the network snapshot
      description: |
        Assembles the snapshot, full roster, and full compliance posture into one
        JSON bundle, using the same serialization as the individual endpoints.
        Synchronous (network data is bounded). Requires `exports:read`.
      operationId: networkExport
      security:
        - bearerAuth: [exports:read]
      responses:
        "200":
          description: The network bundle.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/NetworkExport" }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: vn_live_ / vn_test_ (IQ) or vn_group_live_ (HQ) key
      description: |
        A Verinode API key as a bearer token. IQ keys (`vn_live_` / `vn_test_`)
        carry operator scopes (`read`, `write`, `ingest`, `webhooks`, `keys`);
        HQ group keys (`vn_group_live_`) carry network scopes (`network:read`,
        `members:read`, `compliance:read`, `exports:read`).

        Scopes are attached at key creation and are immutable — to change them,
        mint a new key. An endpoint lists the scope it needs, and a key missing
        it gets `403 insufficient_scope`. `GET /ping` and `GET /network/ping`
        require no scope, which makes them the right first call when debugging
        a `403`.

        `keys:manage` is accepted when minting a group key but is not currently
        required by any endpoint — it is reserved, and grants nothing today.

  headers:
    RequestId:
      description: Unique id for this request. Quote it in support requests.
      schema: { type: string }
    RateLimitLimit:
      description: Request ceiling for the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests remaining in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Seconds until the window resets.
      schema: { type: integer }

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size, 1–100. Defaults to 25.
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      description: |
        Opaque cursor from a previous page's `next_cursor`. Pass it back verbatim
        ,  it encodes a keyset over `(updated_at, id)`, not an offset, so pages
        stay correct while rows are being written. Do not construct or parse one;
        a cursor this API did not issue is rejected with `invalid_cursor`.
      required: false
      schema: { type: string }
    CreatedAfter:
      name: created_after
      in: query
      description: |
        Only records created strictly after this ISO 8601 timestamp (exclusive).
        Use for a first backfill window; use `updated_since` for ongoing sync.
      required: false
      schema: { type: string, format: date-time }
      example: "2026-07-01T00:00:00Z"
    UpdatedSince:
      name: updated_since
      in: query
      description: |
        Only records changed at or after this ISO 8601 timestamp (INCLUSIVE).
        This is the incremental-sync filter: pass the `synced_through` value from
        your last completed sync. The bound is inclusive on purpose ,  it may
        re-deliver rows you already have (upsert by `id`) rather than risk
        dropping a row that shares the boundary instant.
      required: false
      schema: { type: string, format: date-time }
      example: "2026-07-17T09:30:00Z"
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: A unique key that makes this POST safe to retry. The first response is replayed for later requests with the same key.
      required: false
      schema: { type: string }

  schemas:
    Error:
      type: object
      description: Every non-2xx response uses this envelope.
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              description: Broad category (authentication_error, authorization_error, invalid_request_error, rate_limit_error, billing_error, api_error).
            code:
              type: string
              description: Machine-readable specific code (e.g. invalid_api_key, insufficient_scope, iu_exhausted).
            message: { type: string }
            doc_url: { type: [string, "null"] }
            request_id: { type: string }
          required: [type, code, message, request_id]
      required: [error]
      example:
        error:
          type: "authentication_error"
          code: "invalid_api_key"
          message: "Malformed API key."
          doc_url: "https://docs.verinode.ai/errors#invalid_api_key"
          request_id: "req_dd6259535c1f4f0eb96d2cdfbbf2a52b"

    Ping:
      type: object
      properties:
        ok: { type: boolean }
        operator_id: { type: string, format: uuid }
        mode: { type: string, enum: [live, test] }
        scopes:
          type: array
          items: { type: string, enum: [read, write, ingest, keys, webhooks] }
        sandbox: { type: boolean, description: True when the key resolves to the isolated test sandbox. }
        request_id: { type: string }
      required: [ok, operator_id, mode, scopes, sandbox, request_id]

    ApiKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        mode: { type: string, enum: [live, test] }
        prefix: { type: string, description: The non-secret key prefix, safe to display. }
        scopes:
          type: array
          items: { type: string }
        last_used_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        revoked: { type: boolean }
      required: [id, mode, prefix, scopes, created_at, revoked]

    DocumentJsonUpload:
      type: object
      properties:
        filename: { type: string }
        content_type: { type: string, description: MIME type. Optional; inferred from filename when omitted. }
        content_base64: { type: string, description: Base64-encoded file bytes. }
      required: [filename, content_base64]

    IngestionAccepted:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [processing] }
        filename: { type: [string, "null"] }
        request_id: { type: string }
      required: [id, status, request_id]

    Ingestion:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, completed, failed, canceled]
          description: |
            `processing` is the only non-terminal state — poll until it changes.
            `canceled` means the operator aborted this ingestion in the Verinode
            dashboard; it will never be processed, so stop polling and re-send
            the document if you still need it.
        filename: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        receipt:
          oneOf:
            - $ref: "#/components/schemas/Receipt"
            - type: "null"
          description: Present once status is `completed`.
        error: { type: [string, "null"], description: An explanation when status is `failed` or `canceled`; null otherwise. }
        request_id: { type: string }
      required: [id, status, created_at, request_id]

    Receipt:
      type: object
      description: A humanized record of what extraction did. "Show your work."
      properties:
        document_type: { type: [string, "null"] }
        completeness: { type: [number, "null"], description: 0–100 confidence of a complete extraction. }
        extracted_fields:
          type: array
          items: { type: string }
        saved:
          type: array
          items: { type: string }
          description: Which parts of the operating record this document updated.
        transforms:
          type: array
          items: { $ref: "#/components/schemas/ReceiptTransform" }
          description: Normalizations applied to reconcile the document with the schema.
        warnings:
          type: array
          items: { type: string }
      required: [extracted_fields, saved, transforms, warnings]

    ReceiptTransform:
      type: object
      properties:
        field: { type: string }
        note: { type: string }
      required: [field, note]

    Page:
      type: object
      description: |
        Cursor pagination envelope. Ordering is `(updated_at, id)` ascending ,  a
        total order (id breaks timestamp ties), so pagination is stable even
        while rows are written concurrently.

        Sync is AT-LEAST-ONCE: a row edited mid-pagination gets a new
        `updated_at` and moves ahead of your cursor, so it arrives again on a
        later page instead of being skipped. Always upsert by `id`.
      properties:
        data:
          type: array
          items: {}
        next_cursor: { type: [string, "null"], description: Pass as `cursor` to fetch the next page. Null on the last page. }
        has_more: { type: boolean }
        synced_through:
          type: [string, "null"]
          format: date-time
          description: |
            High-water mark to store and pass as `updated_since` on your next
            sync. Non-null ONLY on the last page (`has_more: false`) ,  it is
            null while pages remain, because committing a high-water mark before
            you have drained the pages would skip everything still queued behind
            the cursor.
      required: [data, next_cursor, has_more]

    RecordCreated:
      type: object
      deprecated: true
      description: |
        The pre-2026-07 create response ,  a bare id. Superseded: create now
        returns the full record (see `CreatedEnvelope`). Retained because `id`,
        `resource`, and `request_id` are still present on every create response,
        so integrations reading only those fields are unaffected.
      properties:
        id: { type: string, format: uuid }
        resource: { type: string }
        request_id: { type: string }
      required: [id, resource, request_id]

    CreatedEnvelope:
      type: object
      description: Fields added alongside the created record on a 201.
      properties:
        resource: { type: string, description: The resource name, e.g. "jobs". }
        request_id: { type: string }
      required: [resource, request_id]

    WebhookEndpointInput:
      type: object
      properties:
        url: { type: string, format: uri, description: HTTPS URL. Must resolve to a public address. }
        events:
          type: array
          items:
            type: string
            enum: [job.created, client.created, export.completed, ingestion.completed, ingestion.failed]
          description: |
            Event types to receive, or omit / ["*"] for all. Only events with a
            live producer are listed ,  you can never subscribe to one that never
            fires.

            `ingestion.completed` / `ingestion.failed` fire ONLY for documents
            you pushed through `POST /documents`. Documents the operator emails
            in or uploads in the app are their own activity and never reach your
            endpoint. `ingestion.failed` is terminal (retries exhausted), not a
            transient blip. Both carry `{id, resource: "ingestions", status}` ,
            read `GET /ingestions/{id}` for the receipt.
        description: { type: string }
      required: [url]

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events:
          type: array
          items: { type: string }
        status: { type: string, enum: [active, disabled] }
        description: { type: [string, "null"] }
        consecutive_failures: { type: integer }
        last_success_at: { type: [string, "null"], format: date-time }
        last_failure_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
      required: [id, url, events, status, created_at]

    WebhookEndpointCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret: { type: string, description: Signing secret (whsec_…). Returned only once. }
            request_id: { type: string }
          required: [secret]

    OutboundEvent:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { type: string }
        created_at: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
      required: [id, type, created_at, data]

    ExportAccepted:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [processing] }
        resources:
          type: array
          items: { type: string }
        request_id: { type: string }
      required: [id, status, request_id]

    ExportJob:
      type: object
      properties:
        id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, completed, failed, expired]
          description: |
            `processing` covers both queued and running — poll until it changes.
            The create response and the first poll always agree.
        status_label: { type: string }
        resources:
          type: array
          items: { type: string }
        resource_counts:
          type: [object, "null"]
          additionalProperties: { type: integer }
        download_url: { type: [string, "null"], description: Present only while completed and unexpired. }
        download_expires_at: { type: [string, "null"], format: date-time }
        error: { type: [string, "null"] }
        requested_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time }
        request_id: { type: string }
      required: [id, status, resources, request_id]

    EventInput:
      type: object
      properties:
        activity:
          type: string
          description: What happened. Normalized to a lowercase token (open set). Milestone tokens (assigned, estimate_submitted, carrier_approved, started, completed, billed, paid) also fill the job date.
        occurred_at:
          type: string
          format: date-time
          description: When it happened (ISO 8601). Required; not in the future, not older than 10 years.
        case:
          description: How to tie the event to a job. Omit or null for a job-less event.
          oneOf:
            - type: object
              properties:
                job_id: { type: string, format: uuid }
              required: [job_id]
            - type: object
              properties:
                claim_number: { type: string }
              required: [claim_number]
            - type: "null"
        domain:
          type: string
          enum: [vendor, field, client, billing, team]
          default: field
        resource: { type: string, description: Who or what performed the activity (e.g. a technician or system). }
        value: { type: number, description: Optional measurement (reading, hours, dollars). }
        unit: { type: string }
        attributes:
          type: object
          description: Free-form context, max 32 keys / 8 KB.
          additionalProperties: true
        activity_label: { type: string, description: Optional display label; otherwise the activity is humanized. }
        external_id:
          type: string
          description: The partner's stable id for this event. Makes the import idempotent ,  re-sending is a no-op.
      required: [activity, occurred_at]

    EventAccepted:
      type: object
      properties:
        id: { type: [string, "null"], format: uuid, description: Null when status is "duplicate". }
        status: { type: string, enum: [created, duplicate] }
        request_id: { type: string }
      required: [status, request_id]

    EventBatchResult:
      type: object
      properties:
        accepted: { type: integer }
        rejected: { type: integer }
        results:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              status: { type: string, enum: [created, duplicate, rejected] }
              id:
                type: [string, "null"]
                format: uuid
                description: The new event id when created; null when duplicate. Absent only on a rejected item.
              error:
                type: object
                description: Present only when status is `rejected`.
                properties:
                  code: { type: string }
                  message: { type: string }
            required: [index, status]
        request_id: { type: string }
      required: [accepted, rejected, results, request_id]

    Money:
      type: [string, "null"]
      description: A decimal amount as a string (e.g. "18450.00"), or null.
      examples: ["18450.00"]

    JobCreate:
      type: object
      description: |
        Job fields. All optional except as your data model requires; unknown and
        server-owned keys are ignored. Enum-like fields (status, property_type,
        peril_token, work_phase, client_type) accept the platform's tokens.
      properties:
        client_name: { type: string }
        client_type: { type: string }
        status: { type: string }
        property_type: { type: string }
        peril_token: { type: string }
        work_phase: { type: string }
        state: { type: string }
        is_cat: { type: boolean }
        rcv_total: { type: string }
        acv_total: { type: string }
        net_claim: { type: string }
        deductible: { type: string }
        billed_amount: { type: string }
        collected_amount: { type: string }
        approved_amount: { type: string }
        supplement_count: { type: integer }
        date_started: { type: string, format: date }
        date_completed: { type: string, format: date }
        date_paid: { type: string, format: date }
        date_billed: { type: string, format: date }
      additionalProperties: true

    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: [string, "null"] }
        status_label: { type: [string, "null"] }
        client_name: { type: [string, "null"] }
        client_type: { type: [string, "null"] }
        client_type_label: { type: [string, "null"] }
        property_type: { type: [string, "null"] }
        property_type_label: { type: [string, "null"] }
        peril: { type: [string, "null"] }
        peril_label: { type: [string, "null"] }
        work_phase: { type: [string, "null"] }
        work_phase_label: { type: [string, "null"] }
        state: { type: [string, "null"] }
        is_cat: { type: [boolean, "null"] }
        financials:
          type: object
          properties:
            rcv_total: { $ref: "#/components/schemas/Money" }
            acv_total: { $ref: "#/components/schemas/Money" }
            net_claim: { $ref: "#/components/schemas/Money" }
            deductible: { $ref: "#/components/schemas/Money" }
            billed_amount: { $ref: "#/components/schemas/Money" }
            collected_amount: { $ref: "#/components/schemas/Money" }
            approved_amount: { $ref: "#/components/schemas/Money" }
            supplement_count: { type: [integer, "null"] }
            days_to_pay: { type: [integer, "null"] }
        dates:
          type: object
          properties:
            started: { type: [string, "null"], format: date }
            completed: { type: [string, "null"], format: date }
            paid: { type: [string, "null"], format: date }
            billed: { type: [string, "null"], format: date }
        source: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
      required: [id, created_at]

    ClientCreate:
      type: object
      properties:
        name: { type: string }
        client_type: { type: string }
        status: { type: string }
        contact_name: { type: string }
        contact_email: { type: string }
        contact_phone: { type: string }
        notes: { type: string }
      required: [name]
      additionalProperties: true

    Client:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        client_type: { type: [string, "null"] }
        client_type_label: { type: [string, "null"] }
        status: { type: [string, "null"] }
        status_label: { type: [string, "null"] }
        contact:
          type: object
          properties:
            name: { type: [string, "null"] }
            email: { type: [string, "null"] }
            phone: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        archived_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
      required: [id, created_at]

    Vendor:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: [string, "null"] }
        vendor_type: { type: [string, "null"] }
        vendor_type_label: { type: [string, "null"] }
        domain: { type: [string, "null"] }
        payment_terms_days: { type: [integer, "null"] }
        needs_review: { type: [boolean, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: [string, "null"], format: date-time }
      required: [id, created_at]

    # ── Verinode HQ (Network API) schemas ──────────────────────────────────
    NetworkPing:
      type: object
      properties:
        ok: { type: boolean }
        group_id: { type: string, format: uuid }
        tier: { type: [string, "null"] }
        scopes:
          type: array
          items: { type: string, enum: [network:read, members:read, compliance:read, exports:read, keys:manage] }
        request_id: { type: string }
      required: [ok, group_id, scopes, request_id]

    NetworkSummary:
      type: object
      description: Group network snapshot. Sensitive metrics are null until the k-anon floor is met.
      properties:
        snapshot_date: { type: string, format: date }
        total_members: { type: [integer, "null"] }
        active_members: { type: [integer, "null"] }
        compliance_rate: { type: [number, "null"] }
        avg_operator_score: { type: [number, "null"] }
        reputation_composite: { type: [number, "null"] }
        action_rate_pct: { type: [number, "null"] }
        understaffed_pct: { type: [number, "null"] }
        total_jobs_30d: { type: [integer, "null"] }
        margin_p50: { type: [number, "null"] }
        net_margin_p50: { type: [number, "null"] }
        loaded_labor_rate_p50_cents: { type: [integer, "null"] }
        k_anon:
          type: object
          properties:
            cohort_floor: { type: integer }
            sensitive_floor: { type: integer }
            cohort_met: { type: boolean }
            sensitive_met: { type: boolean }
      required: [snapshot_date, k_anon]

    NetworkMember:
      type: object
      properties:
        id: { type: string, format: uuid }
        location_name: { type: [string, "null"] }
        owner_name: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        status: { type: [string, "null"] }
        opened_at: { type: [string, "null"], format: date-time }
        invited_at: { type: [string, "null"], format: date-time }
        created_at: { type: [string, "null"], format: date-time }
      required: [id]

    NetworkComplianceRow:
      type: object
      properties:
        id: { type: string, format: uuid }
        franchisee_name: { type: [string, "null"] }
        cert_type: { type: [string, "null"] }
        cert_status: { type: [string, "null"] }
        expiration_date: { type: [string, "null"], format: date }
        carrier_program_eligibility: { type: [string, "null"] }
        computed_at: { type: [string, "null"], format: date-time }
      required: [id]

    NetworkExport:
      type: object
      properties:
        manifest:
          type: object
          description: Generation metadata (contract version, per-resource counts, truncation flags).
        resources:
          type: object
          properties:
            summary:
              oneOf:
                - $ref: "#/components/schemas/NetworkSummary"
                - type: "null"
            members:
              type: array
              items: { $ref: "#/components/schemas/NetworkMember" }
            compliance:
              type: array
              items: { $ref: "#/components/schemas/NetworkComplianceRow" }
      required: [manifest, resources]

    NetworkSignal:
      type: object
      description: A network signal row. Counts only, never member attribution.
      properties:
        id: { type: string, format: uuid }
        signal_type: { type: [string, "null"] }
        signal_type_label: { type: string }
        domain: { type: [string, "null"] }
        domain_label: { type: string }
        severity: { type: [string, "null"] }
        severity_label: { type: string }
        title: { type: [string, "null"] }
        body: { type: [string, "null"] }
        affected_count: { type: [integer, "null"] }
        total_count: { type: [integer, "null"] }
        status: { type: [string, "null"] }
        status_label: { type: string }
        detected_at: { type: [string, "null"], format: date-time }
        resolved_at: { type: [string, "null"], format: date-time }
      required: [id]

    NetworkProgram:
      type: object
      properties:
        id: { type: string, format: uuid }
        program_type: { type: [string, "null"] }
        program_type_label: { type: string }
        program_name: { type: [string, "null"] }
        active_members: { type: [integer, "null"] }
        adopted_members: { type: [integer, "null"] }
        adoption_pct: { type: [number, "null"] }
        snapshot_date: { type: [string, "null"], format: date }
      required: [id]

    NetworkProcessRow:
      type: object
      description: Network-wide process median. Industry benchmark columns are never included.
      properties:
        id: { type: string, format: uuid }
        process: { type: [string, "null"] }
        process_label: { type: string }
        stage_from: { type: [string, "null"] }
        stage_from_label: { type: string }
        stage_to: { type: [string, "null"] }
        stage_to_label: { type: string }
        window_days: { type: [integer, "null"] }
        snapshot_date: { type: [string, "null"], format: date }
        n: { type: [integer, "null"] }
        median_days: { type: [number, "null"] }
        p25_days: { type: [number, "null"] }
        p75_days: { type: [number, "null"] }
      required: [id]

  responses:
    Unauthorized:
      description: Missing, malformed, or revoked key.
      headers:
        Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: The key lacks the required scope or entitlement.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such resource for this operator.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ValidationError:
      description: The request body failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IuExhausted:
      description: Intelligence Unit balance is exhausted. Top up or upgrade.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PayloadTooLarge:
      description: The document exceeds the 25 MB cap.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    UnsupportedMediaType:
      description: The document's content type is not accepted.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Too many requests for this window.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
        RateLimit-Remaining: { $ref: "#/components/headers/RateLimitRemaining" }
        Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
