openapi: 3.1.0
info:
  title: Complete iQ Public API
  version: 1.1.0
  description: >
    External integration API for triggering vehicle inspection workflows and
    receiving results. Authenticated with per-organization API keys. Lifecycle
    updates are delivered via signed webhook callbacks (see the Webhooks
    section).

    This is a standalone external contract, separate from the internal
    Orval-generated `Api` spec, so the public surface can version independently.
servers:
  - url: https://inspection360.ai/api/v1
    description: Production
  - url: /api/v1
    description: Same-origin deployment

security:
  - BearerAuth: []
  - XApiKeyAuth: []

tags:
  - name: inspections
    description: Trigger and track inspection workflows.
  - name: templates
    description: Discover organization-scoped inspection templates.
  - name: retired
    description: Compatibility tombstones for operations retired in the auto-only pivot.

paths:
  /inspections:
    post:
      operationId: triggerInspection
      tags: [inspections]
      summary: Trigger an inspection workflow
      description: >
        Creates a claim + inspection request for the authenticated organization
        and (by default) delivers the magic link to the claimant by SMS or email.
        Pass an `Idempotency-Key` header to make retries safe.
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema: { type: string }
          description: Replays the original response if the same key is reused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TriggerInspectionRequest"
      responses:
        "201":
          description: Inspection created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TriggerInspectionResponse"
        "200":
          description: Idempotent replay of a previous create.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TriggerInspectionResponse"
        "400":
          description: >
            Bad request — body validation failed, no destination for the chosen
            channel, or an express handlingTrack was requested for an org that does
            not have Express Claims enabled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                invalid_request:
                  summary: Body failed validation
                  value:
                    error:
                      code: invalid_request
                      message: phone or email is required
                express_not_enabled:
                  summary: Express track requested but not enabled for the org
                  value:
                    error:
                      code: express_not_enabled
                      message: Express Claims is not enabled for this organization
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "409":
          description: >
            Conflict — a claim with the supplied claimNumber already exists, or
            another request with this Idempotency-Key is still processing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                request_in_flight:
                  summary: Concurrent request still processing
                  value:
                    error:
                      code: request_in_flight
                      message: A request with this Idempotency-Key is already being processed. Retry shortly.
                claim_number_exists:
                  summary: Duplicate claimNumber
                  value:
                    error:
                      code: claim_number_exists
                      message: A claim with claimNumber "CLM-1001" already exists. Use a unique claimNumber.
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}:
    get:
      operationId: getInspection
      tags: [inspections]
      summary: Get inspection status and results
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Current status and results.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InspectionStatus"
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}/report:
    get:
      operationId: getInspectionReport
      tags: [inspections]
      summary: Get the structured submitted report (JSON)
      description: >
        Returns the structured report for a submitted, administrator-released
        inspection: claim/claimant, visible answers, severity, photo count,
        sufficiency review, walkaround validation, weather, and photo-location
        verification. Includes links to the PDF and photo endpoints. Returns
        `403 report_not_released` until the report is released.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: The structured report.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Report"
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/ReportNotReleased" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}/report.pdf:
    get:
      operationId: getInspectionReportPdf
      tags: [inspections]
      summary: Download the report as a PDF
      description: >
        Streams an administrator-released PDF rendering of the report (with
        embedded photos) as an attachment. Returns `403 report_not_released`
        before release and 404 if the inspection does not exist for this
        organization.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: PDF document.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/ReportNotReleased" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}/photos:
    get:
      operationId: getInspectionPhotos
      tags: [inspections]
      summary: List inspection photos with signed URLs
      description: >
        Returns each captured photo with a short-lived signed download URL
        (~1 hour), step metadata, per-photo AI analysis, and capture geolocation.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Photo list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PhotoList"
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}/photos.zip:
    get:
      operationId: getInspectionPhotosZip
      tags: [inspections]
      summary: Download all photos as a ZIP bundle
      description: >
        Streams a ZIP of all photos for the inspection (including submitted
        supplements) as an attachment.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: ZIP archive.
          content:
            application/zip:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Error" }
        "404": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/Error" }
        "503": { $ref: "#/components/responses/Error" }
  /templates:
    get:
      operationId: listTemplates
      tags: [templates]
      summary: List the calling org's inspection templates
      description: >
        Lists the inspection templates belonging to the organization the API key
        is scoped to. Use the returned `id` values as `templateId` when triggering
        an inspection.
      parameters:
        - in: query
          name: category
          required: false
          schema: { type: string, enum: [property, vehicle] }
          description: Filter to a category. Use vehicle for new work; property remains available for legacy template reads.
        - in: query
          name: active
          required: false
          schema: { type: boolean }
          description: Filter by active state.
      responses:
        "200":
          description: List of templates for the organization.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Template"
        "401": { $ref: "#/components/responses/Error" }
        "422": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /inspections/{id}/triage-packet:
    get:
      operationId: retiredGetTriagePacket
      tags: [retired]
      deprecated: true
      summary: Retired property triage packet
      description: Retained only as a compatibility tombstone after the auto-only pivot.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        "410": { $ref: "#/components/responses/Gone" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /home-fnol/triage-requests:
    post:
      operationId: retiredCreateHomeFnolTriage
      tags: [retired]
      deprecated: true
      summary: Retired property FNOL triage intake
      description: Retained only as a compatibility tombstone after the auto-only pivot.
      responses:
        "410": { $ref: "#/components/responses/Gone" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /home-fnol/ingest:
    post:
      operationId: retiredIngestHomeFnol
      tags: [retired]
      deprecated: true
      summary: Retired property FNOL document ingestion
      description: Retained only as a compatibility tombstone after the auto-only pivot.
      responses:
        "410": { $ref: "#/components/responses/Gone" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /home-fnol/triage-results/{requestId}:
    get:
      operationId: retiredGetHomeFnolTriageResult
      tags: [retired]
      deprecated: true
      summary: Retired property FNOL triage result
      description: Retained only as a compatibility tombstone after the auto-only pivot.
      parameters:
        - in: path
          name: requestId
          required: true
          schema: { type: string }
      responses:
        "410": { $ref: "#/components/responses/Gone" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
  /claims/{claimId}/policy-snapshot:
    put:
      operationId: retiredAttachPolicySnapshot
      tags: [retired]
      deprecated: true
      summary: Retired property policy snapshot attachment
      description: Retained only as a compatibility tombstone after the auto-only pivot.
      parameters:
        - in: path
          name: claimId
          required: true
          schema: { type: string }
      responses:
        "410": { $ref: "#/components/responses/Gone" }
        "401": { $ref: "#/components/responses/Error" }
        "403": { $ref: "#/components/responses/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/Error" }
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: >
        Per-organization API key sent as
        `Authorization: Bearer ciq_sk_live_...`.
    XApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: Per-organization API key sent directly in the x-api-key header.
  responses:
    Error:
      description: Error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: >
        Rate limit exceeded (120 requests/minute per client IP). Unlike the other
        errors, the body is a plain string envelope from the shared limiter.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
    ReportNotReleased:
      description: The report exists but an administrator has not released it.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: report_not_released
              message: Report has not been released
    Gone:
      description: This property-only operation was retired in the auto-only pivot.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            error:
              code: gone
              message: Property FNOL is not available. This product is Auto-only.
  schemas:
    TriggerInspectionRequest:
      type: object
      required: [claimantFirstName, claimantLastName, claimNumber]
      anyOf:
        - required: [phone]
        - required: [email]
      properties:
        claimantFirstName: { type: string }
        claimantLastName: { type: string }
        claimNumber: { type: string }
        lossType:
          type: string
          # Auto-only product.
          enum: [vehicle]
          default: vehicle
        deliveryChannel:
          type: string
          enum: [sms, email, both]
          description: Defaults to email if email provided, else sms. Use "both" to deliver by SMS and email together (requires both phone and email).
        phone:
          {
            type: string,
            description: "Required if deliveryChannel is sms or both.",
          }
        email:
          {
            type: string,
            format: email,
            description: "Required if deliveryChannel is email or both.",
          }
        templateId:
          type: string
          format: uuid
          description: Optional; uses the org's configured active default template for the loss type if omitted, falling back to the most recent active template.
        propertyAddress:
          type: string
          description: The loss location for the claim.
        vehicleDescription: { type: string }
        descriptionOfLoss: { type: string }
        handlingTrack:
          type: string
          enum: [express_auto, express_assist, investigative, coordinated]
          description: >
            Express tracks (express_auto/express_assist) are gated per-organization:
            they require the org to have Express Claims enabled, otherwise an explicit
            express value is rejected with 400 express_not_enabled. When omitted,
            the organization's configured default is used if valid and allowed;
            otherwise the request defaults to investigative.
        delivery:
          type: string
          enum: [auto, return_link]
          default: auto
          description: "auto sends the link; return_link skips delivery and returns the magic link only."
        metadata:
          type: object
          additionalProperties: true
          description: Echoed back in the response and ignored by processing.
    TriggerInspectionResponse:
      type: object
      properties:
        id: { type: string, format: uuid }
        claimId: { type: string, format: uuid }
        status: { type: string }
        handlingTrack: { type: string }
        deliveryChannel: { type: string }
        messageSent: { type: boolean }
        messageError: { type: string }
        magicLink: { type: string }
        accessCode: { type: string }
        metadata:
          type: object
          additionalProperties: true
    InspectionStatus:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string }
        handlingTrack: { type: string }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        submittedAt: { type: string, format: date-time, nullable: true }
        sufficiency:
          type: object
          nullable: true
          properties:
            status: { type: string, enum: [green, amber, red] }
            score: { type: number }
        dashboardUrl:
          { type: string, description: "Human dashboard link (admin UI)." }
        reportUrl:
          {
            type: string,
            description: "API link to the structured report JSON.",
          }
        reportPdfUrl:
          { type: string, description: "API link to the report PDF." }
        photosUrl: { type: string, description: "API link to the photo list." }
        photosZipUrl:
          { type: string, description: "API link to the photo ZIP." }
    Report:
      type: object
      properties:
        id: { type: string, format: uuid }
        flavor:
          type: string
          enum: [claim, condition]
          description: Claim report or staff/fleet operational condition report.
        claimantName: { type: string }
        claimNumber: { type: string, nullable: true }
        lossType: { type: string, nullable: true }
        templateName: { type: string, nullable: true }
        category: { type: string, nullable: true }
        submittedAt: { type: string, format: date-time, nullable: true }
        status: { type: string }
        severity: { type: string, nullable: true }
        photoCount: { type: integer }
        vehicleInfo: { type: string, nullable: true }
        propertyAddress: { type: string, nullable: true }
        finalNote: { type: string, nullable: true }
        answers:
          type: array
          items:
            type: object
            properties:
              questionKey: { type: string }
              label: { type: string }
              value: { type: string }
        skippedSteps:
          type: array
          description: Capture steps skipped with reason (claimant or system).
          items:
            type: object
            properties:
              stepKey: { type: string }
              label: { type: string }
              reason: { type: string }
              note: { type: string }
        measurementMethod:
          type: string
          nullable: true
          enum: [tool_verified, claimant_estimate_no_tool]
          description: How damage measurements were obtained, when applicable.
        checklist:
          nullable: true
          description: >
            Present for templates with grouped yes/no checklist questions
            (e.g. rental turnaround). Null for ordinary claims templates.
          allOf:
            - $ref: "#/components/schemas/ChecklistVerdict"
        templateSteps:
          type: array
          items:
            type: object
            properties:
              stepKey: { type: string }
              title: { type: string }
              orderIndex: { type: integer }
              capturePhase: { type: string, nullable: true }
        walkaroundValidation:
          { type: object, nullable: true, additionalProperties: true }
        sufficiencyReview:
          { type: object, nullable: true, additionalProperties: true }
        weather:
          $ref: "#/components/schemas/ClaimWeatherSnapshot"
        locationVerification:
          $ref: "#/components/schemas/LocationVerificationSummary"
        scopeOfWork:
          { type: object, nullable: true, additionalProperties: true }
        overallSummary:
          type: array
          items: { type: string }
        lossDetails:
          { type: object, nullable: true, additionalProperties: true }
        triage: { type: object, nullable: true, additionalProperties: true }
        vehicleIdentity:
          { type: object, nullable: true, additionalProperties: true }
        reportPdfUrl: { type: string }
        photosUrl: { type: string }
        photosZipUrl: { type: string }
    ChecklistVerdict:
      type: object
      properties:
        total: { type: integer }
        passed: { type: integer }
        verdict: { type: string, enum: [pass, attention, incomplete] }
        attentionItems:
          type: array
          items:
            type: object
            properties:
              questionKey: { type: string }
              label: { type: string }
              group: { type: string, nullable: true }
              flagPhotoId:
                {
                  type: string,
                  nullable: true,
                  description: "Media id of the staffer's issue photo (flag_<questionKey>).",
                }
              aiFindings:
                type: array
                items: { type: string }
                description: Human-readable AI findings from evidence photos (Damage Evaluator, TPMS, tread).
    ClaimWeatherSnapshot:
      type: object
      description: >
        Historical weather at the loss location/date plus a peril cross-check.
        Missing provider configuration or lookup failures return status
        `unavailable` rather than failing report generation. Claims that are
        not weather-driven (e.g. a collision with no weather cited in the loss
        facts) return status `not_applicable` with a `reason` and no lookup
        performed.
      properties:
        status: { type: string, enum: [available, unavailable, not_applicable] }
        key: { type: string, nullable: true }
        locationUsed: { type: string, nullable: true }
        dateUsed: { type: string, nullable: true }
        fetchedAt: { type: string, format: date-time }
        weather:
          allOf:
            - $ref: "#/components/schemas/WeatherDay"
          nullable: true
        crossCheck:
          allOf:
            - $ref: "#/components/schemas/WeatherCrossCheck"
          nullable: true
        reason:
          {
            type: string,
            description: "Present when status is unavailable or not_applicable.",
          }
    WeatherDay:
      type: object
      properties:
        resolvedAddress: { type: string, nullable: true }
        date: { type: string, format: date }
        tempMax:
          { type: number, nullable: true, description: "Degrees Fahrenheit." }
        tempMin:
          { type: number, nullable: true, description: "Degrees Fahrenheit." }
        precip:
          {
            type: number,
            nullable: true,
            description: "Total precipitation in inches.",
          }
        precipType:
          type: array
          nullable: true
          items: { type: string }
        snow:
          { type: number, nullable: true, description: "Snowfall in inches." }
        windSpeed:
          {
            type: number,
            nullable: true,
            description: "Sustained wind speed in mph.",
          }
        windGust:
          {
            type: number,
            nullable: true,
            description: "Peak wind gust in mph.",
          }
        conditions: { type: string, nullable: true }
        description: { type: string, nullable: true }
    WeatherCrossCheck:
      type: object
      properties:
        verdict:
          type: string
          enum: [supports, inconclusive, conflicts, not_weather_related]
        rationale: { type: string }
    LocationVerificationSummary:
      type: object
      description: >
        Flag-only comparison of photo GPS metadata against the geocoded loss
        location. It never rejects uploads, blocks submission, or changes
        sufficiency scoring.
      properties:
        enabled: { type: boolean }
        status:
          type: string
          enum: [verified, disabled, no_loss_address, unresolved]
        thresholdMeters: { type: integer }
        lossLatitude: { type: number, nullable: true }
        lossLongitude: { type: number, nullable: true }
        lossAddress: { type: string, nullable: true }
        total: { type: integer }
        withGps: { type: integer }
        near: { type: integer }
        far: { type: integer }
        noGps: { type: integer }
        reason: { type: string }
        photos:
          type: array
          items:
            $ref: "#/components/schemas/LocationVerificationPhoto"
    LocationVerificationPhoto:
      type: object
      properties:
        id: { type: string, nullable: true }
        stepKey: { type: string, nullable: true }
        status: { type: string, enum: [near, far, no_gps] }
        distanceMeters: { type: number, nullable: true }
    PhotoList:
      type: object
      properties:
        photos:
          type: array
          items:
            $ref: "#/components/schemas/Photo"
        sessionStartLocation:
          type: object
          nullable: true
          properties:
            latitude: { type: number }
            longitude: { type: number }
    Photo:
      type: object
      properties:
        id: { type: string, format: uuid }
        stepKey: { type: string }
        stepTitle: { type: string }
        url:
          {
            type: string,
            description: "Short-lived signed download URL (~1 hour).",
          }
        thumbnailUrl:
          type: string
          nullable: true
          description: Short-lived signed thumbnail URL when a thumbnail exists.
        storagePath: { type: string }
        analysis: { type: object, nullable: true, additionalProperties: true }
        createdAt: { type: string, format: date-time, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
    Template:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        category:
          type: string
          enum: [property, vehicle]
          description: Property may be returned for historical templates; new inspections are vehicle-only.
        subtype: { type: string }
        reportFlavor:
          type: string
          enum: [claim, condition]
          description: Report framing snapshotted when an inspection is created from this template.
        version: { type: integer }
        active: { type: boolean }
        createdAt: { type: string, format: date-time }
    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }

# ---------------------------------------------------------------------------
# Webhooks (delivered to org-configured endpoints; outbound, not part of the
# request/response API above). Full integrator guide — signature verification,
# delivery semantics, and per-event payloads — is in docs/PUBLIC_API.md.
#
# Each delivery is an HTTP POST with headers:
#   X-CIQ-Event:     the event name (e.g. inspection.submitted)
#   X-CIQ-Delivery:  unique delivery id (dedupe on this)
#   X-CIQ-Signature: t=<unix>,v1=<hex>  where hex = HMAC-SHA256(secret, `${t}.${rawBody}`)
# Body: { event, id, created_at, data: { entityType, entityId, eventType, ... } }
# Events: inspection.created, inspection.opened, inspection.submitted,
#         inspection.analyzed, inspection.sufficiency_completed,
#         inspection.supplement_created, inspection.triage.completed,
#         turnaround.completed
#
# Verification: compute the HMAC over the RAW request body bytes (`${t}.${rawBody}`)
# before JSON-parsing, constant-time compare against v1, and reject timestamps
# outside a ~5 min tolerance window.
#
# Delivery semantics: AT-LEAST-ONCE. The first attempt is inline; failures retry
# with backoff (1m/5m/30m/2h/6h, max 6) drained by the webhooks:dispatch cron.
# Duplicates and out-of-order delivery are possible — dedupe on X-CIQ-Delivery,
# keep handlers idempotent, and reconcile via GET /inspections/{id} when ordering
# matters. Ack within 10s with a 2xx or the delivery is retried.
# ---------------------------------------------------------------------------
