# HousingSurvey Pro REST API v1 — OpenAPI 3.0
# Canonical copy: docs/api/openapi.yaml (served at https://housingsurvey-pro.web.app/api/openapi.yaml,
# rendered by Swagger UI at https://housingsurvey-pro.web.app/api/docs/).
# Also the source definition for the Microsoft Power Automate custom connector
# (some tenants require OpenAPI 2.0 on import: convert with
#  `npx api-spec-converter --from openapi_3 --to swagger_2 openapi.yaml`).
openapi: 3.0.3
info:
  title: HousingSurvey Pro API
  version: "1.0"
  description: |
    Evidence integration API for housing case-management systems: pull
    HACT-aligned, tamper-evident damp & mould evidence records, and push work
    orders to the field.

    **Integrity model.** Finalized records are locked server-side and
    hash-chained per property (UPRN): `recordHash` is the SHA-256 of the
    record's canonical JSON concatenated with the previous record's hash for
    the same property (`prevRecordHash`, `chainSeq`). Any alteration anywhere
    in the timeline is detectable, and a nightly verifier recomputes every
    chain.

    **Auditing.** Every API call is written to the organisation's append-only,
    hash-chained audit log — integration access is itself evidence.

    **Webhooks.** Configure an endpoint in the portal (Integrations) and
    receive a `survey.finalized` POST the moment a record locks. Bodies are
    HMAC-SHA256 signed: `x-hsp-signature` is the hex HMAC of the raw request
    body using your signing secret (shown once at setup); `x-hsp-event` names
    the event. Failed deliveries are retried for one hour. Payload shape:
    `WebhookSurveyFinalized` in the schemas below.

    **Testing.** Every integration is self-serve testable before you rely on
    it: the portal's "Send test event" button fires a real, `x-hsp-signature`-
    signed `test.ping` event at your configured webhook URL immediately (no
    queue, no waiting for a real survey), and shows the exact JSON body and
    headers your endpoint received. A "Quick start" panel next to the API key
    section gives copy-paste `curl` examples for this API, and a sample HACT
    payload viewer shows the full response shape using synthetic data. See
    the handbook's Integrations — Testing your connection guide for the full
    walkthrough (webhook, API, EDI, SMTP and AI provider checks).

    **Report versions.** A revised report is never edited in place — the
    original stays sealed and hash-chained, and a new record is created that
    supersedes it. Every survey carries `lineageId` (stable across every
    version — the original record's own id), `versionNo` (1-based,
    incrementing) and `supersedes` (the id of the version it replaces; `null`
    on the original). Key stored reports by `lineageId` and treat the highest
    `versionNo` as current. Example `survey.finalized` body for version 3 of a
    report:
    ```json
    {
      "event": "survey.finalized",
      "orgId": "org_4b7e1a",
      "surveyId": "svy_1e88bb",
      "recordHash": "3f9e2c1a7b…",
      "chainSeq": 12,
      "lineageId": "svy_9f2a01",
      "versionNo": 3,
      "supersedes": "svy_c77d40"
    }
    ```
    The nightly EDI CSV export carries the same lineage as `version_no` and
    `supersedes` columns alongside its existing `record_hash`/`chain_seq`
    columns.

    **Residency.** All endpoints and data live in europe-west2 (London).
  contact:
    email: support@prosurvey.app
    url: https://housingsurvey.pro
servers:
  - url: https://europe-west2-housingsurvey-pro.cloudfunctions.net/apiV1/v1
    description: Production (europe-west2, London)
security:
  - apiKey: []
tags:
  - name: Evidence
    description: Finalized, hash-chained damp & mould evidence (read)
  - name: Properties
    description: UPRN-keyed housing stock touched by evidence capture (read)
  - name: Work orders
    description: Push cases from your CMS to the field (write)
  - name: Contractors
    description: Supply-chain directory — rich contractor records, invite codes, credential expiry (read/write)
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >
        Org-scoped API key (`hsp_live_...`) minted in the portal → Integrations.
        Keys carry scopes (`surveys:read`, `properties:read`,
        `work-orders:read`, `work-orders:write`), are revocable, and only
        their SHA-256 hash is stored server-side.
  schemas:
    Error:
      type: object
      properties:
        error: { type: string, example: "missing scope surveys:read" }
    Geo:
      type: object
      nullable: true
      description: On-site GPS fix sealed inside the hashed record.
      properties:
        lat: { type: number, example: 53.6458 }
        lng: { type: number, example: -2.1563 }
        accuracyM: { type: number, example: 8 }
    Measurement:
      type: object
      properties:
        room: { type: string, example: "Bedroom 2" }
        rh: { type: number, nullable: true, description: "Relative humidity %", example: 74.5 }
        tempC: { type: number, nullable: true, example: 16.8 }
        surfaceTempC: { type: number, nullable: true, example: 12.1 }
        wme: { type: number, nullable: true, description: "Wood moisture equivalent %", example: 18 }
        dewPoint: { type: number, nullable: true, example: 12.3 }
        iaqScore: { type: number, nullable: true, description: "Indoor air quality score 0–100" }
    Hazard:
      type: object
      properties:
        dampType: { type: string, example: "Condensation" }
        roomRef: { type: string, example: "Bedroom 2" }
        observations: { type: string, nullable: true, example: "Black mould to window reveal and external wall corner." }
        recommendations: { type: string, nullable: true }
    Evidence:
      type: object
      properties:
        url: { type: string, nullable: true, description: "Time-limited photo URL" }
        captureHash: { type: string, description: "SHA-256 of the raw image bytes at the moment of capture", example: "9f2c1a…" }
        capturedAt: { type: string, format: date-time }
    Chain:
      type: object
      description: Tamper-evidence block (single-record GET only).
      properties:
        recordHash: { type: string, example: "71b4fdc616374fe6…" }
        prevRecordHash: { type: string, nullable: true, description: "Hash of the previous finalized record for this property; empty at chain origin" }
        chainSeq: { type: integer, example: 3 }
        finalizedAt: { type: string, format: date-time, description: "Server-set; never a device clock" }
    Version:
      type: object
      description: >
        Convenience mirror of the report-lineage fields already present at the
        top level of HactSurvey (single-record GET only).
      properties:
        lineageId: { type: string, example: "svy_9f2a01" }
        versionNo: { type: integer, example: 1 }
        supersedes: { type: string, nullable: true, example: null }
    HactSurvey:
      type: object
      description: Canonical CMS-agnostic evidence payload, aligned to HACT / UK Housing Data Standards.
      properties:
        uprn: { type: string, description: "UPRN when resolved via OS Places, else internal property id", example: "100012345678" }
        inspectionDate: { type: string, format: date-time }
        inspectorRef: { type: string, description: "Surveyor identifier" }
        performingOrgRef: { type: string, description: "Org that captured (landlord itself, or a contractor)" }
        landlordOrgRef: { type: string, description: "Org that owns the evidence chain" }
        workOrderRef: { type: string, nullable: true }
        externalRef: { type: string, nullable: true, description: "Your CMS case/job id, echoed back" }
        recordHash: { type: string }
        chainSeq: { type: integer }
        lineageId: { type: string, description: "Stable across every version of this report — the original record's own id. Key your stored reports by this.", example: "svy_9f2a01" }
        versionNo: { type: integer, description: "1-based; increments on each revision. The highest versionNo for a lineageId is the current version.", example: 1 }
        supersedes: { type: string, nullable: true, description: "Id of the version this one replaces; null on the original (version 1).", example: null }
        withdrawal:
          type: object
          nullable: true
          description: >
            Present when the sealed record has been WITHDRAWN (marked
            should-not-be-relied-upon by an org admin/owner). The record is
            never deleted — it stays on its property's hash chain and keeps
            flowing through this API with this marker attached. Treat a
            withdrawn record as unreliable, never as absent. A `survey.withdrawn`
            webhook fires at the moment of withdrawal.
          properties:
            by: { type: string, description: "Withdrawing user's uid" }
            byEmail: { type: string }
            at: { type: string, format: date-time }
            reason: { type: string, example: "Captured at the wrong property" }
        geo: { $ref: "#/components/schemas/Geo" }
        measurements: { type: array, items: { $ref: "#/components/schemas/Measurement" } }
        hazards: { type: array, items: { $ref: "#/components/schemas/Hazard" } }
        evidence: { type: array, items: { $ref: "#/components/schemas/Evidence" } }
    Property:
      type: object
      properties:
        id: { type: string, description: "UPRN when resolved, else generated id" }
        uprn: { type: string, nullable: true }
        addressLine1: { type: string }
        postcode: { type: string }
        lat: { type: number, nullable: true }
        lng: { type: number, nullable: true }
        localAuthority: { type: string, nullable: true }
        needsReview: { type: boolean, description: "True when UPRN resolution failed and the address needs a human check" }
        lastSurveyAt: { type: string, format: date-time, nullable: true }
    WorkOrderCreate:
      type: object
      description: >
        Identify the property ONE of three ways, in order of preference:
        `uprn` (links the order to your imported property register and its
        tamper-evident evidence chain), `propertyId` (our register id, from
        `GET /properties`), or a free-text `propertyAddress`. An unknown
        `uprn`/`propertyId` returns 404 — an order is never created silently
        unlinked. When a register match is found, `propertyAddress` may be
        omitted and is denormalised from the register.
      properties:
        uprn: { type: string, example: "100012345678", description: "UPRN of a property in your register — the preferred link" }
        propertyId: { type: string, description: "Register id from GET /properties (alternative to uprn)" }
        propertyAddress: { type: string, example: "12 Example Rise, Rochdale OL11 1AA", description: "Free-text fallback when the property isn't in your register yet" }
        postcode: { type: string, example: "OL11 1AA" }
        description: { type: string, example: "Tenant reports black mould in child's bedroom", description: "The reported issue — shown in the surveyor's inbox" }
        externalRef: { type: string, example: "CASE-48211", description: "Your CMS case/job id — echoed back on the evidence" }
        referenceNumber: { type: string, maxLength: 100, example: "HA-260710-1425", description: "Human-facing job/report reference dictated by the management portal — distinct from externalRef (your CMS's own case/job id). Shown read-only in the field app by default; seeds the sealed record's own reference number." }
        reportedAt: { type: string, format: date-time, description: "When the hazard was reported — starts the statutory Awaab's Law clock (defaults to now)" }
        hazardSeverity: { type: string, enum: [significant, emergency], default: significant, description: "emergency = 24-hour statutory track; significant = working-day track" }
        scheduledFor: { type: string, format: date-time, description: "Pre-booked visit slot, if already arranged with the tenant" }
        dueBy: { type: string, format: date-time, description: "Statutory investigation deadline (Awaab's Law clock)" }
        contractorOrgId: { type: string, description: "Route the job to a granted contractor organisation" }
        assignedToEmail: { type: string, format: email, description: "Assign directly to a surveyor by email" }
    WorkOrder:
      allOf:
        - $ref: "#/components/schemas/WorkOrderCreate"
        - type: object
          properties:
            id: { type: string }
            landlordOrgId: { type: string }
            status: { type: string, enum: [open, accepted, scheduled, in-progress, completed, cancelled] }
            source: { type: string, enum: [portal, api] }
            createdAt: { type: string, format: date-time }
    StatutoryEffective:
      type: object
      nullable: true
      description: >
        Present once `stageTimestamps.investigatedAt` lands — the regulation
        anchors BOTH the written-summary (3 working days) and safety-works
        (5 working days; 24h if emergency) deadlines to the investigation
        ACTUALLY CONCLUDING, not to each other's originally projected
        deadlines. This is the OPERATIVE deadline once present: it re-anchors
        `summaryBy`/`worksBy` to `stageTimestamps.investigatedAt` rather than
        to `investigateBy`/`summaryBy` below, which stay untouched
        projections from `reportedAt` (retained for audit/back-compat). ADD
        this block never overwrites the projection — always prefer
        `effective` over the sibling top-level fields when present.
      properties:
        investigateBy: { type: string, format: date-time, description: "Mirrors statutory.investigateBy — the investigation stage is already met once effective exists" }
        summaryBy: { type: string, format: date-time }
        worksBy: { type: string, format: date-time, description: "Safety works COMPLETE deadline (not merely begun)" }
        anchoredTo: { type: string, enum: [investigatedAt] }
        computedAt: { type: string, format: date-time }
    Statutory:
      type: object
      nullable: true
      description: >
        Server-computed Awaab's Law deadlines (functions/awaab.js) — never
        client-set. Absent entirely on `jobType: general` orders, which carry
        no statutory clock. `investigateBy`/`summaryBy`/`worksBeginBy` are the
        original PROJECTION from `reportedAt`, retained untouched for
        audit/back-compat even after `effective` lands — always read
        `effective` first when it is present.
      properties:
        basis: { type: string, example: "awaabs-law-damp-mould-england" }
        reportedAt: { type: string, format: date-time }
        severity: { type: string, enum: [significant, emergency] }
        investigateBy: { type: string, format: date-time, description: "10 working days of reportedAt (24h if emergency) — original projection" }
        summaryBy: { type: string, format: date-time, description: "3 working days after investigateBy — original projection, chained off the prior deadline, NOT the actual investigation conclusion" }
        worksBeginBy: { type: string, format: date-time, description: "5 working days after summaryBy — original projection" }
        disclaimer: { type: string }
        computedAt: { type: string, format: date-time }
        effective: { $ref: "#/components/schemas/StatutoryEffective" }
    WorkOrderDetail:
      description: >
        Full work-order READ payload (`work-orders:read` scope) — the same
        3-stage statutory clock the portal's work-order drawer renders,
        including fire-once deadline alert flags, so a CMS can poll without
        re-deriving working-day maths itself. See `GET /work-orders` for the
        `statutory_state` filter semantics.
      allOf:
        - type: object
          properties:
            id: { type: string, example: "wo_8Fj2kLpQ" }
            status: { type: string, enum: [open, accepted, scheduled, in-progress, completed, cancelled] }
            jobType: { type: string, enum: [awaab-damp-mould, emergency, general], default: awaab-damp-mould }
            propertyAddress: { type: string }
            postcode: { type: string, nullable: true }
            propertyId: { type: string, nullable: true }
            uprn: { type: string, nullable: true }
            externalRef: { type: string, nullable: true }
            assignedTo: { type: string, nullable: true, description: "Surveyor uid" }
            caseOwner: { type: string, nullable: true, description: "Management-side owner uid (F1) — NOT the surveyor" }
            surveyId: { type: string, nullable: true, description: "Set once evidence capture is finalized against this order" }
            dueBy: { type: string, format: date-time, nullable: true }
            reportedAt: { type: string, format: date-time, nullable: true }
            hazardSeverity: { type: string, enum: [significant, emergency] }
            statutory: { $ref: "#/components/schemas/Statutory" }
            stageTimestamps:
              type: object
              description: "Contemporaneous stamps as each stage is actually met — keys: investigatedAt, summaryProvidedAt, worksBegunAt (a BEGIN signal, auto-stamped from an on-site presence check-in; distinct from the safety-works COMPLETE deadline it's compared against)."
              additionalProperties: { type: string, format: date-time }
              example: { investigatedAt: "2026-07-08T11:02:00Z" }
            deadlineAlerts:
              type: object
              description: "Fire-once alert flags raised by the deadline sweep, keyed '{stage}:{approaching|breached}' -> ISO time first raised."
              additionalProperties: { type: string, format: date-time }
              example: { "investigateBy:approaching": "2026-07-15T06:00:03Z" }
            createdAt: { type: string, format: date-time }
            updatedAt: { type: string, format: date-time }
    Contractor:
      type: object
      description: >
        A rich contractor record in your supply-chain directory. Created via the
        API or portal as an `invited` entry with an `inviteCode`; the contractor's
        organisation redeems the code in their portal to bind (`contractorOrgId`
        set, status `active`) — only bound, active contractors can receive work
        orders. Internal notes/rates are never exposed to the contractor.
      properties:
        id: { type: string }
        contractorOrgId: { type: string, nullable: true, description: "Set once the contractor's org accepts the invite" }
        contractorName: { type: string, example: "FixIt Damp Solutions Ltd" }
        tradingName: { type: string, nullable: true }
        companiesHouseNo: { type: string, nullable: true, example: "12345678" }
        contactName: { type: string, nullable: true }
        contactEmail: { type: string, nullable: true }
        contactPhone: { type: string, nullable: true }
        address: { type: string, nullable: true }
        trades: { type: array, items: { type: string }, example: ["damp-mould-remediation", "ventilation"] }
        regions: { type: array, items: { type: string }, example: ["Greater Manchester"] }
        insurance:
          type: object
          nullable: true
          properties:
            name: { type: string, example: "Aviva" }
            reference: { type: string, example: "PL-99887766" }
            expiresAt: { type: string, format: date, description: "Drives expiry warnings in the portal" }
        accreditations:
          type: array
          items:
            type: object
            properties:
              name: { type: string, example: "PCA" }
              reference: { type: string }
              expiresAt: { type: string, format: date }
        frameworkRef: { type: string, nullable: true }
        status: { type: string, enum: [invited, active, suspended] }
        inviteCode: { type: string, nullable: true, description: "Share with the contractor; consumed on acceptance" }
        createdAt: { type: string, format: date-time }
    WebhookSurveyFinalized:
      type: object
      description: >
        Body of the signed `survey.finalized` webhook POST (not a callable
        endpoint — documented here for integrators).
      properties:
        event: { type: string, example: "survey.finalized" }
        orgId: { type: string }
        at: { type: string, format: date-time }
        surveyId: { type: string }
        recordHash: { type: string }
        chainSeq: { type: integer }
        lineageId: { type: string, description: "Stable across every version of this report — the original record's own id.", example: "svy_9f2a01" }
        versionNo: { type: integer, example: 1 }
        supersedes: { type: string, nullable: true, description: "Id of the version this one replaces; null on the original (version 1).", example: null }
        hact: { $ref: "#/components/schemas/HactSurvey" }
  responses:
    Unauthorized:
      description: Missing or invalid API key
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Key lacks the required scope
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
paths:
  /surveys:
    get:
      tags: [Evidence]
      operationId: ListSurveys
      summary: List evidence records
      description: HACT-aligned records for your organisation, newest first. Soft-deleted drafts are never returned.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [Draft, Complete] }, description: "Filter by lifecycle state; use Complete for locked evidence" }
        - { name: updatedSince, in: query, schema: { type: string, format: date-time }, description: "Incremental sync watermark" }
        - { name: cursor, in: query, schema: { type: string }, description: "nextCursor from the previous page" }
        - { name: limit, in: query, schema: { type: integer, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Page of surveys
          content:
            application/json:
              schema:
                type: object
                properties:
                  surveys: { type: array, items: { $ref: "#/components/schemas/HactSurvey" } }
                  nextCursor: { type: string, nullable: true, description: "Pass as ?cursor= for the next page; null on the last page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /surveys/{surveyId}:
    get:
      tags: [Evidence]
      operationId: GetSurvey
      summary: Get one evidence record with chain proof
      parameters:
        - { name: surveyId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The record plus its tamper-evidence chain block
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/HactSurvey"
                  - type: object
                    properties:
                      chain: { $ref: "#/components/schemas/Chain" }
                      version: { $ref: "#/components/schemas/Version" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Not found (or not your organisation's record)
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  /properties:
    get:
      tags: [Properties]
      operationId: ListProperties
      summary: List properties
      parameters:
        - { name: cursor, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 200, default: 100 } }
      responses:
        "200":
          description: Page of UPRN-keyed properties
          content:
            application/json:
              schema:
                type: object
                properties:
                  properties: { type: array, items: { $ref: "#/components/schemas/Property" } }
                  nextCursor: { type: string, nullable: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /contractors:
    get:
      tags: [Contractors]
      operationId: ListContractors
      summary: List your contractor directory
      description: Rich supply-chain records including insurance/accreditation expiry dates and binding status.
      responses:
        "200":
          description: Contractor directory
          content:
            application/json:
              schema:
                type: object
                properties:
                  contractors:
                    type: array
                    items: { $ref: "#/components/schemas/Contractor" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Contractors]
      operationId: CreateContractor
      summary: Add a contractor to your directory
      description: >
        Creates an `invited` directory entry with an invite code (returned in the
        response) — sync your procurement system's supplier list, then share each
        code with its contractor to bind their organisation. Requires the
        `contractors:write` scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Contractor" }
            example:
              contractorName: "FixIt Damp Solutions Ltd"
              companiesHouseNo: "12345678"
              contactEmail: "dana@fixitdamp.co.uk"
              trades: ["damp-mould-remediation", "ventilation"]
              regions: ["Greater Manchester"]
              insurance: { name: "Aviva", reference: "PL-99887766", expiresAt: "2027-03-31" }
      responses:
        "201":
          description: Contractor created (invited)
          content:
            application/json:
              schema:
                type: object
                properties:
                  contractor: { $ref: "#/components/schemas/Contractor" }
        "400":
          description: contractorName missing
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /work-orders:
    get:
      tags: [Work orders]
      operationId: ListWorkOrders
      summary: Poll your work orders and their statutory clocks
      description: >
        Landlord-org-scoped list, newest-updated first. Requires the
        `work-orders:read` scope (separate from `work-orders:write`, which
        only lets a key create orders). Each order carries the full 3-stage
        Awaab's Law clock (`statutory`, incl. `effective` once the
        investigation has actually concluded — see `StatutoryEffective`),
        `stageTimestamps` and `deadlineAlerts` — poll this alongside or
        instead of the `work_order.deadline_approaching` / `_breached` /
        `stage_recorded` webhooks.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, accepted, scheduled, in-progress, completed, cancelled] } }
        - name: statutory_state
          in: query
          schema: { type: string, enum: [approaching, breached, met] }
          description: >
            DERIVED filter, not a stored field. `breached`: any unmet stage's
            operative deadline (effective when present, else the projection)
            is in the past. `approaching`: any unmet stage is within 2
            working days (mirrors the sweep's own alert window) and none are
            breached. `met`: every stage is stamped, or the order carries no
            statutory clock at all (e.g. `jobType: general`).
        - { name: jobType, in: query, schema: { type: string, enum: [awaab-damp-mould, emergency, general] } }
        - { name: updated_since, in: query, schema: { type: string, format: date-time }, description: "Incremental sync watermark" }
        - { name: cursor, in: query, schema: { type: string }, description: "nextCursor from the previous page" }
        - { name: limit, in: query, schema: { type: integer, maximum: 200, default: 50 } }
      responses:
        "200":
          description: Page of work orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  workOrders: { type: array, items: { $ref: "#/components/schemas/WorkOrderDetail" } }
                  nextCursor: { type: string, nullable: true, description: "Pass as ?cursor= for the next page; null on the last page" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      tags: [Work orders]
      operationId: CreateWorkOrder
      summary: Push a damp/mould case to the field
      description: >
        Creates a work order that appears in the assigned surveyor's (or
        contractor's) app inbox. When the resulting evidence is finalized, the
        work order completes automatically and the evidence carries your
        `externalRef` — close the loop by listening for `survey.finalized` or
        polling `GET /surveys?updatedSince=`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkOrderCreate" }
            examples:
              byUprn:
                summary: Linked to the property register by UPRN (preferred)
                value:
                  uprn: "100012345678"
                  description: "Tenant reports black mould in child's bedroom"
                  externalRef: "CASE-48211"
                  hazardSeverity: significant
                  reportedAt: "2026-07-06T09:00:00Z"
                  assignedToEmail: "surveyor@your-org.co.uk"
              freeTextAddress:
                summary: Property not imported yet — free-text address
                value:
                  propertyAddress: "12 Example Rise, Rochdale OL11 1AA"
                  postcode: "OL11 1AA"
                  description: "Damp patch to rear elevation, ground floor"
                  externalRef: "CASE-48212"
                  hazardSeverity: emergency
      responses:
        "201":
          description: Work order created (statutory Awaab's Law deadlines are stamped server-side moments later)
          content:
            application/json:
              schema:
                type: object
                properties:
                  workOrder: { $ref: "#/components/schemas/WorkOrder" }
              example:
                workOrder:
                  id: "wo_8Fj2kLpQ"
                  landlordOrgId: "org_ha1"
                  propertyId: "100012345678"
                  uprn: "100012345678"
                  propertyAddress: "12 Example Rise, Rochdale, OL11 1AA"
                  externalRef: "CASE-48211"
                  source: api
                  status: open
                  hazardSeverity: significant
                  reportedAt: "2026-07-06T09:00:00Z"
                  createdAt: "2026-07-06T09:00:04Z"
        "400":
          description: No property identification supplied (need uprn, propertyId or propertyAddress)
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "404":
          description: uprn / propertyId not found in your property register
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /work-orders/{workOrderId}:
    get:
      tags: [Work orders]
      operationId: GetWorkOrder
      summary: Get one work order with its full statutory clock
      description: Requires the `work-orders:read` scope.
      parameters:
        - { name: workOrderId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: The work order
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkOrderDetail" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: Not found (or not your organisation's work order)
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
