openapi: "3.1.0"
info:
  title: Appraisal AI Partner API
  version: "2.1.0"
  description: |
    Submit commercial appraisal documents for AI-powered review.

    **Scope.** The programmatic API accepts commercial `summary` and `demo`
    submissions only. Full reviews (commercial or residential) and any
    residential submission flow through the **sub-org deep-link onboarding**
    described in `partner-api.md §11` — end-users onboard into your parent
    workspace and process reports from the dashboard.

    **Source of truth.** This OpenAPI document narrowly describes the wire
    contract. The full spec — including webhook payloads, signature
    verification, deep-link onboarding, key management, security posture,
    and PII handling — is in `partner-api.md`. This file lags the markdown
    spec on additive fields.

    ## Authentication
    `X-API-Key: <your-key>` header on every request. Your account manager
    enables API access on your organization; designated users generate keys
    themselves at `https://appraisal-ai.com → API Keys → Add Partner`.
    Three secrets reveal exactly once on creation: API key, webhook signing
    key, onboarding signing key.

    ## Rate Limiting
    Per-API-key sliding window, 1-minute bucket. Default 30 requests/minute
    (configurable). Every response carries `X-RateLimit-Limit`,
    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds).
    `429 Too Many Requests` when exceeded — back off until reset.

    ## Processing flow
    1. `POST /partner-submit` with `action: "get-upload-url"` → signed URL +
       `record_id`.
    2. `PUT` the PDF to the signed URL (direct to storage, no proxying).
    3. `POST /partner-submit` with `action: "process"` and `report_type`
       (`summary` or `demo`) → `submission_id`.
    4. Receive a `submission.status_changed` webhook within **up to 10
       minutes** with signed download URLs valid 24 hours.

    Optionally call `GET /partner-status` to poll or refresh the signed
    URLs after the 24h window.

    ## Webhook delivery
    Failed deliveries retry with exponential backoff over **5 retries**.
    Retried responses: 5xx, network errors, timeouts (10s). 4xx responses
    are not retried. After exhaustion the submission is dead-lettered and
    an internal alert fires. See `partner-api.md §4` for HMAC verification
    and `partner-api.md §12` for the full retry policy.

    ## Status enum (partner-visible)
    `queued | processing | completed | error` — the API path skips the
    `pending_review` gate (auto-approved for `summary` and `demo`).

    ## Typed error codes
    | Code                            | HTTP | Meaning                                                     |
    | ------------------------------- | ---- | ----------------------------------------------------------- |
    | `RESIDENTIAL_API_NOT_SUPPORTED` | 400  | Use the sub-org deep-link in `partner-api.md §11`           |
    | `FULL_REVIEW_REQUIRES_SUBORG`   | 400  | Use the sub-org deep-link in `partner-api.md §11`           |
    | `FEATURE_DISABLED`              | 403  | Capability not enabled on your key — contact account mgr    |
  contact:
    name: Appraisal AI Support
    email: office@appraisal-ai.com
    url: https://appraisal-ai.com
  license:
    name: Proprietary

servers:
  - url: https://api.appraisal-ai.com/functions/v1
    description: |
      Sandbox and production share the same hostname — the endpoint paths
      below are suffixes. Your sandbox API key (issued via your onboarding
      email) determines which environment your requests target.

security:
  - ApiKeyAuth: []

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Partner API key (e.g. `aai_live_...`)

  schemas:
    UploadUrlRequest:
      type: object
      required: [action, file_name, appraisal_type]
      properties:
        action:
          type: string
          enum: [get-upload-url]
        file_name:
          type: string
          description: PDF filename (must end in `.pdf`)
          example: "commercial-appraisal-123.pdf"
        appraisal_type:
          type: string
          enum: [commercial]
          description: |
            Must be `"commercial"`. Residential submissions are not
            supported via the API — they return
            `RESIDENTIAL_API_NOT_SUPPORTED` and route through the sub-org
            deep-link onboarding.
        external_reference_id:
          type: string
          maxLength: 200
          description: Your internal correlation ID; echoed in all subsequent responses + webhooks. Capped at 200 chars — longer values return `400 EXTERNAL_REFERENCE_ID_TOO_LONG`.
          example: "LB-2026-00456"

    UploadUrlResponse:
      type: object
      properties:
        upload_url:
          type: string
          format: uri
          description: Pre-signed URL for direct PUT (15-minute TTL)
        upload_token:
          type: string
          description: |
            Supabase storage upload token embedded in `upload_url`. Most clients
            can ignore this — it's surfaced for SDKs that need to PUT via the
            Supabase storage client rather than a raw HTTP PUT to `upload_url`.
        record_id:
          type: string
          format: uuid
          description: Internal submission record ID — store, used for the `process` step
        expires_in:
          type: integer
          description: Upload URL TTL in seconds
          example: 900

    ProcessRequest:
      type: object
      required: [action, record_id, report_type]
      properties:
        action:
          type: string
          enum: [process]
        record_id:
          type: string
          format: uuid
          description: From the `get-upload-url` response
        report_type:
          type: string
          enum: [summary, demo]
          description: |
            Required.
            - `summary` — billed, auto-approved short summary PDF.
            - `demo` — non-billable trial output. Use for sandbox testing.
            Passing `"full"` (or omitting this field) returns
            `FULL_REVIEW_REQUIRES_SUBORG`.
        external_reference_id:
          type: string
          maxLength: 200
          description: Your internal ID — recommend supplying the same value as in step 1. Capped at 200 chars.
        duplicate:
          type: boolean
          description: |
            Set true when resubmitting a revised appraisal of the same property.
            Marks the submission as `is_duplicate_report: true` and excludes it
            from billable counts.
          default: false

    SubmissionResponse:
      type: object
      properties:
        submission_id:
          type: string
          format: uuid
        record_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [queued]
        queue_position:
          type: integer

    CheckBiasRequest:
      type: object
      required: [action, submission_id]
      properties:
        action:
          type: string
          enum: [check-bias]
        submission_id:
          type: string
          format: uuid

    RetryRequest:
      type: object
      required: [action, submission_id]
      description: |
        Re-runs the AI pipeline on a previously-errored submission, reusing the
        originally-uploaded PDF. The retry is auto-flagged
        `is_duplicate_report: true` so it never bills.
      properties:
        action:
          type: string
          enum: [retry]
        submission_id:
          type: string
          format: uuid
          description: The errored submission's UUID

    RetryResponse:
      type: object
      properties:
        submission_id:
          type: string
          format: uuid
          description: New submission ID for the retry
        record_id:
          type: string
          format: uuid
          description: New record ID for the retry
        status:
          type: string
          enum: [queued]
        is_duplicate_report:
          type: boolean
          description: Always `true` on retry responses — the row is excluded from billing.
        original_submission_id:
          type: string
          format: uuid
          description: The errored submission's UUID, echoed for lineage tracking
        queue_position:
          type: integer

    BiasCheckSummary:
      type: object
      description: |
        Counts only in this JSON body. The detailed issue list (per-page
        locations, flagged text, severity, suggested replacements) is
        rendered on a branded public results page whose URL ships in the
        `submission.bias_check_completed` interim webhook (see
        partner-api.md §4) — embed or link that page in your UI to give
        end-users the full breakdown without parsing JSON yourself.
        Treat non-zero `hardStopCount` as the strongest signal.
      properties:
        totalIssues:
          type: integer
        hardStopCount:
          type: integer
          description: Compliance-blocking — Fannie Mae / Fair Housing hard-stop terms
        reviewRequiredCount:
          type: integer
          description: Reviewer judgment required
        contextualCount:
          type: integer
          description: Advisory — context-dependent

    BiasCheckResponse:
      type: object
      properties:
        submission_id:
          type: string
          format: uuid
        bias_check_summary:
          $ref: "#/components/schemas/BiasCheckSummary"

    DownloadUrls:
      type: object
      description: |
        Signed URLs valid for **24 hours**. Pull-and-persist on receive —
        re-fetch via `GET /partner-status` if you fall outside the window.
        Cover-page PDFs and table-extraction XLSX are full-review
        artifacts produced through the sub-org dashboard path (§11 of
        partner-api.md) and are not delivered over the partner API
        surface — only `final_report_pdf` is returned here.
      properties:
        final_report_pdf:
          type: string
          format: uri

    StatusResponseProcessing:
      type: object
      description: Returned while the submission is in flight
      properties:
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        status:
          type: string
          enum: [queued, processing]
        is_duplicate_report:
          type: boolean
        queue_position:
          type: integer
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    StatusResponseCompleted:
      type: object
      description: |
        Returned when status is `completed`. Note: `review_report` JSON,
        `extracted_tables` JSON, `math_validation_results`, and
        `bias_check_results` issue-level details are NOT returned via the
        partner API by design — only the bias counts and signed download
        URLs are exposed.
      properties:
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        status:
          type: string
          enum: [completed]
        is_duplicate_report:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        results:
          type: object
          properties:
            download_urls:
              $ref: "#/components/schemas/DownloadUrls"
            bias_check_summary:
              $ref: "#/components/schemas/BiasCheckSummary"

    StatusResponseError:
      type: object
      properties:
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        status:
          type: string
          enum: [error]
        is_duplicate_report:
          type: boolean
        error_message:
          type: string
        can_retry:
          type: boolean

    WebhookStatusChangedPayload:
      type: object
      description: |
        Sent to your configured webhook URL on each terminal-status
        transition (`completed` or `error`). Also fires on intermediate
        `queued` / `processing` if your account has interim notifications
        enabled. Verify the `X-Webhook-Signature` HMAC against your webhook
        signing key — see `partner-api.md §4`.
      properties:
        event:
          type: string
          enum: [submission.status_changed]
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        status:
          type: string
          enum: [queued, processing, completed, error]
        timestamp:
          type: string
          format: date-time
        is_duplicate_report:
          type: boolean
        results:
          type: object
          description: |
            On `completed`: includes `download_urls`, `bias_check_summary`, and `status_url`.
            On other statuses: includes only `status_url` (re-fetch endpoint).
          properties:
            download_urls:
              $ref: "#/components/schemas/DownloadUrls"
            bias_check_summary:
              $ref: "#/components/schemas/BiasCheckSummary"
            status_url:
              type: string
              format: uri
              description: Re-fetch endpoint for canonical state. Always present.

    WebhookBiasCheckCompletedPayload:
      type: object
      description: |
        Interim event fired ahead of the terminal `submission.status_changed`
        webhook for `summary` and `demo` submissions. Carries the four bias
        counts plus a public, branded `bias_results_url` (24h TTL) you can
        surface to the end-user as a fast-time-to-value teaser. Full-review
        submissions skip this event — bias results land in the AAI portal.

        Same HMAC scheme as `submission.status_changed`. Discriminate on
        `event`.
      properties:
        event:
          type: string
          enum: [submission.bias_check_completed]
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        timestamp:
          type: string
          format: date-time
        is_duplicate_report:
          type: boolean
        results:
          type: object
          properties:
            bias_check_summary:
              $ref: "#/components/schemas/BiasCheckSummary"
            bias_results_url:
              type: string
              format: uri
              description: Public, branded landing page (no auth required), 24h single-use TTL
            status_url:
              type: string
              format: uri

    WebhookPayload:
      oneOf:
        - $ref: "#/components/schemas/WebhookStatusChangedPayload"
        - $ref: "#/components/schemas/WebhookBiasCheckCompletedPayload"
      discriminator:
        propertyName: event
        mapping:
          submission.status_changed: "#/components/schemas/WebhookStatusChangedPayload"
          submission.bias_check_completed: "#/components/schemas/WebhookBiasCheckCompletedPayload"

    Error:
      type: object
      properties:
        error:
          type: string
          description: Human-readable message
        code:
          type: string
          description: |
            Stable machine-readable code on typed errors:
            `RESIDENTIAL_API_NOT_SUPPORTED`, `FULL_REVIEW_REQUIRES_SUBORG`,
            `FEATURE_DISABLED`. Absent on generic 4xx/5xx.

paths:
  /partner-submit:
    post:
      summary: Submit a document or run a bias check
      description: |
        Multi-action endpoint. Two request modes:

        - **`application/json`** — the standard 2-step flow (`get-upload-url`
          → PUT to signed URL → `process`) plus the `check-bias` and
          `retry` actions. See the introduction for the full processing
          flow.
        - **`multipart/form-data`** — one-shot direct upload. Send the PDF
          bytes alongside the metadata fields in a single request. Capped
          at **50 MB** per file; anything larger must use the JSON
          `get-upload-url` flow. Skips the `get-upload-url` and PUT steps
          entirely and immediately queues the submission. Returns the
          same `SubmissionResponse` as the `process` action.
      operationId: partnerSubmit
      tags: [Documents]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/UploadUrlRequest"
                - $ref: "#/components/schemas/ProcessRequest"
                - $ref: "#/components/schemas/CheckBiasRequest"
                - $ref: "#/components/schemas/RetryRequest"
            examples:
              getUploadUrl:
                summary: Step 1 — request upload slot
                value:
                  action: get-upload-url
                  file_name: commercial-appraisal.pdf
                  appraisal_type: commercial
                  external_reference_id: LB-2026-00456
              processSummary:
                summary: Step 3 — kick off a billable summary review
                value:
                  action: process
                  record_id: "550e8400-e29b-41d4-a716-446655440000"
                  report_type: summary
                  external_reference_id: LB-2026-00456
              processDemo:
                summary: Step 3 — non-billable demo (sandbox testing)
                value:
                  action: process
                  record_id: "550e8400-e29b-41d4-a716-446655440000"
                  report_type: demo
              checkBias:
                summary: Run bias-language check on a completed submission
                value:
                  action: check-bias
                  submission_id: "550e8400-e29b-41d4-a716-446655440000"
              retry:
                summary: Re-run the AI pipeline on an errored submission (auto-flagged non-billable)
                value:
                  action: retry
                  submission_id: "550e8400-e29b-41d4-a716-446655440000"
          multipart/form-data:
            schema:
              type: object
              required: [file, appraisal_type, report_type]
              properties:
                file:
                  type: string
                  format: binary
                  description: PDF bytes (≤ 50 MB). Filename must end in `.pdf`.
                appraisal_type:
                  type: string
                  enum: [commercial]
                  description: Must be `"commercial"` (same constraint as JSON flow).
                report_type:
                  type: string
                  enum: [summary, demo]
                  description: |
                    Required. Same semantics as the JSON `process` action.
                    Passing `"full"` (or omitting) returns
                    `FULL_REVIEW_REQUIRES_SUBORG`.
                external_reference_id:
                  type: string
                  maxLength: 200
                  description: Your internal correlation ID. Capped at 200 chars.
                duplicate:
                  type: boolean
                  default: false
                  description: |
                    Set true when resubmitting a revised appraisal of the
                    same property. Marks the submission `is_duplicate_report:
                    true` and excludes it from billable counts.
            encoding:
              file:
                contentType: application/pdf
      responses:
        "200":
          description: Upload URL or bias-check completed
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/UploadUrlResponse"
                  - $ref: "#/components/schemas/BiasCheckResponse"
        "201":
          description: Submission queued (process / direct upload / retry)
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/SubmissionResponse"
                  - $ref: "#/components/schemas/RetryResponse"
        "400":
          description: |
            Bad request — see `code` field for typed errors
            (`RESIDENTIAL_API_NOT_SUPPORTED`, `FULL_REVIEW_REQUIRES_SUBORG`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Invalid or revoked API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: API key valid but feature not enabled (`FEATURE_DISABLED`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: Rate limited
          headers:
            Retry-After:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /partner-status:
    get:
      summary: Check submission status and refresh download URLs
      description: |
        Use as a webhook fallback (poll) and to refresh signed download URLs
        after the 24h validity window expires.
      operationId: partnerStatus
      tags: [Documents]
      parameters:
        - name: submission_id
          in: query
          schema:
            type: string
            format: uuid
          description: Canonical submission ID
        - name: external_reference_id
          in: query
          schema:
            type: string
            maxLength: 200
          description: Your reference ID (alternative lookup)
      responses:
        "200":
          description: Current status
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/StatusResponseProcessing"
                  - $ref: "#/components/schemas/StatusResponseCompleted"
                  - $ref: "#/components/schemas/StatusResponseError"
        "401":
          description: Invalid or revoked API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Submission not found for your account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

webhooks:
  submissionStatusChanged:
    post:
      summary: Submission status transition (terminal `completed` / `error`)
      description: |
        Appraisal-AI POSTs this payload to your configured webhook URL on
        terminal status transitions. Verify HMAC, dedupe on `X-Webhook-Id`,
        respond `2xx` within 10s. See `partner-api.md §4` and §12.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookStatusChangedPayload"
      parameters:
        - in: header
          name: X-Webhook-Id
          required: true
          schema: { type: string, format: uuid }
          description: Equal to `submission_id`. Stable across retries — use as your dedupe key.
        - in: header
          name: X-Webhook-Timestamp
          required: true
          schema: { type: string, format: date-time }
          description: ISO-8601. Reject if `|now - timestamp| > 5 minutes`.
        - in: header
          name: X-Webhook-Signature
          required: true
          schema: { type: string, example: "sha256=<hex>" }
          description: HMAC-SHA256 of `submission_id + "." + timestamp + "." + raw-body` using your webhook signing key.
        - in: header
          name: X-Webhook-Key-Version
          required: true
          schema: { type: string, example: "1" }
          description: |
            Integer signing-key version used for this delivery. Increments on
            **Rotate** in the dashboard. Most receivers can ignore this — it's
            informational, useful when running an old/new key dual-verify
            during rotation.
      responses:
        "2xx":
          description: Acknowledgment. Any 2xx is treated as success.
        "4xx":
          description: Misconfiguration on receiver side. NOT retried; we log a warning internally.
        "5xx":
          description: Retried with exponential backoff up to 5 attempts.

  submissionBiasCheckCompleted:
    post:
      summary: Interim bias-check available (`summary` / `demo` only)
      description: |
        Fires the moment the Fair-Housing bias check finishes (typically
        10–30s after submit), well before the terminal status webhook. Carries
        a public `bias_results_url` you can surface as a teaser. Discriminate
        on `event`. Same HMAC scheme + headers as `submission.status_changed`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookBiasCheckCompletedPayload"
      parameters:
        - in: header
          name: X-Webhook-Id
          required: true
          schema: { type: string, format: uuid }
        - in: header
          name: X-Webhook-Timestamp
          required: true
          schema: { type: string, format: date-time }
        - in: header
          name: X-Webhook-Signature
          required: true
          schema: { type: string, example: "sha256=<hex>" }
        - in: header
          name: X-Webhook-Key-Version
          required: true
          schema: { type: string, example: "1" }
      responses:
        "2xx":
          description: Acknowledgment.
