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

    **Scope.** The programmatic API accepts commercial `summary` and `demo`
    submissions directly. Commercial **full reviews** are supported as a
    *sub-org push*: once a client is provisioned as a sub-org via the deep-link
    onboarding (§11), you push `report_type: "full"` with that `sub_org_id` and
    it lands at `pending_review` on their dashboard (§11.1). Residential
    submissions are not supported via the API — they route through the sub-org
    deep-link onboarding described in [partner-api.md §11](/docs/partner-api/).

    **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](/docs/partner-api/). 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.

    The **Partner Name** you choose when creating a key is both a wire
    contract (it is the exact `parent_org` value your signed deep-link URLs
    must carry — see [partner-api.md §11](/docs/partner-api/)) **and** user-facing copy. It is
    rendered on the sub-org onboarding page as
    "Set up your *<partner_name>* workspace" and in the billing copy
    ("billed through the *<partner_name>* monthly invoice"). Pick a clean
    brand name (e.g. `Acme`, not `Acme Sandbox`) for production keys.

    ## 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.

    ## Response headers (every response)
    - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset` — per-key rate-limit state.
    - `X-Request-Id` — opaque per-request UUID. Include this in any support
      escalation; we use it to look up the request in our logs.

    ## 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` / `demo`, or `full` + `sub_org_id` for a sub-org push) →
       `submission_id`.
    4. Receive a `submission.status_changed` webhook within **up to 10
       minutes** with signed download URLs valid 24 hours. (A sub-org `full`
       push instead lands at `pending_review` first, then `completed` on the
       client's approve — two webhooks.)

    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](/docs/partner-api/)
    for HMAC verification and §12 for the full retry policy.

    ## Status enum (partner-visible)
    `queued | processing | pending_review | completed | error`. Parent-direct
    `summary`/`demo` auto-approve and never surface `pending_review`; it appears
    only for sub-org `full` pushes (§11.1), which emit two
    `submission.status_changed` webhooks (`pending_review`, then `completed`).
    Dedupe `submission.status_changed` on `event_id` (unique per occurrence) and
    apply last-writer-wins on `version` — a corrected report re-emits `completed`
    with a new `event_id` + higher `version`. See [partner-api.md §12](/docs/partner-api/).

    ## Typed error codes
    | Code                              | HTTP | Meaning                                                     |
    | --------------------------------- | ---- | ----------------------------------------------------------- |
    | `RESIDENTIAL_API_NOT_SUPPORTED`   | 400  | Use the sub-org deep-link in [partner-api.md §11](/docs/partner-api/)  |
    | `FULL_REVIEW_REQUIRES_SUBORG`    | 400  | `report_type` omitted. Pass summary/demo, or full + sub_org_id (§11.1)  |
    | `FULL_REVIEW_REQUIRES_SUBORG_ID` | 400  | `report_type: full` without a `sub_org_id` (sub-org push, §11.1)        |
    | `SUBORG_REQUIRES_FULL`           | 400  | `sub_org_id` sent with summary/demo — only valid with `full`            |
    | `SUBORG_FORBIDDEN`               | 403  | `sub_org_id` is not a sub-org under your parent                         |
    | `EXTERNAL_REFERENCE_ID_TOO_LONG` | 400  | Cap is 200 characters                                       |
    | `FEATURE_DISABLED`                | 403  | Capability not enabled on your key — contact account mgr    |

    Generic 4xx/5xx errors return `{ "error": "<human-readable message>" }`
    with no `code` field. Only the codes above are stable contract — any
    new typed code will land in this table before going live.
  contact:
    name: Appraisal AI Support
    email: office@appraisal-ai.com
    url: https://www.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"
        sub_org_id:
          type: string
          format: uuid
          description: |
            Sub-org full-review push only (partner-api.md §11.1). When present,
            the created record is owned by that sub-org user and stored under
            their folder, so a `report_type: "full"` submission lands on the
            sub-org's own dashboard. Must be a sub-org provisioned under your
            parent (from a `suborg.created` webhook or `GET /partner-suborgs`),
            else `403 SUBORG_FORBIDDEN`. Omit for parent-direct summary/demo.

    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, full]
          description: |
            Required. Selects the deliverable. `demo` and `summary` run the
            same AI pipeline but emit **visually-distinct PDFs** with different
            compliance copy; `full` is the sub-org push.

            - `summary` — billed, auto-approved production output.
              Standard AI-review disclaimers. Parent-direct.
            - `demo` — non-billable teaser. Stricter messaging marking
              the report as a preview *not* to be relied on for
              commercial / lending decisions. Filename ships as
              `Demo_Report_for_<address>.pdf`. Parent-direct.
            - `full` — sub-org full-review push (partner-api.md §11.1).
              Accepted **only** with a valid `sub_org_id`; lands at
              `pending_review` on the sub-org dashboard and returns the
              final PDF on the client's approve.

            **Hard rule:** demo submissions only ever render with the
            demo template — there is no silent fallback to summary if
            the demo template is misconfigured. Misconfiguration
            surfaces as a 5xx, by design (compliance-copy mismatch is
            worse than a failed submission).

            `full` without `sub_org_id` → `FULL_REVIEW_REQUIRES_SUBORG_ID`;
            omitting `report_type` → `FULL_REVIEW_REQUIRES_SUBORG`.
        sub_org_id:
          type: string
          format: uuid
          description: |
            Required when `report_type: "full"` (sub-org push); rejected with
            `SUBORG_REQUIRES_FULL` if sent with summary/demo. Must match the
            `sub_org_id` used in `get-upload-url` and be a sub-org under your
            parent (`SUBORG_FORBIDDEN` otherwise).
        submitter_email:
          type: string
          maxLength: 320
          description: |
            Optional end-user attribution (PII) for summary/demo orders — the
            lender/originator who triggered the order on your side. Stored for
            your per-end-user reporting; never logged or surfaced publicly.
        submitter_company_name:
          type: string
          maxLength: 200
          description: Optional end-user company attribution for summary/demo orders.
        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](/docs/partner-api/)) — 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](/docs/partner-api/)) 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

    StatusResponsePendingReview:
      type: object
      description: |
        Returned for a sub-org `full_review` push (partner-api.md §11.1) once
        processing finishes and the report awaits the client's edit + approval.
        Flips to `completed` (with `final_report_pdf`) on approve.
      properties:
        submission_id:
          type: string
          format: uuid
        external_reference_id:
          type: string
          maxLength: 200
        status:
          type: string
          enum: [pending_review]
        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
        review_url:
          type: string
          format: uri
          description: Deep-link to the sub-org report editor (`/app/report/com/<record_id>`).
        dashboard_url:
          type: string
          format: uri
          description: The sub-org's dashboard home.

    SubOrgListResponse:
      type: object
      description: Response of `GET /partner-suborgs` (partner-api.md §11.3).
      properties:
        sub_orgs:
          type: array
          items:
            type: object
            properties:
              sub_org_id:
                type: string
                format: uuid
                description: Stable auth user id — the handle you push full reviews with.
              email:
                type: string
              company_name:
                type: string
              created_at:
                type: string
                format: date-time

    SubOrgCreatedPayload:
      type: object
      description: |
        Server-to-server `suborg.created` webhook (partner-api.md §11.2), fired
        after a sub-org is provisioned via the onboarding deep-link, so you
        learn the stable `sub_org_id`. Same HMAC scheme + headers as
        `submission.status_changed`; `X-Webhook-Id` = `sub_org_id`. Correlate
        by `email`.
      properties:
        event:
          type: string
          enum: [suborg.created]
        sub_org_id:
          type: string
          format: uuid
        email:
          type: string
        sub_org_company_name:
          type: string
        created_at:
          type: string
          format: date-time
        timestamp:
          type: string
          format: date-time

    WebhookStatusChangedPayload:
      type: object
      description: |
        Sent to your configured webhook URL on `completed`, `error`, or — for
        sub-org `full_review` pushes only — `pending_review`. Intermediate
        states (`queued`, `processing`) do **not** fire this webhook — poll
        `GET /partner-status` if you need in-flight visibility.

        Verify the `X-Webhook-Signature` HMAC against your webhook signing
        key. The signed string is the literal concatenation
        `<X-Webhook-Id> + "." + timestamp + "." + raw-body` (take the id from
        the `X-Webhook-Id` header — it equals `submission_id` for this event),
        signed with HMAC-SHA256 using the **hex-decoded** webhook signing key.
        The raw body bytes must be passed unmodified — a JSON
        parse-and-re-serialise will break the hash. See
        [partner-api.md §4](/docs/partner-api/) for reference verifiers in
        TypeScript / Python / Java.
      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: [completed, error, pending_review]
          description: |
            `completed`/`error` are terminal. `pending_review` is fired only
            for sub-org `full_review` pushes (partner-api.md §11.1) — the report
            finished processing and is awaiting the client's edit + approval; a
            `completed` payload follows on approve. For `error`, fetch
            `results.status_url` to read `error_message` and `can_retry`.
        timestamp:
          type: string
          format: date-time
        is_duplicate_report:
          type: boolean
        event_id:
          type: string
          description: |
            Globally unique per occurrence — `${submission_id}.${status}.${version}`
            (also in the `X-Webhook-Event-Id` header). **Dedupe on this.** Retries
            reuse it; a new occurrence — even the same status on the same submission
            (e.g. a revised re-approval) — gets a new value.
          example: ef24c113-1154-4eab-a285-8f705bd461d0.completed.7
        version:
          type: integer
          minimum: 0
          description: |
            Monotonic revision; increments on every status change for the submission
            (internal transitions like file generation count too, so values are
            strictly increasing but NOT contiguous — compare relatively, never assume
            `+1`). Apply last-writer-wins: ignore a delivery whose `version` is ≤ one
            you've already applied; a higher `version` (e.g. a corrected `completed`)
            is newer — fetch its `download_urls` and replace your stored copy.
          example: 7
        results:
          type: object
          description: |
            On `completed`: `download_urls`, `bias_check_summary`, `status_url`.
            On `error`: only `status_url`.
            On `pending_review`: `status_url`, `review_url`, `dashboard_url`
            (no downloads yet).
          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.
            review_url:
              type: string
              format: uri
              description: |
                `pending_review` only — deep-link to the sub-org report editor
                (`/app/report/com/<record_id>`). Surface as the client's
                "Review your report" button.
            dashboard_url:
              type: string
              format: uri
              description: "`pending_review` only — the sub-org's dashboard home."

    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.
                The URL is canonical at `https://www.appraisal-ai.com/bias-results/<token>`;
                hits to the `appraisal-ai.com` apex return a 301 to `www.` —
                follow redirects, or normalise to `www` before persisting.
            status_url:
              type: string
              format: uri

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

    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
                    — `summary` is the billed production output, `demo`
                    is the non-billable teaser with stricter compliance
                    copy. Demo submissions never silently fall back to
                    the summary template; see the JSON-flow `report_type`
                    description above for the rationale.
                    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"
        "409":
          description: |
            Conflict — the record is not in a state this action can claim.

            On `action: process`, the record has already been picked up.

            On `action: retry`, see the `code` field:
            `RETRY_LIMIT_REACHED` (this submission's allowance of 3 is spent — a
            retry inherits its parent's usage, so the chain terminates and at
            most 7 retry jobs can originate from one original submission) or
            `RETRY_IN_PROGRESS` (a concurrent retry of the same submission
            claimed it first; exactly one caller wins).
          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"
                  - $ref: "#/components/schemas/StatusResponsePendingReview"
        "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"

  /partner-suborgs:
    get:
      summary: List sub-orgs provisioned under your parent
      description: |
        Reconciliation backstop for the `suborg.created` webhook
        (partner-api.md §11.3). Returns the provisioned account per sub-org,
        scoped to your parent.
      operationId: partnerSubOrgs
      tags: [Documents]
      responses:
        "200":
          description: Sub-org list (empty array if none)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SubOrgListResponse"
        "401":
          description: Invalid or revoked API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

webhooks:
  submissionStatusChanged:
    post:
      summary: Submission status transition (`completed` / `error` / `pending_review`)
      description: |
        Appraisal-AI POSTs this payload to your configured webhook URL on
        `completed`, `error`, and (for sub-org full-review pushes)
        `pending_review` transitions. Verify HMAC, dedupe on `event_id`
        (`X-Webhook-Event-Id`) and apply last-writer-wins on `version` — NOT
        `X-Webhook-Id`, which repeats across a submission's transitions and
        revisions. Respond `2xx` within 10s. See
        [partner-api.md §4](/docs/partner-api/) 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 and used for
            correlation, but NOT unique per delivery (a submission emits
            `pending_review`, then `completed`, and a revised `completed` — all
            with the same value). Dedupe on `event_id` / `X-Webhook-Event-Id`,
            not this header.
        - in: header
          name: X-Webhook-Event-Id
          required: false
          schema: { type: string }
          description: |
            Globally unique per occurrence (mirrors the body's `event_id`).
            **The dedupe key** for `submission.status_changed`. Absent on
            `bias_check_completed` / `suborg.created` (fire-once — dedupe on
            `X-Webhook-Id`).
        - 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 `<X-Webhook-Id> + "." + timestamp + "." + raw-body` (here `X-Webhook-Id` = `submission_id`) 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.

  subOrgCreated:
    post:
      summary: Sub-org provisioned (`suborg.created`)
      description: |
        Fired after a sub-org is provisioned via the onboarding deep-link
        (partner-api.md §11.2), so you learn the stable `sub_org_id` to address
        full-review pushes with. Carries no submission/record — correlate by
        `email`. Same HMAC scheme + headers as `submission.status_changed`;
        `X-Webhook-Id` = `sub_org_id`. Reconcile missed events via
        `GET /partner-suborgs`.
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SubOrgCreatedPayload"
      parameters:
        - in: header
          name: X-Webhook-Id
          required: true
          schema: { type: string, format: uuid }
          description: Equal to `sub_org_id`.
        - 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.
