openapi: 3.1.0

# Source of truth for the Reepli public REST API v1.
#
# This file is not documentation garnish: the Zapier app, the Make module set,
# the n8n node and the planned MCP server are all meant to be generated from it
# rather than hand-written. `npm run check-openapi` fails CI when a route exists
# under app/api/v1 without a matching entry here (or vice versa), so the two
# cannot drift.
#
# Prose lives in docs/api/rest-v1.md.

info:
  title: Reepli REST API
  version: "1.0.0"
  description: >
    Tenant-scoped, key-authenticated API over Reepli's CRM, conversations and
    bookings. The API key determines the tenant; there is no tenant parameter
    anywhere in this specification.
  contact:
    name: Reepli
    url: https://dashboard.reepli.ai

servers:
  - url: https://api.reepli.ai/v1
    description: Canonical base URL
  - url: https://dashboard.reepli.ai/api/v1
    description: Alias reaching the same handlers

security:
  - ApiKeyAuth: []

tags:
  - name: Account
  - name: Contacts
  - name: Conversations
  - name: Bookings
  - name: Events
  - name: Configuration

paths:
  /me:
    get:
      tags: [Account]
      operationId: getMe
      summary: Validate a key and describe what it can do
      responses:
        "200":
          description: Account information
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /contacts:
    get:
      tags: [Contacts]
      operationId: listContacts
      summary: List contacts, newest first
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: lead_status, in: query, schema: { $ref: "#/components/schemas/LeadStatus" } }
        - { name: lead_source, in: query, schema: { type: string } }
        - { name: created_after, in: query, schema: { type: string, format: date-time } }
        - { name: phone_number, in: query, schema: { type: string } }
        - { name: email, in: query, schema: { type: string } }
        - { name: q, in: query, description: Matches the contact name, schema: { type: string } }
      responses:
        "200":
          description: A page of contacts
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Contact" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Contacts]
      operationId: createContact
      summary: Create a contact
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 2 }
                phone_number: { type: string, description: E.164, example: "+33612345678" }
                email: { type: string, format: email }
                custom_attributes: { type: object, additionalProperties: true }
      responses:
        "201":
          description: The created contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /contacts/{id}:
    parameters:
      - $ref: "#/components/parameters/ContactId"
    get:
      tags: [Contacts]
      operationId: getContact
      summary: Fetch one contact
      responses:
        "200":
          description: The contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Contacts]
      operationId: updateContact
      summary: Update a contact
      description: >
        Only the fields present are touched. custom_attributes is MERGED, not
        replaced, so patching one key leaves the rest — including the bot's
        `qualification` block — intact.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 2 }
                phone_number: { type: [string, "null"] }
                email: { type: [string, "null"] }
                custom_attributes: { type: object, additionalProperties: true }
      responses:
        "200":
          description: The updated contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Contact" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /contacts/{id}/notes:
    parameters:
      - $ref: "#/components/parameters/ContactId"
    post:
      tags: [Contacts]
      operationId: addContactNote
      summary: Append a note to the lead timeline
      description: The note is attributed to the calling key's label.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties:
                message: { type: string, minLength: 1 }
      responses:
        "201":
          description: The created note
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { const: note }
                  id: { type: string }
                  contact_id: { type: string, format: uuid }
                  message: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /contacts/{id}/lead-stage:
    parameters:
      - $ref: "#/components/parameters/ContactId"
    post:
      tags: [Contacts]
      operationId: changeLeadStage
      summary: Move a lead through the pipeline
      description: >
        A refused transition returns 200 with changed=false and a
        skipped_reason, not an error: the request was well formed and retrying
        would not change the outcome.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { $ref: "#/components/schemas/LeadStatus" }
                reason: { type: string }
                allow_reopen_terminal:
                  type: boolean
                  default: false
                  description: >
                    Allows moving a won/lost lead back into the funnel. Off by
                    default so an integration cannot reopen closed leads in a loop.
      responses:
        "200":
          description: Outcome of the transition
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { const: lead_stage_change }
                  contact_id: { type: string, format: uuid }
                  changed: { type: boolean }
                  from_status: { type: [string, "null"] }
                  to_status: { type: string }
                  skipped_reason: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations:
    get:
      tags: [Conversations]
      operationId: listConversations
      summary: List conversations, newest first
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: contact_id, in: query, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, enum: [open, resolved] } }
        - { name: channel, in: query, schema: { type: string, enum: [whatsapp, instagram] } }
      responses:
        "200":
          description: A page of conversations
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Conversation" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations/{id}:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
    get:
      tags: [Conversations]
      operationId: getConversation
      summary: Fetch one conversation
      responses:
        "200":
          description: The conversation
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /conversations/{id}/messages:
    parameters:
      - $ref: "#/components/parameters/ConversationId"
    get:
      tags: [Conversations]
      operationId: listMessages
      summary: Read a conversation thread, newest first
      description: >
        Read only. This API cannot send messages — see "What this API
        deliberately does not do" in docs/api/rest-v1.md.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
      responses:
        "200":
          description: A page of messages
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Message" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /bookings:
    get:
      tags: [Bookings]
      operationId: listBookings
      summary: List bookings, newest first
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: from, in: query, schema: { type: string, format: date } }
        - { name: to, in: query, schema: { type: string, format: date } }
        - { name: status, in: query, schema: { $ref: "#/components/schemas/BookingStatus" } }
        - { name: contact_id, in: query, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: A page of bookings
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Booking" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Bookings]
      operationId: createBooking
      summary: Create a booking
      description: >
        Syncs to the connected calendar and notifies the operator, exactly as a
        booking taken by the assistant does.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [date, start_time, end_time]
              properties:
                date: { type: string, format: date }
                start_time: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" }
                end_time: { type: string, pattern: "^([01][0-9]|2[0-3]):[0-5][0-9]$" }
                contact_id:
                  type: string
                  format: uuid
                  description: Required unless contact_phone is given.
                contact_phone: { type: string, description: E.164 }
                contact_name: { type: string }
                service: { type: string }
                notes: { type: string }
                location_type: { type: string }
                customer_email: { type: string, format: email }
                status: { type: string, enum: [confirmed, pending], default: confirmed }
      responses:
        "201":
          description: The created booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /bookings/{id}:
    parameters:
      - $ref: "#/components/parameters/BookingId"
    get:
      tags: [Bookings]
      operationId: getBooking
      summary: Fetch one booking
      responses:
        "200":
          description: The booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /bookings/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/BookingId"
    post:
      tags: [Bookings]
      operationId: cancelBooking
      summary: Cancel a booking
      description: >
        Cancelling an already-cancelled booking returns 200 with the current
        state, so the call is safe to retry without an Idempotency-Key.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, description: Appended to the booking notes. }
      responses:
        "200":
          description: The cancelled booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /events:
    get:
      tags: [Events]
      operationId: listEvents
      summary: Replay emitted events, newest first
      description: >
        Same envelope and same id as the webhook delivery of the event, so one
        handler can serve both transports and dedupe on event.id. 30-day retention.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: type, in: query, schema: { type: string, example: contact.created } }
      responses:
        "200":
          description: A page of events
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - type: object
                    properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Event" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ── Configuration (read) ────────────────────────────────────────────────
  # Scope `config:read`, granted per key from the dashboard and part of no
  # preset. These lists are bounded (a tenant has a handful of services, not
  # thousands), so they use the list envelope with `has_more: false` rather
  # than a cursor.

  /config:
    get:
      tags: [Configuration]
      operationId: getConfig
      summary: Business identity, practical details and booking defaults
      responses:
        "200":
          description: The configuration overview
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfigOverview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /config/services:
    get:
      tags: [Configuration]
      operationId: listConfigServices
      summary: The services the assistant can offer and book
      responses:
        "200":
          description: Services, normalised to the current shape
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Service" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /config/faq:
    get:
      tags: [Configuration]
      operationId: listConfigFaq
      summary: The FAQ the assistant grounds its answers on
      responses:
        "200":
          description: FAQ entries
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - properties:
                      data: { type: array, items: { $ref: "#/components/schemas/FaqEntry" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /config/scenarios:
    get:
      tags: [Configuration]
      operationId: listConfigScenarios
      summary: Which automated scenarios are switched on
      description: >
        One entry per customer-facing scenario, always all of them. `key` is
        the stable identifier; `label` is the operator-facing French name and
        must never be used as one.
      responses:
        "200":
          description: Scenario toggles
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - properties:
                      data: { type: array, items: { $ref: "#/components/schemas/Scenario" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /config/availability:
    get:
      tags: [Configuration]
      operationId: listConfigAvailability
      summary: Weekly opening hours used for booking
      responses:
        "200":
          description: One entry per configured weekday, Sunday = 0
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/List"
                  - properties:
                      data: { type: array, items: { $ref: "#/components/schemas/AvailabilityDay" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /config/assistant:
    get:
      tags: [Configuration]
      operationId: getAssistantBehaviour
      summary: The operator's standing instructions and rules for the assistant
      responses:
        "200":
          description: Behaviour settings
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AssistantBehaviour" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: >
        An API key created in Settings -> Integrations. The key determines the
        tenant; no request field can influence which tenant is read or written.

  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    StartingAfter:
      name: starting_after
      in: query
      description: >
        The id of the last object of the previous page. An unknown cursor is a
        400, not an empty page.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >
        Replays the original response for an identical retry within 24h. Send one
        if your platform retries on timeouts.
      schema: { type: string, maxLength: 255 }
    ContactId:
      name: id
      in: path
      required: true
      description: The contact's public_id.
      schema: { type: string, format: uuid }
    ConversationId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    BookingId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: Malformed request
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Missing, invalid or revoked API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Plan gate, or the key lacks the required scope
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: >
        No such object. Objects owned by another tenant answer here too — a 403
        would confirm the id exists.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: Idempotency key reused with a different body, or a booking slot clash
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Window exhausted
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    ConfigOverview:
      type: object
      properties:
        object: { const: config }
        business:
          type: object
          properties:
            name: { type: string, nullable: true }
            type: { type: string, nullable: true, description: Google Places primary type, e.g. hair_salon }
            description: { type: string, nullable: true }
            website_url: { type: string, nullable: true }
        practical_info:
          type: object
          properties:
            address: { type: string, nullable: true }
            phone: { type: string, nullable: true }
            email: { type: string, nullable: true }
            opening_hours: { type: string, nullable: true }
            payment_methods: { type: string, nullable: true }
        booking:
          type: object
          properties:
            default_appointment_duration_minutes: { type: integer, nullable: true }
            calendar_provider: { type: string, nullable: true }
            calendar_link: { type: string, nullable: true }
        updated_at: { type: string, format: date-time }

    Service:
      type: object
      properties:
        object: { const: service }
        id: { type: string, description: Stable slug }
        name: { type: string }
        description: { type: string }
        kind: { type: string, enum: [appointment, product] }
        duration_minutes: { type: integer }
        location_type: { type: string }
        price_mode: { type: string }
        price_amount: { type: number, nullable: true }
        eligible_resource_ids: { type: array, items: { type: string, format: uuid } }
        active: { type: boolean }
        media_url: { type: string }
        media_kind: { type: string, enum: [image, document] }

    FaqEntry:
      type: object
      properties:
        object: { const: faq_entry }
        question: { type: string }
        answer: { type: string }

    Scenario:
      type: object
      properties:
        object: { const: scenario }
        key:
          type: string
          enum: [qualify_lead, book_appointment, reminder_24h, reminder_2h, service_followup, noshow_followup, dormant_followup, loyalty_anniversary, pre_close_nudge]
        label: { type: string, description: Operator-facing French label. Display only. }
        enabled: { type: boolean }

    AvailabilityDay:
      type: object
      properties:
        object: { const: availability_day }
        day_of_week: { type: integer, minimum: 0, maximum: 6, description: 0 = Sunday }
        start_time: { type: string, example: "09:00:00" }
        end_time: { type: string, example: "18:00:00" }
        # Pas de slot_duration_minutes : la colonne a été supprimée par la
        # migration 099, la durée est une propriété du service. Voir
        # GET /config/services.
        break_between_minutes: { type: integer }
        active: { type: boolean }

    AssistantBehaviour:
      type: object
      properties:
        object: { const: assistant_behaviour }
        free_instructions: { type: string }
        rules: { type: array, items: { type: string } }

    Error:
      type: object
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - idempotency_error
                - rate_limit_error
                - api_error
            code:
              type: string
              description: Stable per API version. Branch on this.
            message: { type: string, description: English, developer-facing. }
            param: { type: string }

    List:
      type: object
      properties:
        object: { const: list }
        data: { type: array, items: { type: object } }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }

    Account:
      type: object
      properties:
        object: { const: account }
        tenant_id: { type: string, format: uuid }
        plan: { type: string, enum: [trial, subscribed], description: Subscription state, not a tier. }
        scopes: { type: array, items: { type: string } }
        api_version: { const: v1 }

    LeadStatus:
      type: string
      enum: [new, qualified, followed_up, won, lost]

    BookingStatus:
      type: string
      enum: [pending, confirmed, cancelled, completed, noshow]

    Contact:
      type: object
      description: >
        The object Reepli webhooks deliver, minus the internal `id`. Webhook
        payloads still carry that field for backward compatibility; the API
        never has. `public_id` is present in both and is the only identifier
        any endpoint accepts.
      properties:
        object: { const: contact }
        public_id: { type: [string, "null"], format: uuid }
        name: { type: [string, "null"] }
        preferred_name: { type: [string, "null"] }
        phone_number: { type: [string, "null"] }
        email: { type: [string, "null"] }
        lead_status: { type: [string, "null"] }
        custom_attributes: { type: object, additionalProperties: true }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    Conversation:
      type: object
      properties:
        object: { const: conversation }
        id: { type: [string, "null"], format: uuid }
        contact_id: { type: [string, "null"], format: uuid }
        status: { type: [string, "null"] }
        channel: { type: [string, "null"] }
        unread_count: { type: integer }
        last_message_at: { type: [string, "null"], format: date-time }
        last_message_preview: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    Message:
      type: object
      properties:
        object: { const: message }
        id: { type: string, description: Stringified integer. }
        conversation_id: { type: string, format: uuid }
        direction: { type: string, enum: [inbound, outbound, activity] }
        content: { type: [string, "null"] }
        sender_type: { type: [string, "null"] }
        sender_name: { type: [string, "null"] }
        delivery_status: { type: [string, "null"] }
        channel: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }

    Booking:
      type: object
      properties:
        object: { const: booking }
        id: { type: string, format: uuid }
        contact_id: { type: [string, "null"], format: uuid }
        contact_name: { type: [string, "null"] }
        contact_phone: { type: [string, "null"] }
        date: { type: [string, "null"], format: date }
        start_time: { type: [string, "null"], description: "HH:MM" }
        end_time: { type: [string, "null"], description: "HH:MM" }
        service: { type: [string, "null"] }
        status: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        location_type: { type: [string, "null"] }
        customer_email: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    Event:
      type: object
      properties:
        object: { const: event }
        id: { type: string, example: evt_3F9KQX7M2B0WYH5J8N1DA4TCRZ }
        type: { type: string }
        version: { type: string, example: "2026-06" }
        created: { type: string, format: date-time }
        data: { type: object, additionalProperties: true }
        previous_attributes: { type: object, additionalProperties: true }
