openapi: 3.1.0
info:
  title: HailMate API
  version: "2.1.0"
  summary: Jobs, contacts, tasks, money, photos, door knocks and hail history for your HailMate workspace.
  description: |
    The HailMate API lets another app read and write your workspace's records,
    and have HailMate call it the moment something happens.

    **Authentication.** Create an API key in HailMate under
    **Settings → Integrations → API & Webhooks** and send it as
    `Authorization: Bearer hm_live_…` (or `X-API-Key: hm_live_…`). A key
    belongs to ONE workspace and can only ever see that workspace's records.
    A key is either **full access** or **read only**.

    **Log in with HailMate (OAuth 2.0).** An app that connects many
    HailMate companies — Zapier is the first — sends the person to
    `https://app.hailmate.ai/oauth/authorize`, where they pick a company and
    press Allow; the app then trades the code for an access token at
    `POST /oauth/token` and sends it as `Authorization: Bearer hm_oat_…`.
    Access tokens last an hour; the refresh token renews them. Apps are
    registered by HailMate — write to contact@hailmate.ai.

    **Limits.** 120 requests a minute per key. Every response carries
    `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`, and a
    `429` carries `Retry-After`.

    **Pagination.** Lists are newest first, paged with an opaque cursor: pass
    `next_cursor` back as `?cursor=` until it is `null`.

    **Idempotency.** Send `Idempotency-Key: <any unique string>` on a POST,
    PATCH or DELETE and a retry with the same key replays the first answer
    instead of doing the work twice. Keys are remembered for 24 hours.

    **Errors.** Every error is `{ "error": { "code", "message", "field?",
    "request_id", "doc_url" } }`. Quote `request_id` (also sent as
    `X-Request-Id`) when you contact us.

    **Webhooks.** Subscribe a URL to any of the events below and HailMate
    posts it a signed JSON body within seconds of the change. See the
    `webhooks` section of this document and https://hailmate.ai/docs/api/webhooks.
  contact:
    name: HailMate support
    email: contact@hailmate.ai
    url: https://hailmate.ai/docs/api
  termsOfService: https://hailmate.ai/terms
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary

servers:
  - url: https://app.hailmate.ai/api/v1
    description: Production. There is no sandbox — use a separate workspace to test.

security:
  - BearerAuth: []
  - ApiKeyHeader: []
  - OAuth2: []

tags:
  - name: Connection
    description: Check a key and see which workspace it belongs to.
  - name: Jobs
    description: The storm-restoration jobs on your pipeline boards.
  - name: Contacts
    description: Homeowners, adjusters, subcontractors and everyone else in your book.
  - name: Tasks
    description: Tasks and appointments. An appointment is a task with an `appointment_type`.
  - name: Estimates
    description: Estimates and proposals — made from one of your templates or as your standard proposal, then finished and sent in HailMate.
  - name: Invoices
    description: Draft invoices, totalled exactly as HailMate totals them; mark them sent, or void them.
  - name: Payments
    description: Money received against a job — carrier cheques, deductibles, card payments.
  - name: Notes
    description: Notes on a job's timeline.
  - name: Files
    description: Photos and documents on a job.
  - name: Canvassing
    description: Door knocks — pins on the canvassing map with a knock result.
  - name: Storm Lists
    description: Every property under hail of a chosen size, with the owner of record (read only).
  - name: Hail
    description: Hail and damaging-wind history at an address.
  - name: Lookups
    description: Pipelines, stages, teammates and the event catalogue — what a dropdown is built from.
  - name: Webhooks
    description: Subscribe a URL to events.
  - name: OAuth
    description: "Log in with HailMate: the token endpoints for an app that connects on a person's behalf."

paths:
  /ping:
    get:
      tags: [ Connection ]
      operationId: ping
      summary: Check a key
      description: >
        The cheapest authenticated call. Returns the workspace the key belongs to and what the key may do. `/me` is the same endpoint.
      responses:
        "200":
          description: The key is valid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ping" }
              example:
                ok: true
                object: workspace
                workspace_id: 5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10
                workspace: Ridgeline Roofing
                api_version: v1
                key: { name: Zapier, access: write }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PlanRequired" }

  # ---------------------------------------------------------------------------
  # Jobs
  # ---------------------------------------------------------------------------
  /jobs:
    get:
      tags: [ Jobs ]
      operationId: listJobs
      summary: List jobs
      description: Newest first. Archived jobs are left out unless you ask for them.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: stage
          in: query
          description: A stage `key` from `/stages`.
          schema: { type: string }
        - name: pipeline_id
          in: query
          schema: { type: string, format: uuid }
        - name: job_type
          in: query
          schema: { type: string, enum: [ insurance, retail ] }
        - name: assigned_to
          in: query
          description: A user id. Matches the primary assignee OR any co-assignee.
          schema: { type: string, format: uuid }
        - name: contact_id
          in: query
          description: Jobs where this contact is the homeowner, the adjuster, or a secondary contact.
          schema: { type: string, format: uuid }
        - name: archived
          in: query
          description: Omitted, only live jobs. `true` returns archived jobs, `any` returns both.
          schema: { type: string, enum: [ "true", "any" ] }
      responses:
        "200":
          description: A page of jobs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobList" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Jobs ]
      operationId: createJob
      summary: Create a job
      description: |
        Only `name` and `address` are required. With no `stage` the job lands
        on the first column of your default pipeline. `stage` may be a stage
        **key** or its **label** as your board shows it ("Claim Approved").
        `notes`, when sent, becomes the first note on the job's timeline.

        A homeowner, adjuster or assignee named by id must belong to this
        workspace. The new job fires `job.created`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobCreate" }
            example:
              name: 1804 Cedar Ridge Dr
              address: 1804 Cedar Ridge Dr
              city: Plano
              state: TX
              postal_code: "75024"
              job_type: insurance
              stage: Inspection Scheduled
              homeowner_id: b7c2d3e4-4444-4000-8000-0000000000dd
              lead_source: Website
              insurance_company: Example Mutual
              claim_number: CLM-88213
              notes: Homeowner reports hail on the back slope.
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }

  /jobs/search:
    get:
      tags: [ Jobs ]
      operationId: searchJobs
      summary: Find jobs
      description: >
        `address` and `name` match part of the value ("1804 Cedar" finds "1804 Cedar Ridge Dr"); `job_number` and `claim_number` must match whole (case-insensitive). With no criteria the result is EMPTY — never "the newest jobs", which is how a blank search step writes to the wrong job.
      parameters:
        - name: address
          in: query
          schema: { type: string }
        - name: job_number
          in: query
          schema: { type: string }
        - name: claim_number
          in: query
          schema: { type: string }
        - name: name
          in: query
          schema: { type: string }
        - name: contact_id
          in: query
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Matching jobs, newest first. Not paged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: getJob
      summary: Get a job
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Jobs ]
      operationId: updateJob
      summary: Update a job
      description: |
        Send only the fields to change. A field sent as `null` (or `""`) is
        cleared; a field left out is untouched. `name` and `address` cannot be
        cleared.

        **Moving a job** is `{"stage": "Claim Approved"}` — a key or a label.
        It runs every stage automation, writes the timeline entry and fires
        `job.stage_changed`, exactly as dragging the card does. Setting
        `assigned_to` makes that teammate the primary assignee.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobUpdate" }
            example:
              stage: claim_approved
              rcv_amount: 24380.5
              assigned_to: 5d0e1c2b-7777-4000-8000-0000000000ab
      responses:
        "200":
          description: The job, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ Jobs ]
      operationId: deleteJob
      summary: Delete a job
      description: >
        The job goes to **Settings → Recently Deleted** for 30 days with everything on it, and can be restored from there. Fires `job.deleted`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
              example: { id: f2b1a0c4-1111-4000-8000-0000000000aa, object: job, deleted: true }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs/{id}/notes:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobNotes
      summary: List a job's notes
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of notes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteList" }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs/{id}/files:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobFiles
      summary: List a job's photos and documents
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: category
          in: query
          schema: { type: string, enum: [ photo, document, scope, estimate, contract, other ] }
      responses:
        "200":
          description: A page of files.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileList" }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs/{id}/photos:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobPhotos
      summary: List a job's photos
      description: "`/jobs/{id}/files?category=photo`, as its own path."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of photos.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}/tasks:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobTasks
      summary: List a job's tasks and appointments
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of tasks.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}/estimates:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobEstimates
      summary: List a job's estimates
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of estimates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}/invoices:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobInvoices
      summary: List a job's invoices
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of invoices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvoiceList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs/{id}/payments:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Jobs ]
      operationId: listJobPayments
      summary: List a job's payments
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of payments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------------------
  # Contacts
  # ---------------------------------------------------------------------------
  /contacts:
    get:
      tags: [ Contacts ]
      operationId: listContacts
      summary: List contacts
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: type
          in: query
          schema: { $ref: "#/components/schemas/ContactType" }
        - name: assigned_to
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A page of contacts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Contacts ]
      operationId: createContact
      summary: Create a contact
      description: |
        At least one of a name, `email`, `phone` or `company` is required — a
        contact with none of them is refused rather than filling your CRM with
        blank rows. Send `name` alone and it is split into first and last.

        US phone numbers are stored as `(615) 555-0148`, the way your team
        types them. `sms_consent` cannot be set: no integration can establish
        that a homeowner agreed to be texted.

        To avoid duplicates, search first — `GET /contacts/search` matches a
        phone number however it was typed.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactCreate" }
            example:
              first_name: Dana
              last_name: Reed
              email: dana@example.com
              phone: "+19725550148"
              address: 1804 Cedar Ridge Dr
              city: Plano
              state: TX
              postal_code: "75024"
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/InvalidRequest" }

  /contacts/search:
    get:
      tags: [ Contacts ]
      operationId: searchContacts
      summary: Find contacts
      description: >
        Tries `email` first, then `phone`, then `name`, and returns the first that matches. A phone number matches however it was typed into HailMate — `+16155550148`, `615-555-0148` and `(615) 555-0148` are the same number. `name` matches a first name, last name, "first last" or a company. With no criteria the result is empty.
      parameters:
        - name: email
          in: query
          schema: { type: string }
        - name: phone
          in: query
          schema: { type: string }
        - name: name
          in: query
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Matching contacts. Not paged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /contacts/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Contacts ]
      operationId: getContact
      summary: Get a contact
      responses:
        "200":
          description: The contact.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Contacts ]
      operationId: updateContact
      summary: Update a contact
      description: Send only the fields to change; `null` clears a field.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactUpdate" }
            example: { email: dana.reed@example.com, notes: Prefers texts after 5pm. }
      responses:
        "200":
          description: The contact, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ Contacts ]
      operationId: deleteContact
      summary: Delete a contact
      description: Kept for 30 days under Settings → Recently Deleted. Fires `contact.deleted`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

  /contacts/{id}/jobs:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Contacts ]
      operationId: listContactJobs
      summary: List a contact's jobs
      description: Every job this person is on — as homeowner, adjuster or secondary contact — archived included.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of jobs.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------------------
  # Tasks
  # ---------------------------------------------------------------------------
  /tasks:
    get:
      tags: [ Tasks ]
      operationId: listTasks
      summary: List tasks and appointments
      description: "`/appointments` is the same list narrowed to appointments."
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: completed
          in: query
          schema: { type: string, enum: [ "true", "false" ] }
        - name: appointments
          in: query
          description: "`true` returns appointments only."
          schema: { type: string, enum: [ "true" ] }
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: contact_id
          in: query
          schema: { type: string, format: uuid }
        - name: assigned_to
          in: query
          schema: { type: string, format: uuid }
        - name: due_after
          in: query
          description: Wall-clock, e.g. `2026-10-01` or `2026-10-01T08:00:00`.
          schema: { type: string }
        - name: due_before
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of tasks.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TaskList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Tasks ]
      operationId: createTask
      summary: Create a task or an appointment
      description: |
        Set `appointment_type` (`inspection`, `adjuster_meeting`, `build_day`)
        to make it an appointment; `POST /appointments` requires one.

        **Times are wall-clock.** `due_date` is the local time your crew will
        read. If you send an offset (`2026-10-02T15:00:00-05:00`, which is what
        Zapier sends) the offset is dropped and 3:00 PM is kept — HailMate
        never shifts an appointment by a time zone.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskCreate" }
            example:
              title: Adjuster meeting — 1804 Cedar Ridge Dr
              due_date: "2026-10-02T15:00:00"
              duration_minutes: 60
              appointment_type: adjuster_meeting
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              assigned_to: 5d0e1c2b-7777-4000-8000-0000000000ab
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "400": { $ref: "#/components/responses/InvalidRequest" }

  /tasks/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Tasks ]
      operationId: getTask
      summary: Get a task
      responses:
        "200":
          description: The task.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Tasks ]
      operationId: updateTask
      summary: Update a task
      description: >
        Reschedule it, reassign it, or finish it with `{"completed": true}` (fires `task.completed`). `null` clears a field.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TaskUpdate" }
            example: { completed: true, outcome: completed }
      responses:
        "200":
          description: The task, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Task" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ Tasks ]
      operationId: deleteTask
      summary: Delete a task
      description: Permanent. Fires `task.deleted`.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Estimates and invoices (read only)
  # ---------------------------------------------------------------------------
  /estimates:
    get:
      tags: [ Estimates ]
      operationId: listEstimates
      summary: List estimates
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: status
          in: query
          schema: { type: string, enum: [ draft, sent, viewed, signed, expired, declined ] }
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: estimate_number
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of estimates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Estimates ]
      operationId: createEstimate
      summary: Create an estimate
      description: |
        Makes a **draft** estimate on a job, one of two ways — the same two the
        app offers:

        - **From one of your templates** (`template_id`, from
          `GET /estimate_templates`): an exact copy, with its smart fields
          (`{{customer_name}}`, `{{property_address}}`…) filled in from the job.
        - **Your standard proposal**: cover, introduction, Scope of Work,
          warranty, terms and signature. The Scope of Work is the lines you
          send, or HailMate's standard checklist when you send none.

        `price` states one price for the whole job, which is what the homeowner
        sees as the total. The estimate is not sent — a person finishes and
        sends it in HailMate. Fires `estimate.created`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateCreate" }
            example:
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              template_id: 7a1c9e02-2222-4000-8000-0000000000c3
              price: 14500
      responses:
        "201":
          description: Created, as a draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /estimates/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Estimates ]
      operationId: getEstimate
      summary: Get an estimate
      responses:
        "200":
          description: The estimate.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Estimates ]
      operationId: updateEstimate
      summary: Update an estimate
      description: |
        Changes what sits outside the document: its title, how long it is
        valid, and one stated price (`null` takes the price off). The sections
        and lines are edited in HailMate. Refused with `409` once the estimate
        is out for signature or signed.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EstimateUpdate" }
            example:
              price: 15250
              valid_until: "2026-12-01"
      responses:
        "200":
          description: The estimate, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Estimate" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /estimate_templates:
    get:
      tags: [ Estimates ]
      operationId: listEstimateTemplates
      summary: List estimate templates
      description: The workspace's own templates, most recently changed first — what a "which template?" dropdown lists.
      parameters:
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: The templates.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EstimateTemplateList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /invoices:
    get:
      tags: [ Invoices ]
      operationId: listInvoices
      summary: List invoices
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: status
          in: query
          schema: { type: string, enum: [ draft, sent, viewed, partially_paid, paid, overdue, void, bad_debt ] }
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: contact_id
          in: query
          schema: { type: string, format: uuid }
        - name: invoice_number
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of invoices.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InvoiceList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Invoices ]
      operationId: createInvoice
      summary: Create an invoice
      description: |
        Makes a **draft** invoice for a job (`job_id`) or a contact
        (`contact_id`), with at least one line. HailMate numbers it
        (`INV-0312`) and totals it with the same calculator the app uses: lines,
        minus any discount, plus tax on the taxable lines. A negative price is a
        credit. `purpose` fills the matching figure on the job — a `deductible`
        invoice sets the job's deductible — exactly as in the app.

        A draft is not sent. Send it from HailMate, or call `mark_sent` when it
        went out another way. Fires `invoice.created`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceCreate" }
            example:
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              title: Deductible
              purpose: deductible
              due_date: "2026-10-12"
              line_items:
                - { name: Insurance deductible, quantity: 1, unit_price: 2500 }
      responses:
        "201":
          description: Created, as a draft.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /invoices/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Invoices ]
      operationId: getInvoice
      summary: Get an invoice
      responses:
        "200":
          description: The invoice.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Invoices ]
      operationId: updateInvoice
      summary: Update a draft invoice
      description: |
        Only a **draft** can change — once an invoice is sent the homeowner
        holds a copy, so a sent one is voided and replaced instead (`409`).
        `line_items`, when sent, replace every line; the totals are worked out
        again either way.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/InvoiceUpdate" }
            example:
              due_date: "2026-10-20"
              tax_rate: 8.25
      responses:
        "200":
          description: The invoice, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [ Invoices ]
      operationId: deleteInvoice
      summary: Delete a draft invoice
      description: Only a draft. A sent invoice is voided instead, so its history stays on the job.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
              example: { id: c90d7b31-3333-4000-8000-0000000000f2, object: invoice, deleted: true }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /invoices/{id}/mark_sent:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [ Invoices ]
      operationId: markInvoiceSent
      summary: Mark an invoice sent
      description: |
        Records that the invoice went out some other way. A draft becomes
        `sent`, which turns on the homeowner's pay page (`payment_url`); an
        invoice already out keeps its status. Refused for a void invoice.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The invoice, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  /invoices/{id}/void:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    post:
      tags: [ Invoices ]
      operationId: voidInvoice
      summary: Void an invoice
      description: Voids the invoice. Voiding one that is already void answers with it, unchanged.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The invoice, now void.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Invoice" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Payments
  # ---------------------------------------------------------------------------
  /payments:
    get:
      tags: [ Payments ]
      operationId: listPayments
      summary: List payments
      description: A refund is its own row with a negative `amount`, so summing `amount` gives the net.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: invoice_id
          in: query
          schema: { type: string, format: uuid }
        - name: payment_type
          in: query
          schema: { $ref: "#/components/schemas/PaymentType" }
      responses:
        "200":
          description: A page of payments.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaymentList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Payments ]
      operationId: createPayment
      summary: Record a payment
      description: |
        Records money that arrived — a cheque logged in your accounting app, a
        deposit from a bank feed. The job's money and, when you name one, the
        invoice's balance and status update exactly as they do for a payment
        logged in HailMate.

        **The invoice it pays is never guessed.** Send `invoice_id` to apply it
        to an invoice on the same job; leave it out and the payment sits on the
        job. Fires `payment.received`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PaymentCreate" }
            example:
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              amount: 13164.2
              payment_type: acv
              payer_type: insurance
              payment_method: check
              check_number: "004471"
              date_received: "2026-09-28"
      responses:
        "201":
          description: Recorded.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payment" }
        "400": { $ref: "#/components/responses/InvalidRequest" }

  /payments/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Payments ]
      operationId: getPayment
      summary: Get a payment
      responses:
        "200":
          description: The payment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Payment" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Notes
  # ---------------------------------------------------------------------------
  /notes:
    get:
      tags: [ Notes ]
      operationId: listNotes
      summary: List notes
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A page of notes.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NoteList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Notes ]
      operationId: createNote
      summary: Add a note to a job
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NoteCreate" }
            example:
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              content: Adjuster confirmed for Thursday at 3.
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Note" }
        "400": { $ref: "#/components/responses/InvalidRequest" }

  /notes/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Notes ]
      operationId: getNote
      summary: Get a note
      responses:
        "200":
          description: The note.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Note" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Files
  # ---------------------------------------------------------------------------
  /files:
    get:
      tags: [ Files ]
      operationId: listFiles
      summary: List photos and documents
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: category
          in: query
          schema: { type: string, enum: [ photo, document, scope, estimate, contract, other ] }
      responses:
        "200":
          description: A page of files.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Files ]
      operationId: uploadFile
      summary: Upload a photo or document to a job
      description: |
        Two ways in:

        - **multipart/form-data** with a `file` field and a `job_id` field
          (plus optional `category`, `description`, `file_name`).
        - **JSON** with a public `file_url` HailMate downloads for you.

        Up to 25 MB. A JPEG, PNG, WebP, GIF or MP4 is filed as a **photo**;
        anything else (a PDF, a HEIC, a spreadsheet) as a **document**, so it
        never shows as a broken tile. Fires `file.created`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: "#/components/schemas/FileUploadForm" }
          application/json:
            schema: { $ref: "#/components/schemas/FileFromUrl" }
            example:
              job_id: f2b1a0c4-1111-4000-8000-0000000000aa
              file_url: https://example.com/claim/scope-of-loss.pdf
              category: scope
      responses:
        "201":
          description: Stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "413":
          description: The file is larger than 25 MB.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /files/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Files ]
      operationId: getFile
      summary: Get a file
      responses:
        "200":
          description: The file.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Canvassing
  # ---------------------------------------------------------------------------
  /pins:
    get:
      tags: [ Canvassing ]
      operationId: listPins
      summary: List canvassing pins
      description: Every door a rep dropped a pin on, newest first.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - $ref: "#/components/parameters/CreatedAfter"
        - $ref: "#/components/parameters/CreatedBefore"
        - name: knock_result
          in: query
          schema: { $ref: "#/components/schemas/KnockResult" }
        - name: pin_type
          in: query
          schema: { type: string, enum: [ knock, inspection, job ] }
        - name: job_id
          in: query
          schema: { type: string, format: uuid }
        - name: created_by
          in: query
          description: The rep who dropped the pin.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A page of pins.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PinList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Canvassing ]
      operationId: createPin
      summary: Log a door knock
      description: |
        Drops a pin on the canvassing map. Send `latitude` and `longitude`, or
        an `address` and HailMate places it. It is credited to the teammate in
        `created_by_id`, else to whoever connected the integration. Fires
        `pin.created`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PinCreate" }
            example:
              address: 1804 Cedar Ridge Dr
              city: Plano
              state: TX
              postal_code: "75024"
              knock_result: interested
              homeowner_name: Dana Reed
      responses:
        "201":
          description: Logged.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Pin" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /pins/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Canvassing ]
      operationId: getPin
      summary: Get a pin
      responses:
        "200":
          description: The pin.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Pin" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [ Canvassing ]
      operationId: updatePin
      summary: Update a door knock
      description: Most often its knock result. The rep who logged it never changes. Fires `pin.result_changed` when the result moves.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PinUpdate" }
            example:
              knock_result: appointment_scheduled
      responses:
        "200":
          description: The pin, as it is now.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Pin" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ Canvassing ]
      operationId: deletePin
      summary: Delete a door knock
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
              example: { id: 0e4c2a19-8888-4000-8000-0000000000c1, object: pin, deleted: true }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # Storm lists
  # ---------------------------------------------------------------------------
  /storm_lists:
    get:
      tags: [ Storm Lists ]
      operationId: listStormLists
      summary: List storm lists
      description: >
        Read only — building a list spends data credits and happens in HailMate. `source: auto` is a nightly build off the hail data; `manual` is one somebody made.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/UpdatedSince"
        - name: status
          in: query
          schema: { type: string, enum: [ building, ready, partial, failed ] }
      responses:
        "200":
          description: A page of storm lists.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StormListList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /storm_lists/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Storm Lists ]
      operationId: getStormList
      summary: Get a storm list
      responses:
        "200":
          description: The storm list.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StormList" }
        "404": { $ref: "#/components/responses/NotFound" }

  /storm_lists/{id}/properties:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Storm Lists ]
      operationId: listStormListProperties
      summary: List the properties on a storm list
      description: >
        The rows a mail house wants. `mail_deliverable` is our own judgement from the address we hold — it is NOT a CASS or NCOA result; run your own validation before you spend postage.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: mailable
          in: query
          description: "`true` drops properties with no usable mailing address or owner name."
          schema: { type: string, enum: [ "true" ] }
        - name: residential
          in: query
          schema: { type: string, enum: [ "true" ] }
      responses:
        "200":
          description: A page of properties.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StormListPropertyList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------------------
  # Hail
  # ---------------------------------------------------------------------------
  /hail_history:
    get:
      tags: [ Hail ]
      operationId: getHailHistory
      summary: Hail and wind history at an address
      description: |
        Every hail day and damaging-wind day at an address (or a latitude and
        longitude) since `since` — the same Evidence Grade™ ruling HailMate
        shows on its maps and storm reports, combining radar-estimated hail
        size, severe-hail probability and verified ground reports.

        `confidence` is `confirmed` (a ground report backs the size), `likely`
        or `radar` (radar only). Default window: the last five years. United
        States only. Limited to 20 lookups a minute per key, inside the
        general limit.
      parameters:
        - name: address
          in: query
          description: A US street address. Or send `latitude` and `longitude`.
          schema: { type: string }
        - name: latitude
          in: query
          schema: { type: number }
        - name: longitude
          in: query
          schema: { type: number }
        - name: since
          in: query
          description: "`YYYY-MM-DD`. Defaults to five years ago."
          schema: { type: string, format: date }
      responses:
        "200":
          description: The history.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HailReport" }
        "400": { $ref: "#/components/responses/InvalidRequest" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ---------------------------------------------------------------------------
  # Lookups
  # ---------------------------------------------------------------------------
  /pipelines:
    get:
      tags: [ Lookups ]
      operationId: listPipelines
      summary: List pipelines and their stages
      responses:
        "200":
          description: Every pipeline, default first, with its stages in board order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Pipeline" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /stages:
    get:
      tags: [ Lookups ]
      operationId: listStages
      summary: List pipeline stages
      description: >
        Every stage on every board. `key` is what you send and filter on; `label` is what to show a person. `/pipeline_stages` is the same list.
      responses:
        "200":
          description: The stages, default pipeline first, in board order.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/PipelineStage" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /users:
    get:
      tags: [ Lookups ]
      operationId: listUsers
      summary: List your team
      responses:
        "200":
          description: The workspace's people.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /events:
    get:
      tags: [ Lookups ]
      operationId: listEvents
      summary: List every webhook event
      responses:
        "200":
          description: The event catalogue, with the filters each one accepts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EventDefinition" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ---------------------------------------------------------------------------
  # Webhooks
  # ---------------------------------------------------------------------------
  /webhooks:
    get:
      tags: [ Webhooks ]
      operationId: listWebhooks
      summary: List this key's webhooks
      description: A key only ever sees the subscriptions it created.
      responses:
        "200":
          description: The subscriptions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [ Webhooks ]
      operationId: createWebhook
      summary: Subscribe to an event
      description: |
        Send `event` (one event, or `"*"` for every event including ones added
        later) or `events` (several). The URL must be `https`, public, and not
        a private or link-local address. The response carries the signing
        `secret` — **once**. Several events for one URL share one secret.

        `filters` narrows a single-event subscription, e.g.
        `{"stage": "Claim Approved"}` on `job.stage_changed` fires only when a
        job enters that stage. `GET /events` lists what each event accepts.

        Subscribing the same event, URL and filters twice returns the existing
        subscription. A read-only key can subscribe.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
            example:
              event: job.stage_changed
              target_url: https://yourapp.example.com/hooks/hailmate
              filters: { stage: claim_approved }
      responses:
        "201":
          description: >
            Subscribed. One event returns the subscription; several return `{ "object": "list", "data": [...], "secret": "…" }`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookCreated" }
        "400": { $ref: "#/components/responses/InvalidRequest" }

  /webhooks/{id}:
    parameters: [ { $ref: "#/components/parameters/Id" } ]
    get:
      tags: [ Webhooks ]
      operationId: getWebhook
      summary: Get a webhook
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [ Webhooks ]
      operationId: deleteWebhook
      summary: Unsubscribe
      description: Answering a delivery with **410 Gone** unsubscribes too.
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Deleted" }
        "404": { $ref: "#/components/responses/NotFound" }

  /webhooks/samples/{event}:
    parameters:
      - name: event
        in: path
        required: true
        schema: { type: string }
        example: job.created
    get:
      tags: [ Webhooks ]
      operationId: getWebhookSamples
      summary: See what an event delivers
      description: >
        Up to three REAL recent records from your workspace, wrapped exactly as a delivery of that event wraps them. Use it to map fields before any event has happened.
      responses:
        "200":
          description: Sample deliveries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, const: list }
                  data: { type: array, items: { type: object } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------------------------------------------------------------------------
  # OAuth ("Log in with HailMate")
  # ---------------------------------------------------------------------------
  /oauth/token:
    post:
      tags: [ OAuth ]
      operationId: oauthToken
      summary: Get or refresh an access token
      description: |
        The token endpoint of the OAuth 2.0 authorization-code flow. After the
        person presses Allow at `https://app.hailmate.ai/oauth/authorize`, your
        app is sent back with `?code=…&state=…`; trade the code here within ten
        minutes. The access token lasts an hour — trade the refresh token for a
        new one. The refresh token does not change.

        Authenticate your app with `client_id` and `client_secret` in the body
        or as HTTP Basic. The body may be form-encoded (the standard) or JSON.
        Errors use the OAuth standard's own shape: `{ "error": "invalid_grant",
        "error_description": "…" }`. A code used twice disconnects the
        connection it made.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthTokenRequest" }
            example:
              grant_type: authorization_code
              code: hm_oac_3v7k2m9q4x8w1c6t5r0p2n8j4h6g2f0d9s7a5l3e
              redirect_uri: https://zapier.com/dashboard/auth/oauth/return/App246952CLIAPI/
              client_id: your_client_id
              client_secret: your_client_secret
      responses:
        "200":
          description: A token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthToken" }
        "400":
          description: The code or refresh token is not valid (`invalid_grant`), or the request is malformed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: Unknown app or wrong secret (`invalid_client`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/revoke:
    post:
      tags: [ OAuth ]
      operationId: oauthRevoke
      summary: Disconnect
      description: |
        RFC 7009. Send a refresh token to disconnect the connection — its
        tokens stop working and the webhooks it set up are switched off — or an
        access token to retire just that token. Always answers `200`.
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/OAuthRevokeRequest" }
            example:
              token: hm_ort_8d2k6m1q9x4w7c3t0r5p6n2j8h1g4f7d3s9a2l6e
              client_id: your_client_id
              client_secret: your_client_secret
      responses:
        "200":
          description: Done.
          content:
            application/json:
              schema: { type: object, example: {} }
        "401":
          description: Unknown app or wrong secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

# -----------------------------------------------------------------------------
# What HailMate posts to your URL. Every delivery is the record's own fields at
# the TOP LEVEL plus the `event_*` envelope — see EventEnvelope.
# -----------------------------------------------------------------------------
webhooks:
  job.created:
    post:
      operationId: jobCreatedEvent
      summary: A job was created
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Job" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  job.updated:
    post:
      operationId: jobUpdatedEvent
      summary: A job changed (`changed_fields` says what)
      description: Several quick changes to one job arrive as one delivery carrying every changed field.
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Job" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  job.stage_changed:
    post:
      operationId: jobStageChangedEvent
      summary: A job moved stage (`previous_stage` says from where)
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Job" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  job.assigned:
    post:
      operationId: jobAssignedEvent
      summary: A job got a new primary assignee
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Job" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  job.deleted:
    post:
      operationId: jobDeletedEvent
      summary: A job was deleted
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/DeletedSnapshot" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  contact.created:
    post:
      operationId: contactCreatedEvent
      summary: A contact was added
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Contact" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  contact.updated:
    post:
      operationId: contactUpdatedEvent
      summary: A contact changed
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Contact" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  contact.deleted:
    post:
      operationId: contactDeletedEvent
      summary: A contact was deleted
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/DeletedSnapshot" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  task.created:
    post:
      operationId: taskCreatedEvent
      summary: A task or appointment was created
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Task" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  task.updated:
    post:
      operationId: taskUpdatedEvent
      summary: A task changed
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Task" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  task.completed:
    post:
      operationId: taskCompletedEvent
      summary: A task was marked done
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Task" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  task.deleted:
    post:
      operationId: taskDeletedEvent
      summary: A task was deleted
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/DeletedSnapshot" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  appointment.created:
    post:
      operationId: appointmentCreatedEvent
      summary: An appointment was scheduled
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Task" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  estimate.created:
    post:
      operationId: estimateCreatedEvent
      summary: An estimate was created
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Estimate" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  estimate.sent:
    post:
      operationId: estimateSentEvent
      summary: An estimate went out
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Estimate" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  estimate.viewed:
    post:
      operationId: estimateViewedEvent
      summary: The homeowner opened an estimate for the first time
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Estimate" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  estimate.signed:
    post:
      operationId: estimateSignedEvent
      summary: A homeowner signed an estimate
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Estimate" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  estimate.declined:
    post:
      operationId: estimateDeclinedEvent
      summary: A homeowner declined an estimate
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Estimate" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  invoice.created:
    post:
      operationId: invoiceCreatedEvent
      summary: An invoice was created
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Invoice" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  invoice.sent:
    post:
      operationId: invoiceSentEvent
      summary: An invoice was sent
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Invoice" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  invoice.paid:
    post:
      operationId: invoicePaidEvent
      summary: An invoice was paid in full
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Invoice" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  invoice.overdue:
    post:
      operationId: invoiceOverdueEvent
      summary: An invoice went past due
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Invoice" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  invoice.voided:
    post:
      operationId: invoiceVoidedEvent
      summary: An invoice was voided
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Invoice" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  payment.received:
    post:
      operationId: paymentReceivedEvent
      summary: Money came in on a job
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Payment" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  payment.refunded:
    post:
      operationId: paymentRefundedEvent
      summary: A payment was refunded (negative amount)
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Payment" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  note.created:
    post:
      operationId: noteCreatedEvent
      summary: A note was added to a job
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Note" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  file.created:
    post:
      operationId: fileCreatedEvent
      summary: A photo or document was added to a job
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/File" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  pin.created:
    post:
      operationId: pinCreatedEvent
      summary: A rep dropped a pin on the canvassing map
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Pin" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  pin.result_changed:
    post:
      operationId: pinResultChangedEvent
      summary: A pin got a new knock result
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/Pin" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }
  storm_list.ready:
    post:
      operationId: stormListReadyEvent
      summary: A storm list finished building
      requestBody:
        content:
          application/json:
            schema: { allOf: [ { $ref: "#/components/schemas/EventEnvelope" }, { $ref: "#/components/schemas/StormList" } ] }
      responses: { "200": { description: Any 2xx settles the delivery. } }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer hm_live_…`"
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
    OAuth2:
      type: oauth2
      description: "Log in with HailMate. Apps are registered by HailMate — write to contact@hailmate.ai."
      flows:
        authorizationCode:
          authorizationUrl: https://app.hailmate.ai/oauth/authorize
          tokenUrl: https://app.hailmate.ai/api/v1/oauth/token
          refreshUrl: https://app.hailmate.ai/api/v1/oauth/token
          scopes:
            read: See records and be told when they change.
            write: See, create and change records.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      description: Page size. Out-of-range values fall back to the default.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` from the previous page, exactly as received. A cursor we did not issue is a 400.
      schema: { type: string }
    UpdatedSince:
      name: updated_since
      in: query
      description: Only records changed at or after this ISO date or date-time. The way to sync only what moved.
      schema: { type: string, format: date-time }
    CreatedAfter:
      name: created_after
      in: query
      description: Only records created at or after this ISO date or date-time.
      schema: { type: string, format: date-time }
    CreatedBefore:
      name: created_before
      in: query
      description: Only records created before this ISO date or date-time.
      schema: { type: string, format: date-time }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: >
        Any unique string (a UUID is ideal). A retry with the same key within 24 hours replays the first answer — with `Idempotent-Replayed: true` — instead of doing the work again. Reusing a key for a DIFFERENT request is a 409.
      schema: { type: string, maxLength: 255 }

  responses:
    Unauthorized:
      description: The key is missing, wrong or revoked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: unauthorized
              message: That API key is not valid, or it has been revoked.
              request_id: req_4c1n8y2m0q7p3x5z9a1b
              doc_url: https://hailmate.ai/docs/api#errors
    PlanRequired:
      description: The workspace has no active subscription.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: A read-only key tried to change something.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: No such record in this workspace.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InvalidRequest:
      description: Something in the request was wrong; `field` names what.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: invalid_request
              message: No stage called "Aproved" on this workspace's boards. Use a key or a label from GET /v1/stages.
              field: stage
              request_id: req_4c1n8y2m0q7p3x5z9a1b
              doc_url: https://hailmate.ai/docs/api#errors
    Conflict:
      description: An Idempotency-Key clash — reused for a different request, or the first is still running.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Over 120 requests a minute (or 20 hail lookups a minute). Wait `Retry-After` seconds.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      required: [ error ]
      properties:
        error:
          type: object
          required: [ code, message, doc_url ]
          properties:
            code:
              type: string
              enum: [ unauthorized, forbidden, not_found, invalid_request, conflict, payload_too_large, rate_limited, plan_required, method_not_allowed, server_error ]
              description: Machine-readable. Branch on this.
            message: { type: string, description: For a person. May be reworded. }
            field: { type: string, description: "The request field that was wrong, when there was one." }
            request_id: { type: string }
            doc_url: { type: string, format: uri }
      example:
        error:
          code: invalid_request
          message: A job needs a name.
          field: name
          request_id: req_jfvtxfboxyzopov1pi4d
          doc_url: https://hailmate.ai/docs/api#errors

    Ping:
      type: object
      properties:
        ok: { type: boolean }
        object: { type: string, const: workspace }
        workspace_id: { type: string, format: uuid }
        workspace: { type: [ string, "null" ], description: The workspace's name. }
        api_version: { type: string, example: v1 }
        key:
          type: object
          properties:
            name: { type: string }
            access: { type: string, enum: [ read, write ] }
      example:
        ok: true
        object: workspace
        workspace_id: 5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10
        workspace: Ridgeline Roofing
        api_version: v1
        key:
          name: Zapier
          access: write

    Deleted:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string }
        deleted: { type: boolean, const: true }
      example:
        id: f2b1a0c4-1111-4000-8000-0000000000aa
        object: job
        deleted: true

    DeletedSnapshot:
      type: object
      description: What a `*.deleted` delivery carries — the record is gone, so these are the fields that identify your copy of it.
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [ job, contact, task ] }
        deleted: { type: boolean, const: true }
        job_number: { type: string }
        name: { type: string }
        address: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string }
        phone: { type: string }
        title: { type: string }
        job_id: { type: string, format: uuid }
      example:
        id: f2b1a0c4-1111-4000-8000-0000000000aa
        object: job
        deleted: true
        job_number: JOB-00142
        name: 1804 Cedar Ridge Dr
        address: 1804 Cedar Ridge Dr

    PersonRef:
      type: [ object, "null" ]
      properties:
        id: { type: string, format: uuid }
        name: { type: [ string, "null" ] }
        email: { type: [ string, "null" ] }
        phone: { type: [ string, "null" ] }
      example:
        id: b7c2d3e4-4444-4000-8000-0000000000dd
        name: Dana Reed
        email: dana@example.com
        phone: (972) 555-0148

    ListMeta:
      type: object
      properties:
        object: { type: string, const: list }
        has_more: { type: boolean }
        next_cursor: { type: [ string, "null" ], description: Pass back as `?cursor=`. `null` means that was the last page. }
      example:
        object: list
        has_more: true
        next_cursor: eyJjIjoiMjAyNi0wOS0yOFQxNDowMjowMFoiLCJpIjoiZjJiMWEwYzQifQ

    JobList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Job" } }
    ContactList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Contact" } }
    TaskList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Task" } }
    EstimateList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Estimate" } }
    InvoiceList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Invoice" } }
    PaymentList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Payment" } }
    NoteList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Note" } }
    FileList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/File" } }
    PinList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/Pin" } }
    StormListList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/StormList" } }
    StormListPropertyList:
      allOf:
        - $ref: "#/components/schemas/ListMeta"
        - type: object
          properties:
            data: { type: array, items: { $ref: "#/components/schemas/StormListProperty" } }

    # -- Jobs -------------------------------------------------------------------
    Job:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: job }
        job_number: { type: [ string, "null" ], example: JOB-00142 }
        name: { type: [ string, "null" ], example: 1804 Cedar Ridge Dr }
        job_type: { type: string, enum: [ insurance, retail ] }
        stage: { type: [ string, "null" ], description: "The stage key. Filter on this.", example: inspection_scheduled }
        stage_label: { type: [ string, "null" ], description: "The stage as your board names it.", example: Inspection Scheduled }
        stage_is_completed: { type: [ boolean, "null" ], description: The stage is a won / completed column. }
        stage_is_lost: { type: [ boolean, "null" ], description: The stage is a lost column. }
        stage_entered_at: { type: [ string, "null" ], format: date-time }
        pipeline_id: { type: [ string, "null" ], format: uuid }
        pipeline_name: { type: [ string, "null" ], example: Insurance }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ], description: "Two-letter code.", example: TX }
        postal_code: { type: [ string, "null" ] }
        county: { type: [ string, "null" ] }
        homeowner_id: { type: [ string, "null" ], format: uuid }
        homeowner: { $ref: "#/components/schemas/PersonRef" }
        adjuster_id: { type: [ string, "null" ], format: uuid }
        adjuster: { $ref: "#/components/schemas/PersonRef" }
        secondary_contact_ids: { type: array, items: { type: string, format: uuid } }
        secondary_adjuster_ids: { type: array, items: { type: string, format: uuid } }
        assigned_to_id: { type: [ string, "null" ], format: uuid, description: The primary assignee. }
        assigned_to_name: { type: [ string, "null" ] }
        assignee_ids: { type: array, items: { type: string, format: uuid }, description: "Everyone on the job, primary included." }
        assignee_names: { type: array, items: { type: string } }
        insurance_company: { type: [ string, "null" ] }
        claim_number: { type: [ string, "null" ] }
        policy_number: { type: [ string, "null" ] }
        date_of_loss: { type: [ string, "null" ], format: date }
        damage_types: { type: array, items: { type: string } }
        tags: { type: array, items: { type: string } }
        lead_source: { type: [ string, "null" ] }
        priority: { type: [ string, "null" ], enum: [ low, normal, high, urgent, null ] }
        mortgage_company: { type: [ string, "null" ] }
        financing_method: { type: [ string, "null" ], enum: [ cash, check, credit_card, financing, other, null ] }
        rcv_amount: { type: [ number, "null" ], description: Replacement cost value on the claim. }
        acv_amount: { type: [ number, "null" ], description: Actual cash value. }
        deductible: { type: [ number, "null" ] }
        supplements_amount: { type: [ number, "null" ] }
        estimated_amount: { type: [ number, "null" ] }
        final_amount: { type: [ number, "null" ], description: Contract value on a retail job. }
        total_job_value: { type: [ number, "null" ], description: What the job is worth — the same figure the Money tab shows. }
        amount_received: { type: [ number, "null" ], description: Money received on the job so far. }
        balance_due: { type: [ number, "null" ] }
        contract_date: { type: [ string, "null" ], format: date }
        installation_date: { type: [ string, "null" ], format: date }
        archived: { type: boolean }
        completed_at: { type: [ string, "null" ], format: date-time }
        lost_at: { type: [ string, "null" ], format: date-time }
        lost_reason: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri, description: Opens the job in HailMate. }
        portal_url: { type: [ string, "null" ], format: uri, description: The homeowner's job page — the same link "Copy Homeowner Link" gives. }
      example:
        id: f2b1a0c4-1111-4000-8000-0000000000aa
        object: job
        job_number: JOB-00142
        name: 1804 Cedar Ridge Dr
        job_type: insurance
        stage: inspection_scheduled
        stage_label: Inspection Scheduled
        stage_is_completed: false
        stage_is_lost: false
        stage_entered_at: "2026-09-16T14:02:00.000Z"
        pipeline_id: 1cf7d87b-7d99-4ba0-b277-0e88ac127e75
        pipeline_name: Insurance
        address: 1804 Cedar Ridge Dr
        city: Plano
        state: TX
        postal_code: "75024"
        county: Collin
        homeowner_id: b7c2d3e4-4444-4000-8000-0000000000dd
        homeowner: { id: b7c2d3e4-4444-4000-8000-0000000000dd, name: Dana Reed, email: dana@example.com, phone: (972) 555-0148 }
        adjuster_id: null
        adjuster: null
        secondary_contact_ids: []
        secondary_adjuster_ids: []
        assigned_to_id: 5d0e1c2b-7777-4000-8000-0000000000ab
        assigned_to_name: Sam Carter
        assignee_ids: [ 5d0e1c2b-7777-4000-8000-0000000000ab ]
        assignee_names: [ Sam Carter ]
        insurance_company: Example Mutual
        claim_number: CLM-88213
        policy_number: null
        date_of_loss: "2026-09-12"
        damage_types: [ hail ]
        tags: []
        lead_source: Website
        priority: normal
        mortgage_company: null
        financing_method: null
        rcv_amount: 24380.5
        acv_amount: 19240
        deductible: 2500
        supplements_amount: null
        estimated_amount: null
        final_amount: null
        total_job_value: 24380.5
        amount_received: 0
        balance_due: 24380.5
        contract_date: null
        installation_date: null
        archived: false
        completed_at: null
        lost_at: null
        lost_reason: null
        created_at: "2026-09-16T14:02:00.000Z"
        updated_at: "2026-09-16T14:02:00.000Z"
        url: https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa
        portal_url: https://app.hailmate.ai/project/0f0f0f0f-3333-4000-8000-0000000000cc

    JobCreate:
      type: object
      required: [ name, address ]
      properties:
        name: { type: string, maxLength: 200, description: "`job_name` works too." }
        address: { type: string, maxLength: 300, description: "`property_address` works too." }
        city: { type: string }
        state: { type: string, description: A code or a name — "TX" or "Texas". }
        postal_code: { type: string, description: "`zip` works too." }
        county: { type: string }
        job_type: { type: string, enum: [ insurance, retail ], default: insurance }
        stage: { type: string, description: A stage key or label. Defaults to the first stage of the default pipeline. }
        pipeline_id: { type: string, format: uuid }
        homeowner_id: { type: string, format: uuid }
        adjuster_id: { type: string, format: uuid }
        assigned_to: { type: string, format: uuid, description: A teammate's user id from `/users`. }
        lead_source: { type: string }
        priority: { type: string, enum: [ low, normal, high, urgent ] }
        insurance_company: { type: string }
        claim_number: { type: string }
        policy_number: { type: string }
        date_of_loss: { type: string, format: date }
        mortgage_company: { type: string }
        financing_method: { type: string, enum: [ cash, check, credit_card, financing, other ] }
        rcv_amount: { type: number }
        acv_amount: { type: number }
        deductible: { type: number }
        supplements_amount: { type: number }
        estimated_amount: { type: number }
        final_amount: { type: number }
        contract_date: { type: string, format: date }
        installation_date: { type: string, format: date }
        damage_types: { type: array, items: { type: string }, description: Or a comma-separated string. }
        tags: { type: array, items: { type: string }, description: Or a comma-separated string. }
        notes: { type: string, description: Becomes the first note on the job. }

    JobUpdate:
      type: object
      description: Any subset of JobCreate's fields (except `notes`), plus `archived` and `lost_reason`. `null` clears.
      properties:
        name: { type: string }
        address: { type: string }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        postal_code: { type: [ string, "null" ] }
        county: { type: [ string, "null" ] }
        job_type: { type: string, enum: [ insurance, retail ] }
        stage: { type: string }
        pipeline_id: { type: string, format: uuid }
        homeowner_id: { type: [ string, "null" ], format: uuid }
        adjuster_id: { type: [ string, "null" ], format: uuid }
        assigned_to: { type: [ string, "null" ], format: uuid }
        lead_source: { type: [ string, "null" ] }
        priority: { type: [ string, "null" ], enum: [ low, normal, high, urgent, null ] }
        insurance_company: { type: [ string, "null" ] }
        claim_number: { type: [ string, "null" ] }
        policy_number: { type: [ string, "null" ] }
        date_of_loss: { type: [ string, "null" ], format: date }
        mortgage_company: { type: [ string, "null" ] }
        financing_method: { type: [ string, "null" ] }
        rcv_amount: { type: [ number, "null" ] }
        acv_amount: { type: [ number, "null" ] }
        deductible: { type: [ number, "null" ] }
        supplements_amount: { type: [ number, "null" ] }
        estimated_amount: { type: [ number, "null" ] }
        final_amount: { type: [ number, "null" ] }
        contract_date: { type: [ string, "null" ], format: date }
        installation_date: { type: [ string, "null" ], format: date }
        damage_types: { type: [ array, "null" ], items: { type: string } }
        tags: { type: [ array, "null" ], items: { type: string } }
        archived: { type: boolean }
        lost_reason: { type: [ string, "null" ] }

    # -- Contacts ---------------------------------------------------------------
    ContactType:
      type: string
      enum: [ homeowner, commercial, adjuster, contractor, subcontractor, install_crew, inspector, mortgage_company, other ]

    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: contact }
        first_name: { type: [ string, "null" ] }
        last_name: { type: [ string, "null" ] }
        full_name: { type: [ string, "null" ] }
        email: { type: [ string, "null" ] }
        phone: { type: [ string, "null" ], example: (972) 555-0148 }
        company: { type: [ string, "null" ] }
        type: { $ref: "#/components/schemas/ContactType" }
        trades: { type: array, items: { type: string }, description: "For a subcontractor — gutters, siding…" }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        postal_code: { type: [ string, "null" ] }
        notes: { type: [ string, "null" ] }
        claim_number: { type: [ string, "null" ] }
        adjuster_type: { type: [ string, "null" ] }
        adjuster_extension: { type: [ string, "null" ] }
        assigned_to_id: { type: [ string, "null" ], format: uuid }
        assigned_to_name: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
      example:
        id: b7c2d3e4-4444-4000-8000-0000000000dd
        object: contact
        first_name: Dana
        last_name: Reed
        full_name: Dana Reed
        email: dana@example.com
        phone: (972) 555-0148
        company: null
        type: homeowner
        trades: []
        address: 1804 Cedar Ridge Dr
        city: Plano
        state: TX
        postal_code: "75024"
        notes: null
        claim_number: null
        adjuster_type: null
        adjuster_extension: null
        assigned_to_id: null
        assigned_to_name: null
        created_at: "2026-09-16T13:40:00.000Z"
        updated_at: "2026-09-16T13:40:00.000Z"
        url: https://app.hailmate.ai/contact/b7c2d3e4-4444-4000-8000-0000000000dd

    ContactCreate:
      type: object
      description: At least one of a name, email, phone or company.
      properties:
        name: { type: string, description: "A full name, split into first and last when those are not sent." }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string, description: Any format. }
        company: { type: string }
        type: { $ref: "#/components/schemas/ContactType" }
        trades: { type: array, items: { type: string } }
        address: { type: string }
        city: { type: string }
        state: { type: string }
        postal_code: { type: string }
        notes: { type: string }
        claim_number: { type: string }
        adjuster_type: { type: string }
        adjuster_extension: { type: string }
        assigned_to: { type: string, format: uuid }

    ContactUpdate:
      type: object
      description: Any subset of ContactCreate's fields (except `name`). `null` clears.
      properties:
        first_name: { type: [ string, "null" ] }
        last_name: { type: [ string, "null" ] }
        email: { type: [ string, "null" ] }
        phone: { type: [ string, "null" ] }
        company: { type: [ string, "null" ] }
        type: { $ref: "#/components/schemas/ContactType" }
        trades: { type: [ array, "null" ], items: { type: string } }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        postal_code: { type: [ string, "null" ] }
        notes: { type: [ string, "null" ] }
        claim_number: { type: [ string, "null" ] }
        adjuster_type: { type: [ string, "null" ] }
        adjuster_extension: { type: [ string, "null" ] }
        assigned_to: { type: [ string, "null" ], format: uuid }

    # -- Tasks ------------------------------------------------------------------
    Task:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: task }
        title: { type: [ string, "null" ] }
        description: { type: [ string, "null" ] }
        due_date: { type: [ string, "null" ], description: "Wall-clock — the local time the crew reads. No offset.", example: "2026-10-02T15:00:00" }
        end_date: { type: [ string, "null" ], format: date, description: The last day of a task that blocks off several days. }
        duration_minutes: { type: [ integer, "null" ] }
        priority: { type: [ string, "null" ], enum: [ low, normal, high, null ] }
        completed: { type: boolean }
        completed_at: { type: [ string, "null" ], format: date-time }
        is_appointment: { type: boolean }
        appointment_type: { type: [ string, "null" ], enum: [ inspection, adjuster_meeting, build_day, null ] }
        outcome: { type: [ string, "null" ], enum: [ completed, no_show, rescheduled, canceled, null ] }
        customer_reminder: { type: [ string, "null" ], enum: [ none, sms, email, both, null ], description: Whether HailMate reminds the homeowner the day before. }
        reminder_minutes: { type: [ integer, "null" ] }
        job_id: { type: [ string, "null" ], format: uuid }
        contact_id: { type: [ string, "null" ], format: uuid }
        assigned_to_id: { type: [ string, "null" ], format: uuid }
        assigned_to_name: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
      example:
        id: d4e5f6a7-5555-4000-8000-0000000000ee
        object: task
        title: Adjuster meeting — 1804 Cedar Ridge Dr
        description: null
        due_date: "2026-10-02T15:00:00"
        end_date: null
        duration_minutes: 60
        priority: normal
        completed: false
        completed_at: null
        is_appointment: true
        appointment_type: adjuster_meeting
        outcome: null
        customer_reminder: sms
        reminder_minutes: 60
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        contact_id: b7c2d3e4-4444-4000-8000-0000000000dd
        assigned_to_id: 5d0e1c2b-7777-4000-8000-0000000000ab
        assigned_to_name: Sam Carter
        created_at: "2026-09-16T14:10:00.000Z"
        updated_at: "2026-09-16T14:10:00.000Z"
        url: https://app.hailmate.ai/task/d4e5f6a7-5555-4000-8000-0000000000ee

    TaskCreate:
      type: object
      required: [ title ]
      properties:
        title: { type: string, maxLength: 200 }
        description: { type: string }
        due_date: { type: string, description: A date or date-time. Any offset is dropped; the digits are kept. }
        end_date: { type: string, format: date }
        duration_minutes: { type: integer, minimum: 15, maximum: 480 }
        priority: { type: string, enum: [ low, normal, high ] }
        appointment_type: { type: string, enum: [ inspection, adjuster_meeting, build_day ] }
        customer_reminder: { type: string, enum: [ none, sms, email, both ], description: Remind the homeowner the day before. }
        reminder_minutes: { type: integer, enum: [ -1, 0, 5, 10, 15, 30, 60, 120, 1440 ] }
        job_id: { type: string, format: uuid }
        contact_id: { type: string, format: uuid }
        assigned_to: { type: string, format: uuid }
        completed: { type: boolean }

    TaskUpdate:
      type: object
      description: Any subset of TaskCreate's fields, plus `outcome`. `null` clears.
      properties:
        title: { type: string }
        description: { type: [ string, "null" ] }
        due_date: { type: [ string, "null" ] }
        end_date: { type: [ string, "null" ], format: date }
        duration_minutes: { type: [ integer, "null" ] }
        priority: { type: [ string, "null" ] }
        appointment_type: { type: [ string, "null" ] }
        outcome: { type: [ string, "null" ], enum: [ completed, no_show, rescheduled, canceled, null ] }
        customer_reminder: { type: string }
        reminder_minutes: { type: [ integer, "null" ] }
        job_id: { type: [ string, "null" ], format: uuid }
        contact_id: { type: [ string, "null" ], format: uuid }
        assigned_to: { type: [ string, "null" ], format: uuid }
        completed: { type: boolean }

    # -- Estimates and invoices -------------------------------------------------
    Estimate:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: estimate }
        estimate_number: { type: [ string, "null" ], example: EST-0214 }
        title: { type: [ string, "null" ] }
        document_type: { type: [ string, "null" ], enum: [ proposal, estimate, null ] }
        status: { type: [ string, "null" ], enum: [ draft, sent, viewed, signed, expired, declined, null ] }
        signature_status: { type: [ string, "null" ] }
        total: { type: [ number, "null" ], description: "The selected package's price, else the base package. Null — never 0 — when it could not be computed." }
        package_totals: { type: [ object, "null" ], additionalProperties: { type: number }, description: "Every package's price, keyed by package.", example: { good: 18450, better: 21900, best: 26400 } }
        upgrades_total: { type: [ number, "null" ], description: "Optional upgrades, quoted outside the package price." }
        selected_tier: { type: [ string, "null" ], description: The package the homeowner chose. }
        job_id: { type: [ string, "null" ], format: uuid }
        valid_until: { type: [ string, "null" ], format: date-time }
        sent_at: { type: [ string, "null" ], format: date-time }
        first_viewed_at: { type: [ string, "null" ], format: date-time }
        last_viewed_at: { type: [ string, "null" ], format: date-time }
        view_count: { type: integer }
        signed_at: { type: [ string, "null" ], format: date-time }
        signer_name: { type: [ string, "null" ] }
        pdf_url: { type: [ string, "null" ], format: uri }
        signed_pdf_url: { type: [ string, "null" ], format: uri }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
        view_url: { type: [ string, "null" ], format: uri, description: The homeowner's view of the estimate. }
      example:
        id: a41c9e20-2222-4000-8000-0000000000e1
        object: estimate
        estimate_number: EST-0214
        title: Roof replacement — 1804 Cedar Ridge Dr
        document_type: proposal
        status: sent
        signature_status: pending
        total: 21900
        package_totals:
          good: 18450
          better: 21900
          best: 26400
        upgrades_total: 1250
        selected_tier: null
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        valid_until: 2026-10-28T00:00:00.000Z
        sent_at: 2026-09-28T15:10:00.000Z
        first_viewed_at: 2026-09-28T18:42:00.000Z
        last_viewed_at: 2026-09-28T19:05:00.000Z
        view_count: 3
        signed_at: null
        signer_name: null
        pdf_url: https://files.example-cdn.com/estimates/EST-0214.pdf
        signed_pdf_url: null
        created_at: 2026-09-27T21:30:00.000Z
        updated_at: 2026-09-28T19:05:00.000Z
        url: https://app.hailmate.ai/estimates/a41c9e20-2222-4000-8000-0000000000e1
        view_url: https://app.hailmate.ai/estimate/6f1d3b52-9c7e-4a10-8f3b-2d4e5a6b7c8d

    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: invoice }
        invoice_number: { type: [ string, "null" ], example: INV-0311 }
        title: { type: [ string, "null" ] }
        status: { type: string, enum: [ draft, sent, viewed, partially_paid, paid, overdue, void, bad_debt ] }
        purpose: { type: [ string, "null" ], enum: [ claim_scope, deductible, supplement, depreciation, contract, other, null ] }
        subtotal: { type: [ number, "null" ] }
        discount_amount: { type: [ number, "null" ] }
        tax_amount: { type: [ number, "null" ] }
        late_fee_amount: { type: [ number, "null" ] }
        total_amount: { type: number }
        amount_paid: { type: number }
        balance_due: { type: number, description: "`total_amount - amount_paid`, never below zero." }
        part_number: { type: [ integer, "null" ], description: "When billed in parts, which part this is." }
        part_count: { type: [ integer, "null" ] }
        issue_date: { type: [ string, "null" ], format: date }
        due_date: { type: [ string, "null" ], format: date }
        sent_at: { type: [ string, "null" ], format: date-time }
        first_viewed_at: { type: [ string, "null" ], format: date-time }
        paid_at: { type: [ string, "null" ], format: date-time }
        job_id: { type: [ string, "null" ], format: uuid }
        contact_id: { type: [ string, "null" ], format: uuid }
        estimate_id: { type: [ string, "null" ], format: uuid }
        pdf_url: { type: [ string, "null" ], format: uri }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
        payment_url: { type: [ string, "null" ], format: uri, description: The homeowner's pay page — the same link "Copy Pay Link" gives. }
      example:
        id: c90d7b31-3333-4000-8000-0000000000f2
        object: invoice
        invoice_number: INV-0311
        title: Deductible
        status: sent
        purpose: deductible
        subtotal: 2500
        discount_amount: 0
        tax_amount: 0
        late_fee_amount: 0
        total_amount: 2500
        amount_paid: 0
        balance_due: 2500
        part_number: null
        part_count: null
        issue_date: 2026-09-28
        due_date: 2026-10-12
        sent_at: 2026-09-28T16:00:00.000Z
        first_viewed_at: null
        paid_at: null
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        contact_id: b7c2d3e4-4444-4000-8000-0000000000dd
        estimate_id: null
        pdf_url: https://files.example-cdn.com/invoices/INV-0311.pdf
        created_at: 2026-09-28T15:55:00.000Z
        updated_at: 2026-09-28T16:00:00.000Z
        url: https://app.hailmate.ai/jobs/invoice/c90d7b31-3333-4000-8000-0000000000f2
        payment_url: https://app.hailmate.ai/invoice/0b7e2c19-5d4a-4f3e-9a1b-8c6d2e4f1a37

    # -- Payments ---------------------------------------------------------------
    PaymentType:
      type: string
      enum: [ acv, deductible, supplement, depreciation, retail, other ]

    Payment:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: payment }
        job_id: { type: string, format: uuid }
        invoice_id: { type: [ string, "null" ], format: uuid }
        amount: { type: number, description: Negative for a refund. }
        payment_type: { $ref: "#/components/schemas/PaymentType" }
        payer_type: { type: string, enum: [ insurance, homeowner, mortgage_company, other ] }
        payment_method: { type: string, enum: [ check, cash, card, ach, financing, other ] }
        status: { type: string, enum: [ expected, received, sent_to_mortgage, endorsed, deposited ] }
        check_number: { type: [ string, "null" ] }
        date_received: { type: [ string, "null" ], format: date }
        date_deposited: { type: [ string, "null" ], format: date }
        notes: { type: [ string, "null" ] }
        is_refund: { type: boolean }
        refund_of_payment_id: { type: [ string, "null" ], format: uuid }
        online: { type: boolean, description: Paid through HailMate's online payment page. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: [ string, "null" ], format: uri }
      example:
        id: 9fa3e1d2-5555-4000-8000-0000000000c3
        object: payment
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        invoice_id: null
        amount: 13164
        payment_type: acv
        payer_type: insurance
        payment_method: check
        status: received
        check_number: "4471"
        date_received: 2026-09-26
        date_deposited: null
        notes: First ACV check from Example Mutual
        is_refund: false
        refund_of_payment_id: null
        online: false
        created_at: 2026-09-26T20:14:00.000Z
        updated_at: 2026-09-26T20:14:00.000Z
        url: https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa

    PaymentCreate:
      type: object
      required: [ job_id, amount ]
      properties:
        job_id: { type: string, format: uuid }
        amount: { type: number, exclusiveMinimum: 0 }
        invoice_id: { type: string, format: uuid, description: An invoice ON THIS JOB to apply it to. Never guessed. }
        payment_type: { allOf: [ { $ref: "#/components/schemas/PaymentType" } ], default: other }
        payer_type: { type: string, enum: [ insurance, homeowner, mortgage_company, other ], default: homeowner }
        payment_method: { type: string, enum: [ check, cash, card, ach, financing, other ], default: check }
        status: { type: string, enum: [ expected, received, sent_to_mortgage, endorsed, deposited ], default: received }
        check_number: { type: string }
        date_received: { type: string, format: date, description: Defaults to today (US Central). }
        date_deposited: { type: string, format: date }
        notes: { type: string }

    # -- Notes and files --------------------------------------------------------
    Note:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: note }
        job_id: { type: string, format: uuid }
        content: { type: string }
        author_id: { type: [ string, "null" ], format: uuid, description: Null for a note written through the API. }
        author_name: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
      example:
        id: 3c8b1a07-6666-4000-8000-0000000000b4
        object: note
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        content: Adjuster meeting moved to Thursday 10am. Homeowner will be home.
        author_id: 5d0e1c2b-7777-4000-8000-0000000000ab
        author_name: Sam Carter
        created_at: 2026-09-27T14:20:00.000Z
        updated_at: 2026-09-27T14:20:00.000Z
        url: https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa

    NoteCreate:
      type: object
      required: [ job_id, content ]
      properties:
        job_id: { type: string, format: uuid }
        content: { type: string, maxLength: 10000 }

    File:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: file }
        job_id: { type: [ string, "null" ], format: uuid }
        name: { type: string }
        category: { type: string, enum: [ photo, document, scope, estimate, contract, other ] }
        mime_type: { type: [ string, "null" ] }
        is_photo: { type: boolean }
        is_video: { type: boolean }
        size_bytes: { type: [ integer, "null" ] }
        description: { type: [ string, "null" ], description: The caption. }
        tags: { type: array, items: { type: string } }
        latitude: { type: [ number, "null" ], description: Where a photo was taken. }
        longitude: { type: [ number, "null" ] }
        taken_at: { type: [ string, "null" ], format: date-time }
        uploaded_by_id: { type: [ string, "null" ], format: uuid }
        uploaded_by_name: { type: [ string, "null" ] }
        download_url: { type: string, format: uri }
        created_at: { type: string, format: date-time }
        url: { type: [ string, "null" ], format: uri }
      example:
        id: 7e2d9c40-8888-4000-8000-0000000000a5
        object: file
        job_id: f2b1a0c4-1111-4000-8000-0000000000aa
        name: north-slope-hits.jpg
        category: photo
        mime_type: image/jpeg
        is_photo: true
        is_video: false
        size_bytes: 1532410
        description: Hail hits on the north slope, chalked
        tags:
          - damage
          - north slope
        latitude: 33.0726
        longitude: -96.7512
        taken_at: 2026-09-16T15:12:08.000Z
        uploaded_by_id: 5d0e1c2b-7777-4000-8000-0000000000ab
        uploaded_by_name: Sam Carter
        download_url: https://files.example-cdn.com/jobs/f2b1a0c4/north-slope-hits.jpg
        created_at: 2026-09-16T15:12:40.000Z
        url: https://app.hailmate.ai/job/f2b1a0c4-1111-4000-8000-0000000000aa

    FileUploadForm:
      type: object
      required: [ file, job_id ]
      properties:
        file: { type: string, format: binary }
        job_id: { type: string, format: uuid }
        category: { type: string, enum: [ photo, document, scope, estimate, contract, other ] }
        description: { type: string }
        file_name: { type: string }

    FileFromUrl:
      type: object
      required: [ job_id, file_url ]
      properties:
        job_id: { type: string, format: uuid }
        file_url: { type: string, format: uri, description: A public https address. Redirects are followed (up to 3). }
        category: { type: string, enum: [ photo, document, scope, estimate, contract, other ] }
        description: { type: string }
        file_name: { type: string }

    # -- Canvassing -------------------------------------------------------------
    KnockResult:
      type: string
      enum: [ interested, not_interested, contacted, no_answer, door_hanger, follow_up, appointment_scheduled, dont_knock, renter, no_damage, cash_quote, lost ]

    Pin:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: pin }
        pin_type: { type: string, enum: [ knock, inspection, job ] }
        knock_result: { oneOf: [ { $ref: "#/components/schemas/KnockResult" }, { type: "null" } ] }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        postal_code: { type: [ string, "null" ] }
        latitude: { type: number }
        longitude: { type: number }
        homeowner_name: { type: [ string, "null" ] }
        homeowner_first_name: { type: [ string, "null" ] }
        homeowner_last_name: { type: [ string, "null" ] }
        phone: { type: [ string, "null" ] }
        email: { type: [ string, "null" ] }
        notes: { type: [ string, "null" ] }
        job_id: { type: [ string, "null" ], format: uuid, description: Set once the pin became a job. }
        created_by_id: { type: string, format: uuid }
        created_by_name: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        map_url: { type: [ string, "null" ], format: uri }
        url: { type: string, format: uri }
      example:
        id: 2b6f4e18-9999-4000-8000-0000000000d6
        object: pin
        pin_type: knock
        knock_result: interested
        address: 1812 Cedar Ridge Dr
        city: Plano
        state: TX
        postal_code: "75024"
        latitude: 33.0731
        longitude: -96.7519
        homeowner_name: Chris Lane
        homeowner_first_name: Chris
        homeowner_last_name: Lane
        phone: (972) 555-0193
        email: null
        notes: Wants an inspection Saturday morning
        job_id: null
        created_by_id: 5d0e1c2b-7777-4000-8000-0000000000ab
        created_by_name: Sam Carter
        created_at: 2026-09-27T17:45:00.000Z
        updated_at: 2026-09-27T17:45:00.000Z
        map_url: https://www.google.com/maps?q=33.0731,-96.7519
        url: https://app.hailmate.ai/canvassing

    PinCreate:
      type: object
      description: "Needs `latitude` and `longitude`, or an `address` to place it from."
      properties:
        latitude: { type: number, minimum: -90, maximum: 90 }
        longitude: { type: number, minimum: -180, maximum: 180 }
        address: { type: string }
        city: { type: string }
        state: { type: string, description: A code or a name — "TX" or "Texas". }
        postal_code: { type: string }
        knock_result: { $ref: "#/components/schemas/KnockResult" }
        pin_type: { type: string, enum: [ knock, inspection ], default: knock }
        homeowner_name: { type: string, description: Split into first and last for you. }
        homeowner_first_name: { type: string }
        homeowner_last_name: { type: string }
        phone: { type: string }
        email: { type: string, format: email }
        notes: { type: string }
        created_by_id: { type: string, format: uuid, description: The teammate who knocked. Defaults to whoever connected the integration. }

    PinUpdate:
      type: object
      properties:
        knock_result: { oneOf: [ { $ref: "#/components/schemas/KnockResult" }, { type: "null" } ] }
        pin_type: { type: string, enum: [ knock, inspection ] }
        latitude: { type: number }
        longitude: { type: number }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        postal_code: { type: [ string, "null" ] }
        homeowner_name: { type: string }
        homeowner_first_name: { type: [ string, "null" ] }
        homeowner_last_name: { type: [ string, "null" ] }
        phone: { type: [ string, "null" ] }
        email: { type: [ string, "null" ], format: email }
        notes: { type: [ string, "null" ] }

    # -- Writing invoices and estimates -------------------------------------------
    LineItemInput:
      type: object
      required: [ name ]
      properties:
        name: { type: string }
        description: { type: string }
        quantity: { type: number, minimum: 0, default: 1 }
        unit: { type: string, description: "sq, lf, ea…" }
        unit_price: { type: number, description: "Required on an invoice line. Negative on an invoice is a credit." }
        taxable: { type: boolean, default: true, description: Invoices only. }

    InvoiceCreate:
      type: object
      required: [ line_items ]
      description: Needs a `job_id` or a `contact_id`.
      properties:
        job_id: { type: string, format: uuid }
        contact_id: { type: string, format: uuid, description: Who it bills. }
        estimate_id: { type: string, format: uuid }
        title: { type: string }
        purpose: { type: string, enum: [ claim_scope, deductible, supplement, depreciation, contract, other ] }
        issue_date: { type: string, format: date, description: Defaults to today. }
        due_date: { type: string, format: date }
        line_items: { type: array, minItems: 1, maxItems: 200, items: { $ref: "#/components/schemas/LineItemInput" } }
        tax_rate: { type: number, minimum: 0, maximum: 100, description: A percent, on the taxable lines. }
        discount_type: { type: string, enum: [ amount, percent ] }
        discount_value: { type: number, minimum: 0 }
        notes: { type: string }
        terms: { type: string }
        allow_partial_payments: { type: boolean }

    InvoiceUpdate:
      type: object
      properties:
        contact_id: { type: [ string, "null" ], format: uuid }
        estimate_id: { type: [ string, "null" ], format: uuid }
        title: { type: [ string, "null" ] }
        purpose: { type: [ string, "null" ], enum: [ claim_scope, deductible, supplement, depreciation, contract, other, null ] }
        issue_date: { type: [ string, "null" ], format: date }
        due_date: { type: [ string, "null" ], format: date }
        line_items: { type: array, minItems: 1, maxItems: 200, items: { $ref: "#/components/schemas/LineItemInput" }, description: Replaces every line. }
        tax_rate: { type: number, minimum: 0, maximum: 100 }
        discount_type: { type: string, enum: [ amount, percent ] }
        discount_value: { type: [ number, "null" ], minimum: 0, description: "`0` or `null` takes the discount off." }
        notes: { type: [ string, "null" ] }
        terms: { type: [ string, "null" ] }
        allow_partial_payments: { type: boolean }

    EstimateCreate:
      type: object
      required: [ job_id ]
      properties:
        job_id: { type: string, format: uuid }
        template_id: { type: string, format: uuid, description: One of your templates. Leave out for your standard proposal. }
        title: { type: string }
        price: { type: number, exclusiveMinimum: 0, description: One price for the whole job. }
        valid_until: { type: string, format: date, description: Defaults to your workspace's setting. }
        line_items: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/LineItemInput" }, description: "The Scope of Work, without a template. Leave out for the standard checklist." }

    EstimateUpdate:
      type: object
      properties:
        title: { type: [ string, "null" ] }
        price: { type: [ number, "null" ], exclusiveMinimum: 0, description: "`null` takes the stated price off." }
        valid_until: { type: [ string, "null" ], format: date }

    EstimateTemplate:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: estimate_template }
        name: { type: string }
        template_number: { type: [ string, "null" ] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: 7a1c9e02-2222-4000-8000-0000000000c3
        object: estimate_template
        name: Insurance roof replacement
        template_number: TPL-0003
        created_at: 2026-08-30T15:00:00.000Z
        updated_at: 2026-09-20T18:30:00.000Z

    EstimateTemplateList:
      type: object
      properties:
        object: { type: string, const: list }
        data: { type: array, items: { $ref: "#/components/schemas/EstimateTemplate" } }
        has_more: { type: boolean, const: false }
        next_cursor: { type: "null" }
      example:
        object: list
        data:
          - id: 7a1c9e02-2222-4000-8000-0000000000c3
            object: estimate_template
            name: Insurance roof replacement
            template_number: TPL-0003
            created_at: 2026-08-30T15:00:00.000Z
            updated_at: 2026-09-20T18:30:00.000Z
        has_more: false
        next_cursor: null

    # -- OAuth ------------------------------------------------------------------
    OAuthTokenRequest:
      type: object
      required: [ grant_type, client_id, client_secret ]
      properties:
        grant_type: { type: string, enum: [ authorization_code, refresh_token ] }
        code: { type: string, description: "For `authorization_code`." }
        redirect_uri: { type: string, format: uri, description: "For `authorization_code` — the same address the person was sent back to." }
        refresh_token: { type: string, description: "For `refresh_token`." }
        client_id: { type: string }
        client_secret: { type: string }

    OAuthRevokeRequest:
      type: object
      required: [ token, client_id, client_secret ]
      properties:
        token: { type: string }
        client_id: { type: string }
        client_secret: { type: string }

    OAuthToken:
      type: object
      properties:
        access_token: { type: string, description: "`hm_oat_…` — send as `Authorization: Bearer`." }
        token_type: { type: string, const: Bearer }
        expires_in: { type: integer, description: Seconds — an hour. }
        refresh_token: { type: string, description: "`hm_ort_…` — keep it secret; it does not change." }
        scope: { type: string, enum: [ read, write ] }
        workspace_id: { type: string, format: uuid }
        workspace: { type: [ string, "null" ], description: The company that was connected. }
      example:
        access_token: hm_oat_1q2w3e4r5t6y7u8i9o0p1a2s3d4f5g6h7j8k9l0z
        token_type: Bearer
        expires_in: 3600
        refresh_token: hm_ort_8d2k6m1q9x4w7c3t0r5p6n2j8h1g4f7d3s9a2l6e
        scope: write
        workspace_id: 5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10
        workspace: Ridgeline Roofing

    OAuthError:
      type: object
      properties:
        error: { type: string, enum: [ invalid_request, invalid_client, invalid_grant, unsupported_grant_type, server_error ] }
        error_description: { type: string }
      example:
        error: invalid_grant
        error_description: That code has expired. Start the connection again.

    # -- Storm lists ------------------------------------------------------------
    StormList:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: storm_list }
        name: { type: [ string, "null" ] }
        status: { type: [ string, "null" ], enum: [ building, ready, partial, failed, null ] }
        source: { type: [ string, "null" ], enum: [ manual, auto, null ] }
        window_from: { type: [ string, "null" ], format: date }
        window_to: { type: [ string, "null" ], format: date }
        min_size_in: { type: [ number, "null" ] }
        area_kind: { type: [ string, "null" ] }
        territory_id: { type: [ string, "null" ], format: uuid }
        clipped_area_sq_mi: { type: [ number, "null" ] }
        property_cap: { type: [ integer, "null" ] }
        property_count: { type: [ integer, "null" ] }
        skipped_duplicates: { type: [ integer, "null" ] }
        dedupe_days: { type: [ integer, "null" ] }
        credits_spent: { type: [ integer, "null" ] }
        total_available: { type: [ integer, "null" ] }
        storm_date: { type: [ string, "null" ], format: date }
        built_at: { type: [ string, "null" ], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        url: { type: string, format: uri }
      example:
        id: e5a1c7d9-aaaa-4000-8000-0000000000e7
        object: storm_list
        name: Plano 1.25″+ — Sep 12
        status: ready
        source: manual
        window_from: 2026-09-12
        window_to: 2026-09-12
        min_size_in: 1.25
        area_kind: drawn
        territory_id: null
        clipped_area_sq_mi: 4.2
        property_cap: 1000
        property_count: 412
        skipped_duplicates: 18
        dedupe_days: 90
        credits_spent: 412
        total_available: 430
        storm_date: 2026-09-12
        built_at: 2026-09-13T13:04:00.000Z
        created_at: 2026-09-13T13:02:00.000Z
        updated_at: 2026-09-13T13:04:00.000Z
        url: https://app.hailmate.ai/canvassing/lists/e5a1c7d9-aaaa-4000-8000-0000000000e7

    StormListProperty:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: storm_list_property }
        storm_list_id: { type: string, format: uuid }
        address: { type: [ string, "null" ] }
        city: { type: [ string, "null" ] }
        state: { type: [ string, "null" ] }
        zip: { type: [ string, "null" ] }
        owner_name: { type: [ string, "null" ] }
        owner_occupied: { type: [ boolean, "null" ], description: Null when the county record did not say. }
        mailing_address: { type: [ string, "null" ] }
        mailing_city: { type: [ string, "null" ] }
        mailing_state: { type: [ string, "null" ] }
        mailing_zip: { type: [ string, "null" ] }
        mail_deliverable: { type: boolean, description: Our judgement — NOT a CASS or NCOA result. }
        mail_exclude_reason: { type: [ string, "null" ] }
        hail_size_in: { type: [ number, "null" ] }
        hail_event_date: { type: [ string, "null" ], format: date }
        nearest_report_mi: { type: [ number, "null" ] }
        nearest_report_size_in: { type: [ number, "null" ] }
        year_built: { type: [ integer, "null" ] }
        property_use: { type: [ string, "null" ] }
        is_residential: { type: [ boolean, "null" ] }
        latitude: { type: [ number, "null" ] }
        longitude: { type: [ number, "null" ] }
        created_at: { type: string, format: date-time }
      example:
        id: d4b3a2c1-bbbb-4000-8000-0000000000f8
        object: storm_list_property
        storm_list_id: e5a1c7d9-aaaa-4000-8000-0000000000e7
        address: 1804 Cedar Ridge Dr
        city: Plano
        state: TX
        zip: "75024"
        owner_name: Dana Reed
        owner_occupied: true
        mailing_address: 1804 Cedar Ridge Dr
        mailing_city: Plano
        mailing_state: TX
        mailing_zip: "75024"
        mail_deliverable: true
        mail_exclude_reason: null
        hail_size_in: 1.5
        hail_event_date: 2026-09-12
        nearest_report_mi: 1.8
        nearest_report_size_in: 1.75
        year_built: 2004
        property_use: Single family
        is_residential: true
        latitude: 33.0726
        longitude: -96.7512
        created_at: 2026-09-13T13:04:00.000Z

    # -- Hail -------------------------------------------------------------------
    HailEvent:
      type: object
      properties:
        date: { type: string, format: date }
        size_in: { type: [ number, "null" ], description: The size HailMate stands behind for this day at this address. }
        confidence: { type: [ string, "null" ], enum: [ confirmed, likely, radar, null ] }
        confirmed_by_ground_report: { type: boolean }
        radar_estimated_size_in: { type: [ number, "null" ] }
        severe_hail_probability: { type: [ integer, "null" ], description: Percent. }
        ground_report_size_in: { type: [ number, "null" ] }
        ground_report_distance_mi: { type: [ number, "null" ] }
        ground_report_count: { type: integer }
      example:
        date: 2026-09-12
        size_in: 1.5
        confidence: confirmed
        confirmed_by_ground_report: true
        radar_estimated_size_in: 1.5
        severe_hail_probability: 80
        ground_report_size_in: 1.75
        ground_report_distance_mi: 1.8
        ground_report_count: 3

    HailReport:
      type: object
      properties:
        object: { type: string, const: hail_report }
        address: { type: [ string, "null" ], description: The address as it was found. }
        latitude: { type: number }
        longitude: { type: number }
        since: { type: string, format: date }
        hail_day_count: { type: integer }
        largest_hail_in: { type: [ number, "null" ] }
        last_hail_date: { type: [ string, "null" ], format: date }
        hail_events: { type: array, items: { $ref: "#/components/schemas/HailEvent" } }
        wind_events:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              max_gust_mph: { type: [ number, "null" ], description: Null when the report was damage with no measured gust. }
      example:
        object: hail_report
        address: 1804 Cedar Ridge Dr, Plano, TX 75024, USA
        latitude: 33.0198
        longitude: -96.6989
        since: "2021-09-28"
        hail_day_count: 2
        largest_hail_in: 1.5
        last_hail_date: "2025-03-25"
        hail_events:
          - { date: "2025-03-25", size_in: 1.5, confidence: confirmed, confirmed_by_ground_report: true, radar_estimated_size_in: null, severe_hail_probability: null, ground_report_size_in: 1.5, ground_report_distance_mi: 1.4, ground_report_count: 11 }
          - { date: "2024-05-28", size_in: 1.5, confidence: likely, confirmed_by_ground_report: false, radar_estimated_size_in: 1.5, severe_hail_probability: null, ground_report_size_in: 1.25, ground_report_distance_mi: 9, ground_report_count: 1 }
        wind_events:
          - { date: "2025-03-04", max_gust_mph: 90 }

    # -- Lookups ----------------------------------------------------------------
    PipelineStage:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: pipeline_stage }
        pipeline_id: { type: string, format: uuid }
        key: { type: string, description: "What you send and filter on.", example: claim_approved }
        label: { type: string, description: "What to show a person.", example: Claim Approved }
        order: { type: integer }
        is_completed: { type: boolean }
        is_lost: { type: boolean }
      example:
        id: 8a7b6c5d-cccc-4000-8000-000000000011
        object: pipeline_stage
        pipeline_id: 1cf7d87b-7d99-4ba0-b277-0e88ac127e75
        key: claim_approved
        label: Claim Approved
        order: 6
        is_completed: false
        is_lost: false

    Pipeline:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: pipeline }
        name: { type: string }
        is_default: { type: boolean }
        order: { type: [ integer, "null" ] }
        stages: { type: array, items: { $ref: "#/components/schemas/PipelineStage" } }
      example:
        id: 1cf7d87b-7d99-4ba0-b277-0e88ac127e75
        object: pipeline
        name: Insurance
        is_default: true
        order: 0
        stages:
          - id: 8a7b6c5d-cccc-4000-8000-000000000010
            object: pipeline_stage
            pipeline_id: 1cf7d87b-7d99-4ba0-b277-0e88ac127e75
            key: lead
            label: Lead
            order: 0
            is_completed: false
            is_lost: false
          - id: 8a7b6c5d-cccc-4000-8000-000000000011
            object: pipeline_stage
            pipeline_id: 1cf7d87b-7d99-4ba0-b277-0e88ac127e75
            key: claim_approved
            label: Claim Approved
            order: 6
            is_completed: false
            is_lost: false

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: user }
        name: { type: [ string, "null" ] }
        email: { type: [ string, "null" ] }
        role: { type: string, enum: [ owner, admin, member ] }
      example:
        id: 5d0e1c2b-7777-4000-8000-0000000000ab
        object: user
        name: Sam Carter
        email: sam@ridgelineroofing.example
        role: member

    EventDefinition:
      type: object
      properties:
        event: { type: string, example: job.stage_changed }
        resource: { type: string, example: job }
        label: { type: string, example: Job Stage Changed }
        description: { type: string }
        filters:
          type: array
          items:
            type: object
            properties:
              key: { type: string, example: stage }
              label: { type: string }
              kind: { type: string, enum: [ stage, user, pipeline, enum, boolean ] }
              values: { type: array, items: { type: string } }
      example:
        event: job.stage_changed
        resource: job
        label: Job Stage Changed
        description: A job moved to a different stage on the board.
        filters:
          - key: stage
            label: Stage
            kind: stage
          - key: previous_stage
            label: Previous stage
            kind: stage
          - key: job_type
            label: Job type
            kind: enum
            values:
              - insurance
              - retail
          - key: pipeline_id
            label: Pipeline
            kind: pipeline

    # -- Webhooks ---------------------------------------------------------------
    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, const: webhook }
        event: { type: string, description: 'An event name, or "*" for every event.' }
        target_url: { type: string, format: uri }
        description: { type: [ string, "null" ] }
        filters: { type: [ object, "null" ], additionalProperties: { type: string } }
        created_at: { type: string, format: date-time }
        disabled_at: { type: [ string, "null" ], format: date-time }
      example:
        id: 0f8c8c1b-dddd-4000-8000-000000000022
        object: webhook
        event: job.stage_changed
        target_url: https://hooks.zapier.com/hooks/standard/1234567/abcdef/
        description: "Slack: approved claims"
        filters:
          stage: claim_approved
        created_at: 2026-09-20T18:30:00.000Z
        disabled_at: null

    WebhookCreated:
      allOf:
        - $ref: "#/components/schemas/Webhook"
        - type: object
          properties:
            secret: { type: string, description: "The signing secret. Returned ONCE.", example: whsec_3a2b1c5f… }

    WebhookCreate:
      type: object
      required: [ target_url ]
      properties:
        event: { type: string, description: 'One event, or "*".' }
        events: { type: array, items: { type: string }, description: Several events for one URL. }
        target_url: { type: string, format: uri, description: "`targetUrl` works too (Zapier's spelling)." }
        description: { type: string }
        filters: { type: object, additionalProperties: { type: string }, description: Only with a single event. See GET /events. }

    EventEnvelope:
      type: object
      description: >
        Every delivery is the record's own fields at the top level plus these. Dedupe on `event_id`: a retry or a resend carries the same one.
      properties:
        event_type: { type: string, example: job.stage_changed }
        event_id: { type: string, format: uuid, description: Also the X-HailMate-Delivery header. }
        event_at: { type: string, format: date-time, description: When the change happened. }
        event_workspace_id: { type: string, format: uuid }
        event_test: { type: boolean, description: Present and true on a test send from Settings. }
        previous_stage: { type: [ string, "null" ], description: "`job.stage_changed` only." }
        previous_stage_label: { type: [ string, "null" ], description: "`job.stage_changed` only." }
        changed_fields: { type: array, items: { type: string }, description: "`*.updated` only — the public field names that changed." }
        previous_assigned_to_id: { type: [ string, "null" ], description: "`job.assigned` only." }
        previous_knock_result: { type: [ string, "null" ], description: "`pin.result_changed` only." }
      example:
        event_type: job.stage_changed
        event_id: 2cb27b8c-4e1f-4a3b-9d2c-6f7e8a9b0c1d
        event_at: 2026-09-28T19:42:57.388Z
        event_workspace_id: 5f0c2a8e-1b3d-4c7a-9e21-7a3b8d4c6e10
        previous_stage: inspection_complete
        previous_stage_label: Inspection Complete
