openapi: 3.1.0
info:
  title: Verinode Operator API
  version: "1.0.0"
  summary: Programmatic access to your own restoration operating data.
  description: |
    The Verinode Operator API is the third ingestion primitive, alongside Connect
    (OAuth) and Flow (routed exports). It lets you and the tools acting on your
    behalf do two things:

    - **Push** documents and structured records into your own Verinode vault,
      through the same schema-validated normalization pipeline every other lane
      uses.
    - **Read** your own structured data back out — jobs, vendors, clients, and
      the extraction receipts that show exactly what Verinode did with each
      document.

    It never exposes another operator's data, never exposes peer benchmark
    numbers, and 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. There are two kinds:

    - `vn_live_…` — act on your real data. Requires a paid membership.
    - `vn_test_…` — act on an isolated sandbox copy of your account. Free on any
      plan, never charges Intelligence Units, and never touches live data or the
      intelligence layer. Ideal for building an integration before you point it
      at production.

    ## 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 response carries `RateLimit-Limit`,
      `RateLimit-Remaining`, and `RateLimit-Reset` (seconds). Exceeding a limit
      returns `429` with `Retry-After`.
    - **Idempotency:** send an `Idempotency-Key` header on any `POST` to make a
      retry safe — the first response is replayed rather than re-executed.
  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.

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, or
        - `application/json` with `filename` + base64 `content_base64`.

        Accepts PDF, JPEG, PNG, HEIC, CSV, XLSX/XLS, DOCX/DOC, TXT, up to 25 MB.
        Requires the `ingest` scope. Live keys charge Intelligence Units when
        extraction runs; test keys are free.
      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" }

  /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"
      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.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordCreated" }
              example:
                id: "c7d8e9f0-1a2b-3c4d-5e6f-7a8b9c0d1e2f"
                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" }

  /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"
      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.
          headers:
            Verinode-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RecordCreated" }
              example:
                id: "d8e9f0a1-2b3c-4d5e-6f7a-8b9c0d1e2f3a"
                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" }

  /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"
      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" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: vn_live_ / vn_test_ key
      description: |
        A Verinode API key as a bearer token. Scopes (`read`, `write`, `ingest`,
        `keys`) are attached to each key at creation; an endpoint lists the scope
        it needs. A key missing the scope gets `403 insufficient_scope`.

  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`.
      required: false
      schema: { type: string }
    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] }
        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]
        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: Present when status is `failed`. }
        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.
      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 }
      required: [data, next_cursor, has_more]

    RecordCreated:
      type: object
      properties:
        id: { type: string, format: uuid }
        resource: { type: string }
        request_id: { type: string }
      required: [id, resource, 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 }
      required: [id, created_at]

  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" }
