# PreArrive Public REST API — OpenAPI 3.1 spec (source of truth).
#
# Scope: this file is the canonical contract for /v1. Handlers in
# functions/api/public/v1/ MUST match it; build/lib/api-drift-guard.ts
# checks both directions at build time (no handler without a spec
# entry, no spec entry without a handler).
#
# Current state: v1 contract for properties, packets, reservations,
# certificates, sync sources, SMS message logs, and webhook endpoints.
# api-drift-guard checks the OpenAPI paths against functions/v1 on every
# deploy so marketing, docs, and handlers do not drift quietly.
#
# Editor: any YAML editor works; for live validation use
# https://editor.swagger.io/ or `npx @stoplight/spectral-cli lint`.

openapi: "3.1.0"

info:
  title: PreArrive REST API
  version: "1.0.0"
  summary: Server-to-server REST API for PreArrive (Pro plan).
  description: |
    The PreArrive REST API lets Pro-tier customers integrate the
    signed-acknowledgment workflow into their own dashboards and
    automation. Push reservations, pull certificates, react to events.

    **Authentication** is by API key (bearer token). Issue keys at
    `/app/settings/api`. Keys are team-scoped — they grant the same
    access the team owner has, optionally narrowed by scopes.

    **Versioning** is stable for v1. Breaking changes ship in v2;
    v1 continues to serve for at least 18 months after v2 lands.
  contact:
    name: PreArrive Support
    url: https://prearrive.com/contact/
  license:
    name: Proprietary

servers:
  - url: https://api.prearrive.com/v1
    description: Production (live keys + live data)

security:
  - bearerAuth: []

x-prearrive-recipes:
  arrival_release_control:
    title: Arrival release control
    description: |
      Use this recipe when a PMS, guidebook, smart-lock workflow tool,
      or internal dashboard owns the actual check-in details but wants
      PreArrive to be the signed acknowledgment gate before those
      details are released.
    state_machine:
      not_started: No arrival-release timestamp has been recorded yet.
      not_configured: The packet has no arrival details to release.
      not_held: Arrival details exist, but populated groups are visible before signing.
      ready_to_hold: At least one populated group is configured after signing and the guest has not opened the packet yet.
      held: The guest opened the unsigned packet and selected details were withheld.
      released: The guest signed, or a manual signature override released the details.
      viewed: The signed guest revisited the link and viewed released arrival details.
      override_released: A host released details early with an override reason.
    events:
      - reservation.arrival_details_held
      - reservation.arrival_details_released
      - reservation.arrival_details_viewed
      - reservation.arrival_details_override_released
    steps:
      - Read or update the property packet with `GET /v1/properties/{id}/packet` and `PATCH /v1/properties/{id}/packet`.
      - Set `packet.arrival_release` groups such as `wifi`, `door_code`, `checkin_notes`, or `parking_notes` to `after_signing` when they should be withheld.
      - Create and send the reservation with `POST /v1/reservations` and `POST /v1/reservations/{id}/send`.
      - Subscribe a webhook endpoint to `reservation.arrival_details_released`; include `reservation.arrival_details_held`, `reservation.arrival_details_viewed`, and `reservation.arrival_details_override_released` when the partner system needs audit parity.
      - Verify the `PreArrive-Signature` header before trusting the event body.
      - Release only partner-owned details, such as a guidebook section, check-in message, or smart-lock code, after `reservation.arrival_details_released`.
      - Preserve override reasons from `reservation.arrival_details_override_released` in the partner audit log.
      - Keep emergency and safety details visible in the partner product even before signature.
      - Read `GET /v1/reservations/{id}/events` when support needs the ordered release timeline.

tags:
  - name: properties
    description: Listings (one row per place you rent out).
  - name: packets
    description: The rules + fees + check-in info shown to guests for a property.
  - name: reservations
    description: Per-guest signing events.
  - name: certificates
    description: Signed PDFs produced when a guest acknowledges a packet.
  - name: sync_sources
    description: iCal feeds and forward-email enrollments that auto-create reservations.
  - name: sms_messages
    description: Read-only SMS delivery log.
  - name: webhook_endpoints
    description: Outbound webhooks for reservation lifecycle events.

# ---------------------------------------------------------------- paths
paths:
  /properties:
    get:
      summary: List properties
      operationId: listProperties
      tags: [properties]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - name: subdomain
          in: query
          description: Filter to a single property by its vanity subdomain.
          schema: { type: string }
      responses:
        "200":
          description: Paginated list of properties.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Property" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create a property
      operationId: createProperty
      tags: [properties]
      x-stability: stable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Property" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /properties/{id}:
    get:
      summary: Retrieve a property
      operationId: getProperty
      tags: [properties]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
      responses:
        "200":
          description: The property.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Property" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      summary: Update a property
      operationId: updateProperty
      tags: [properties]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PropertyUpdate" }
      responses:
        "200":
          description: Updated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Property" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      summary: Delete a property
      operationId: deleteProperty
      tags: [properties]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
        - name: force
          in: query
          description: If true, cascade-delete even when active reservations exist.
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: Deleted.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The property has active reservations; pass `?force=true` to cascade.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations:
    get:
      summary: List reservations
      operationId: listReservations
      tags: [reservations]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
        - name: status
          in: query
          schema: { type: string, enum: [draft, synced, sent, opened, clicked, signed, expired, ignored] }
        - name: property
          in: query
          description: Filter to a single property (prop_… id).
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string, enum: [airbnb, vrbo, booking, direct] }
        - name: checkin_date.gte
          in: query
          schema: { type: string, format: date }
        - name: checkin_date.lte
          in: query
          schema: { type: string, format: date }
        - name: created_at.gte
          in: query
          schema: { type: string, format: date-time }
        - name: created_at.lte
          in: query
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Reservation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create a reservation (does not send)
      operationId: createReservation
      tags: [reservations]
      x-stability: stable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ReservationCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Reservation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}:
    get:
      summary: Retrieve a reservation
      operationId: getReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Reservation" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      summary: Update a draft / synced reservation
      operationId: updateReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/ReservationUpdate" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Reservation" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      summary: Delete a draft reservation
      operationId: deleteReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}/send:
    post:
      summary: Mint a signing token + email the guest
      operationId: sendReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200":
          description: Sent (or already sent — idempotent).
          content: { application/json: { schema: { $ref: "#/components/schemas/Reservation" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}/resend:
    post:
      summary: Issue a fresh signing token + re-email the guest
      operationId: resendReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200":
          description: Resent.
          content: { application/json: { schema: { $ref: "#/components/schemas/Reservation" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}/cancel:
    post:
      summary: Cancel a reservation
      operationId: cancelReservation
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200":
          description: Cancelled.
          content: { application/json: { schema: { $ref: "#/components/schemas/Reservation" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}/email_preview:
    get:
      summary: Render the email body for a test-mode reservation
      operationId: getReservationEmailPreview
      tags: [reservations]
      x-stability: stable
      description: |
        Only available for reservations created with a test key.
        Returns the rendered email subject + HTML + text body that
        `POST /send` would have dispatched in live mode. Stashed in
        a short-lived KV slot with a 24-hour TTL — fetch within
        24h of the /send call.
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200":
          description: The rendered email body.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:         { type: string, const: email_preview }
                  reservation_id: { type: string }
                  rendered_at:    { type: string, format: date-time }
                  would_send_to:  { type: string, format: email }
                  from:           { type: string, format: email }
                  subject:        { type: string }
                  html:           { type: string }
                  text:           { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /reservations/{id}/events:
    get:
      summary: Lifecycle audit-trail for one reservation
      operationId: listReservationEvents
      tags: [reservations]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/ReservationId" }]
      responses:
        "200":
          description: Ordered list of lifecycle events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:         { type: string, const: list }
                  reservation_id: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        type: { type: string }
                        at:   { type: string, format: date-time }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /certificates:
    get:
      summary: List certificates (one per signed reservation)
      operationId: listCertificates
      tags: [certificates]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - $ref: "#/components/parameters/EndingBefore"
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Certificate" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /certificates/{id}:
    get:
      summary: Retrieve a certificate (with audit-trail JSON)
      operationId: getCertificate
      tags: [certificates]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/CertificateId" }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/Certificate" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /certificates/{id}/download:
    get:
      summary: 302 to the PDF download URL
      operationId: downloadCertificate
      tags: [certificates]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/CertificateId" }]
      responses:
        "302":
          description: Redirect to the public verify-token PDF URL.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: Certificate was redacted and is no longer downloadable.
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /sync_sources:
    get:
      summary: List sync sources
      operationId: listSyncSources
      tags: [sync_sources]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/SyncSource" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /sync_sources/{id}:
    get:
      summary: Retrieve a sync source
      operationId: getSyncSource
      tags: [sync_sources]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/SyncSourceId" }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/SyncSource" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /sync_sources/{id}/run:
    post:
      summary: Force an iCal sync now
      operationId: runSyncSource
      tags: [sync_sources]
      x-stability: stable
      parameters: [{ $ref: "#/components/parameters/SyncSourceId" }]
      responses:
        "200":
          description: Sync complete; returns the source with updated last_status.
          content: { application/json: { schema: { $ref: "#/components/schemas/SyncSource" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /sms_messages:
    get:
      summary: List SMS messages (read-only)
      operationId: listSmsMessages
      tags: [sms_messages]
      x-stability: beta
      parameters:
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list (empty until SMS ships).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/SmsMessage" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /sms_messages/{id}:
    get:
      summary: Retrieve an SMS message
      operationId: getSmsMessage
      tags: [sms_messages]
      x-stability: beta
      parameters: [{ $ref: "#/components/parameters/SmsMessageId" }]
      responses:
        "200":
          description: OK
          content: { application/json: { schema: { $ref: "#/components/schemas/SmsMessage" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_endpoints:
    get:
      summary: List webhook endpoints
      operationId: listWebhookEndpoints
      tags: [webhook_endpoints]
      x-stability: beta
      parameters: [{ $ref: "#/components/parameters/Limit" }]
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookEndpoint" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      summary: Create a webhook endpoint
      operationId: createWebhookEndpoint
      tags: [webhook_endpoints]
      x-stability: beta
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreate" }
      responses:
        "201":
          description: |
            Created. Response includes the plaintext signing_secret —
            store it now, it will not be shown again.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_endpoints/{id}:
    get:
      summary: Retrieve a webhook endpoint
      operationId: getWebhookEndpoint
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      summary: Update a webhook endpoint
      operationId: updateWebhookEndpoint
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:    { type: string, format: uri }
                events: { type: array, items: { type: string } }
                status: { type: string, enum: [enabled, disabled] }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      summary: Delete a webhook endpoint
      operationId: deleteWebhookEndpoint
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_endpoints/{id}/deliveries:
    get:
      summary: List delivery attempts for an endpoint
      operationId: listWebhookDeliveries
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ListEnvelope"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookDelivery" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_endpoints/{id}/deliveries/{evt_id}:
    get:
      summary: Retrieve one delivery attempt
      operationId: getWebhookDelivery
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: evt_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/WebhookDelivery" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /webhook_endpoints/{id}/deliveries/{evt_id}/redeliver:
    post:
      summary: Re-attempt a delivery
      operationId: redeliverWebhook
      tags: [webhook_endpoints]
      x-stability: beta
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: evt_id
          in: path
          required: true
          schema: { type: string }
      responses:
        "202":
          description: Queued; the scheduler will fire on the next tick.
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookDelivery" } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /properties/{id}/packet:
    get:
      summary: Retrieve the packet for a property
      operationId: getPacket
      tags: [packets]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
      responses:
        "200":
          description: The packet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Packet" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      summary: Replace the packet for a property
      operationId: replacePacket
      tags: [packets]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Packet" }
      responses:
        "200":
          description: The updated packet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Packet" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      summary: Partially update the packet for a property
      operationId: updatePacket
      tags: [packets]
      x-stability: stable
      parameters:
        - $ref: "#/components/parameters/PropertyId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Packet" }
      responses:
        "200":
          description: The updated packet.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Packet" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/RateLimited" }

# ----------------------------------------------------------- components
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Send your API key as a Bearer token:

            Authorization: Bearer prearrive_live_sk_4Xz9...

  parameters:
    Limit:
      name: limit
      in: query
      description: Page size. 1–100. Defaults to 25.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    StartingAfter:
      name: starting_after
      in: query
      description: |
        Cursor for forward pagination. Pass the `id` of the last
        item from the previous page to fetch the next page.
      schema: { type: string }
    EndingBefore:
      name: ending_before
      in: query
      description: |
        Cursor for backward pagination. Pass the `id` of the first
        item from the current page to fetch the previous page.
      schema: { type: string }
    PropertyId:
      name: id
      in: path
      required: true
      description: A property id, prefixed with `prop_`.
      schema: { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
    ReservationId:
      name: id
      in: path
      required: true
      description: A reservation id, prefixed with `res_`.
      schema: { type: string, pattern: "^res_[A-Za-z0-9_-]{8,}$" }
    SyncSourceId:
      name: id
      in: path
      required: true
      description: A sync-source id, prefixed with `ss_`.
      schema: { type: string, pattern: "^ss_[A-Za-z0-9_-]{8,}$" }
    CertificateId:
      name: id
      in: path
      required: true
      description: A certificate id, prefixed with `sig_`.
      schema: { type: string, pattern: "^sig_[A-Za-z0-9_-]{8,}$" }
    SmsMessageId:
      name: id
      in: path
      required: true
      description: An SMS-message id.
      schema: { type: string }

  responses:
    BadRequest:
      description: A required field was missing or a field value was rejected.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: API key missing, malformed, or rejected.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: Key valid but missing the required scope.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Resource doesn't exist or isn't owned by this team.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Conflict:
      description: |
        Uniqueness violation (e.g. duplicate subdomain), ETag mismatch,
        or business-rule conflict.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    PaymentRequired:
      description: |
        Team isn't Pro, or a plan cap was hit on a write
        (e.g. creating a 26th property without an add-on).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: |
        Rate limit hit. The `Retry-After` header tells you how
        many seconds to wait before retrying.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    # ------------------------------------------------------------- meta
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, code, message]
          properties:
            type:    { type: string, enum: [invalid_request_error, authentication_error,
              permission_error, not_found_error, conflict_error, idempotency_error,
              rate_limit_error, plan_limit_error, api_error] }
            code:    { type: string, example: missing_required_field }
            message: { type: string, example: "Field `guest_email` is required." }
            param:   { type: string, nullable: true }
            doc_url: { type: string, format: uri, nullable: true }
            request_id: { type: string, nullable: true }

    ListEnvelope:
      type: object
      required: [object, data, has_more, url]
      properties:
        object:   { type: string, const: list }
        has_more: { type: boolean }
        url:      { type: string, example: /v1/properties }
        data:     { type: array, items: {} }

    # -------------------------------------------------------- resources
    Property:
      type: object
      required: [id, object, name, created_at, updated_at]
      properties:
        id:            { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
        object:        { type: string, const: property }
        name:          { type: string }
        address:       { type: string, nullable: true }
        brand_color:   { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
        logo_url:      { type: string, nullable: true, format: uri }
        subdomain:     { type: string, nullable: true }
        custom_domain: { type: string, nullable: true }
        timezone:      { type: string, nullable: true }
        auto_send_packet:       { type: boolean }
        auto_send_offset_hours: { type: integer, nullable: true, minimum: 1, maximum: 336 }
        auto_reminders:         { type: boolean }
        created_at:    { type: string, format: date-time }
        updated_at:    { type: string, format: date-time }

    PropertyCreate:
      type: object
      required: [name]
      properties:
        name:          { type: string, maxLength: 120 }
        address:       { type: string, nullable: true, maxLength: 240 }
        brand_color:   { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
        logo_url:      { type: string, nullable: true, format: uri }
        subdomain:     { type: string, nullable: true, pattern: "^[a-z][a-z0-9-]{2,29}$" }
        custom_domain: { type: string, nullable: true }
        auto_send_packet:       { type: boolean, default: false }
        auto_send_offset_hours: { type: integer, nullable: true, minimum: 1, maximum: 336 }
        auto_reminders:         { type: boolean, default: false }
        lat:           { type: number, nullable: true }
        lng:           { type: number, nullable: true }
        city:          { type: string, nullable: true, maxLength: 80 }

    PropertyUpdate:
      type: object
      description: All fields optional; only present fields are updated.
      properties:
        name:          { type: string, maxLength: 120 }
        address:       { type: string, nullable: true, maxLength: 240 }
        brand_color:   { type: string, nullable: true, pattern: "^#[0-9a-fA-F]{6}$" }
        logo_url:      { type: string, nullable: true, format: uri }
        subdomain:     { type: string, nullable: true, pattern: "^[a-z][a-z0-9-]{2,29}$" }
        custom_domain: { type: string, nullable: true }
        auto_send_packet:       { type: boolean }
        auto_send_offset_hours: { type: integer, nullable: true, minimum: 1, maximum: 336 }
        auto_reminders:         { type: boolean }
        lat:           { type: number, nullable: true }
        lng:           { type: number, nullable: true }
        city:          { type: string, nullable: true, maxLength: 80 }

    Reservation:
      type: object
      required: [id, object, property_id, guest_name, guest_email, checkin_date, checkout_date, source, status, created_at, updated_at]
      properties:
        id:               { type: string, pattern: "^res_[A-Za-z0-9_-]{8,}$" }
        object:           { type: string, const: reservation }
        property_id:      { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
        guest_name:       { type: string }
        guest_email:      { type: string, format: email }
        checkin_date:     { type: string, format: date }
        checkout_date:    { type: string, format: date }
        source:           { type: string, enum: [airbnb, vrbo, booking, direct] }
        status:           { type: string, enum: [draft, synced, sent, opened, clicked, signed, expired, ignored] }
        sent_at:          { type: string, nullable: true, format: date-time }
        opened_at:        { type: string, nullable: true, format: date-time }
        clicked_at:       { type: string, nullable: true, format: date-time }
        signed_at:        { type: string, nullable: true, format: date-time }
        signature_id:     { type: string, nullable: true, pattern: "^sig_[A-Za-z0-9_-]{8,}$" }
        manual_override:  { type: boolean }
        redacted:         { type: boolean }
        external_source:  { type: string, nullable: true }
        external_id:      { type: string, nullable: true }
        reminder_24h_sent_at: { type: string, nullable: true, format: date-time }
        reminder_2h_sent_at:  { type: string, nullable: true, format: date-time }
        late_notified_at:     { type: string, nullable: true, format: date-time }
        arrival_release_state: { $ref: "#/components/schemas/ArrivalReleaseState" }
        arrival_release:       { $ref: "#/components/schemas/ReservationArrivalRelease" }
        arrival_details_held_at:     { type: string, nullable: true, format: date-time }
        arrival_details_released_at: { type: string, nullable: true, format: date-time }
        arrival_details_viewed_at:   { type: string, nullable: true, format: date-time }
        arrival_release_override_at:     { type: string, nullable: true, format: date-time }
        arrival_release_override_by:     { type: string, nullable: true }
        arrival_release_override_reason: { type: string, nullable: true }
        created_at:       { type: string, format: date-time }
        updated_at:       { type: string, format: date-time }

    ReservationCreate:
      type: object
      required: [property_id, guest_name, guest_email, checkin_date, checkout_date]
      properties:
        property_id:    { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
        guest_name:     { type: string, maxLength: 120 }
        guest_email:    { type: string, format: email }
        checkin_date:   { type: string, format: date }
        checkout_date:  { type: string, format: date }
        source:         { type: string, enum: [airbnb, vrbo, booking, direct], default: direct }

    ReservationUpdate:
      type: object
      description: All fields optional; only draft/synced reservations can be updated.
      properties:
        guest_name:    { type: string, maxLength: 120 }
        guest_email:   { type: string, format: email }
        checkin_date:  { type: string, format: date }
        checkout_date: { type: string, format: date }

    ArrivalReleaseState:
      type: string
      enum:
        - not_started
        - not_configured
        - not_held
        - ready_to_hold
        - held
        - released
        - viewed
        - override_released

    ReservationArrivalRelease:
      type: object
      description: Computed state for the arrival-detail release lifecycle.
      properties:
        state:           { $ref: "#/components/schemas/ArrivalReleaseState" }
        state_label:     { type: string }
        held_at:         { type: string, nullable: true, format: date-time }
        released_at:     { type: string, nullable: true, format: date-time }
        viewed_at:       { type: string, nullable: true, format: date-time }
        override_at:     { type: string, nullable: true, format: date-time }
        override_by:     { type: string, nullable: true }
        override_reason: { type: string, nullable: true }
        next_events:
          type: array
          items: { type: string }

    Certificate:
      type: object
      required: [id, object, reservation_id, signed_at]
      properties:
        id:             { type: string, pattern: "^sig_[A-Za-z0-9_-]{8,}$" }
        object:         { type: string, const: certificate }
        reservation_id: { type: string, pattern: "^res_[A-Za-z0-9_-]{8,}$" }
        signed_at:      { type: string, format: date-time }
        content_hash:   { type: string, nullable: true, description: SHA-256 of the packet snapshot the guest signed. }
        verify_token:   { type: string, nullable: true }
        pdf_available:  { type: boolean }
        redacted:       { type: boolean }
        legal_hold:     { type: boolean }
        verify_url:     { type: string, nullable: true, format: uri }

    SyncSource:
      type: object
      required: [id, object, kind, property_id]
      properties:
        id:             { type: string, pattern: "^ss_[A-Za-z0-9_-]{8,}$" }
        object:         { type: string, const: sync_source }
        kind:           { type: string, enum: [airbnb_ical, vrbo_ical, booking_ical, forward_email] }
        property_id:    { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
        feed_url:       { type: string, nullable: true, format: uri }
        enabled:        { type: boolean }
        last_synced_at: { type: string, nullable: true, format: date-time }
        last_status:    { type: string, nullable: true }
        last_error:     { type: string, nullable: true }
        created_at:     { type: string, format: date-time }
        updated_at:     { type: string, format: date-time }

    SmsMessage:
      type: object
      required: [id, object]
      properties:
        id:             { type: string }
        object:         { type: string, const: sms_message }
        reservation_id: { type: string, nullable: true }
        to:             { type: string, nullable: true }
        body:           { type: string, nullable: true }
        status:         { type: string, nullable: true }
        delivered_at:   { type: string, nullable: true, format: date-time }
        created_at:     { type: string, format: date-time }

    WebhookEndpoint:
      type: object
      required: [id, object, url, events, status]
      properties:
        id:                   { type: string }
        object:               { type: string, const: webhook_endpoint }
        url:                  { type: string, format: uri }
        events:
          type: array
          items: { type: string }
          description: Either a list of event types or `["*"]` for all.
        status:               { type: string, enum: [enabled, disabled, auto_disabled] }
        mode:                 { type: string, enum: [live, test] }
        signing_secret_last4: { type: string }
        consecutive_failures: { type: integer }
        last_delivery_at:     { type: string, nullable: true, format: date-time }
        last_success_at:      { type: string, nullable: true, format: date-time }
        rotated_until:        { type: string, nullable: true, format: date-time }
        created_at:           { type: string, format: date-time }
        updated_at:           { type: string, format: date-time }

    WebhookEndpointCreate:
      type: object
      required: [url]
      properties:
        url:    { type: string, format: uri, description: Must be https://. }
        events: { type: array, items: { type: string }, default: ["*"] }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [signing_secret]
          properties:
            signing_secret:
              type: string
              description: |
                Plaintext signing secret. Use this to verify the
                `PreArrive-Signature` header on incoming webhook
                deliveries. Shown ONLY at create time.
            signing_secret_warning:
              type: string

    WebhookDelivery:
      type: object
      required: [id, object, event_id, endpoint_id, event_type, attempt_number, status, created_at]
      properties:
        id:                       { type: string }
        object:                   { type: string, const: webhook_delivery }
        event_id:                 { type: string }
        endpoint_id:              { type: string }
        event_type:               { type: string }
        attempt_number:           { type: integer }
        status:                   { type: string, enum: [pending, succeeded, failed] }
        status_code:              { type: integer, nullable: true }
        response_body_truncated:  { type: string, nullable: true }
        duration_ms:              { type: integer, nullable: true }
        next_retry_at:            { type: string, nullable: true, format: date-time }
        delivered_at:             { type: string, nullable: true, format: date-time }
        created_at:               { type: string, format: date-time }

    Packet:
      type: object
      properties:
        id:               { type: string, pattern: "^pkt_[A-Za-z0-9_-]{8,}$" }
        object:           { type: string, const: packet }
        property_id:      { type: string, pattern: "^prop_[A-Za-z0-9_-]{8,}$" }
        welcome_message:  { type: string, nullable: true }
        rules:
          type: array
          description: |
            Array of rules the guest acknowledges. ARRAY-REPLACE on PATCH —
            to append a rule, GET, append client-side, PATCH back.
          items:
            type: object
            properties:
              id:       { type: string }
              text:     { type: string, maxLength: 500 }
              category: { type: string, nullable: true }
        fees:
          type: array
          description: Array of fees the guest acknowledges. Array-replace semantics.
          items:
            type: object
            properties:
              id:           { type: string }
              label:        { type: string, maxLength: 120 }
              amount_cents: { type: integer, minimum: 0 }
              unit:         { type: string, nullable: true }
              description:  { type: string, nullable: true, maxLength: 500 }
        wifi_ssid:        { type: string, nullable: true }
        wifi_password:    { type: string, nullable: true }
        door_code:        { type: string, nullable: true }
        checkin_notes:    { type: string, nullable: true }
        parking_notes:    { type: string, nullable: true }
        checkout_notes:   { type: string, nullable: true }
        local_notes:      { type: string, nullable: true }
        safety_notes:
          type: string
          nullable: true
          description: Safety or emergency information shown before and after signing.
        arrival_release:
          type: object
          description: Per-field release timing for arrival details.
          properties:
            wifi:           { type: string, enum: [before_signing, after_signing] }
            door_code:      { type: string, enum: [before_signing, after_signing] }
            checkin_notes:  { type: string, enum: [before_signing, after_signing] }
            parking_notes:  { type: string, enum: [before_signing, after_signing] }
            checkout_notes: { type: string, enum: [before_signing, after_signing] }
            local_notes:    { type: string, enum: [before_signing, after_signing] }
        version:          { type: integer }
        updated_at:       { type: string, format: date-time }
