openapi: 3.1.0
info:
  title: UnitBook Listings API
  version: 1.0.0
  description: |
    Publish and maintain a firm's UnitBook listings from a server-side system.

    Create credentials in **Settings → API**. Send the key as a Bearer token;
    keys are secrets and must not be exposed in browser or mobile application code.

    Each firm has one active API key. Replacing that key invalidates the old key
    immediately but preserves every `external_id` mapping and existing listing.

    Availability and publication are separate. Setting `availability_status` to
    `rented_out` or `unavailable` always changes `publication_status` to `hidden` in
    the same transaction. A later update only republishes the listing if it explicitly
    includes `publication_status: published`.

    Every new or changed address is a complete structured object. UnitBook verifies
    it as a precise premises address with Google, then stores and returns Google's
    formatted address, place ID, and coordinates. Unit-only and status-only changes
    do not trigger another Google request.
servers:
  - url: https://api.unitbook.app/v1
    description: UnitBook production
security:
  - bearerAuth: []
tags:
  - name: Account
  - name: Listings
paths:
  /me:
    get:
      tags: [Account]
      operationId: getApiIdentity
      summary: Verify the credential and its firm
      responses:
        "200":
          description: Credential details
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [firm, scopes, rate_limit]
                    properties:
                      firm:
                        type: object
                        required: [name, type]
                        properties:
                          name: { type: string }
                          type: { type: [string, "null"] }
                      scopes:
                        type: array
                        items: { $ref: "#/components/schemas/Scope" }
                      rate_limit:
                        type: object
                        required: [requests, window_seconds]
                        properties:
                          requests: { type: integer, example: 300 }
                          window_seconds: { type: integer, example: 60 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /listings:
    get:
      tags: [Listings]
      operationId: listListings
      summary: List listings owned by this firm's API connection
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: Opaque cursor returned by the preceding page.
          schema: { type: string }
      responses:
        "200":
          description: A page of listings, newest updates first
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Listing" }
                  next_cursor: { type: [string, "null"] }
                  has_more: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /listings/{external_id}:
    parameters:
      - $ref: "#/components/parameters/ExternalId"
    get:
      tags: [Listings]
      operationId: getListing
      summary: Get one listing
      responses:
        "200":
          description: Listing
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Listing" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    put:
      tags: [Listings]
      operationId: putListing
      summary: Create or replace a listing
      description: |
        The recommended sync endpoint. The first request creates the listing; later
        requests replace the supplied inventory fields. A complete structured
        `address`, `unit`, and `bedrooms` are required. UnitBook rejects addresses
        Google cannot match to a precise premises. Omitted nullable fields are cleared, while an omitted
        status is preserved to avoid accidentally changing publication.

        Supplying `source_updated_at` makes delivery order safe: older updates are
        acknowledged without changing the listing, identical retries are idempotent,
        and two different payloads for the same timestamp return `409`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ListingPut" }
            example:
              address:
                line_1: 74 Webster Avenue
                city: Jersey City
                state: NJ
                postal_code: "07307"
                country: US
              unit: 3B
              bedrooms: 2
              bathrooms: 1
              monthly_rent: 2450
              available_on: "2026-09-01"
              availability_status: available
              publication_status: published
              source_updated_at: "2026-08-18T15:30:00Z"
      responses:
        "200": { $ref: "#/components/responses/Upserted" }
        "201": { $ref: "#/components/responses/Upserted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    patch:
      tags: [Listings]
      operationId: patchListing
      summary: Update selected listing fields or statuses
      description: |
        Only fields present in the request are changed; `null` clears a nullable field.
        This endpoint covers availability and publication status updates, so a separate
        status endpoint is unnecessary. If `address` is present, it must be complete
        and is verified through Google. Address-free updates do not call Google.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ListingPatch" }
            example:
              availability_status: rented_out
              source_updated_at: "2026-08-18T16:00:00Z"
      responses:
        "200": { $ref: "#/components/responses/Upserted" }
        "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" }
        "422": { $ref: "#/components/responses/Unprocessable" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    delete:
      tags: [Listings]
      operationId: deleteListing
      summary: Delist a listing
      description: |
        Reversibly changes publication to `hidden`; it does not erase the UnitBook
        record. The operation is idempotent and remains available when paid writes
        are blocked so stale inventory can always be removed. Republish later with
        an explicit PATCH setting `publication_status` to `published`.
      responses:
        "204": { description: Listing is hidden, or was already absent/hidden }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: UnitBook API key
  parameters:
    ExternalId:
      name: external_id
      in: path
      required: true
      description: Stable identifier from your system, unique within this firm's API connection.
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
        example: pmg-74-webster-3b
  schemas:
    Scope:
      type: string
      enum: ["listings:read", "listings:write", "listings:publish"]
    AvailabilityStatus:
      type: string
      enum:
        - vacant
        - available
        - listing_shown
        - showing_scheduled
        - application_pending
        - rented_out
        - unavailable
    PublicationStatus:
      type: string
      enum: [draft, published, hidden]
    AddressInput:
      type: object
      additionalProperties: false
      required: [line_1, city, state, postal_code, country]
      properties:
        line_1: { type: string, minLength: 1, maxLength: 240 }
        city: { type: string, minLength: 1, maxLength: 120 }
        state: { type: string, minLength: 1, maxLength: 80 }
        postal_code: { type: string, minLength: 1, maxLength: 20 }
        country:
          type: string
          pattern: "^[A-Za-z]{2}$"
          description: Two-letter ISO 3166-1 country code.
    VerifiedAddress:
      type: object
      additionalProperties: false
      required:
        - line_1
        - city
        - state
        - postal_code
        - country
        - formatted_address
        - google_place_id
        - latitude
        - longitude
        - verified
      properties:
        line_1: { type: string }
        city: { type: string }
        state: { type: string }
        postal_code: { type: string }
        country: { type: string }
        formatted_address: { type: string }
        google_place_id: { type: string }
        latitude: { type: number }
        longitude: { type: number }
        verified: { type: boolean, const: true }
    ListingFields:
      type: object
      properties:
        address: { $ref: "#/components/schemas/AddressInput" }
        unit: { type: string, maxLength: 80 }
        bedrooms: { type: number, minimum: 0, maximum: 20 }
        bathrooms: { type: [number, "null"], minimum: 0, maximum: 20 }
        monthly_rent: { type: [number, "null"], minimum: 0, maximum: 1000000 }
        voucher_requirement:
          type: [string, "null"]
          enum: [NHA, DCA, NHA_OR_DCA, null]
        available_on:
          type: [string, "null"]
          format: date
        availability_status: { $ref: "#/components/schemas/AvailabilityStatus" }
        publication_status: { $ref: "#/components/schemas/PublicationStatus" }
        description: { type: [string, "null"], maxLength: 5000 }
        neighborhood: { type: [string, "null"], maxLength: 160 }
        source_updated_at:
          type: string
          format: date-time
          description: Monotonic source modification time used for ordered, idempotent delivery.
    ListingPut:
      allOf:
        - $ref: "#/components/schemas/ListingFields"
        - type: object
          required: [address, unit, bedrooms]
      unevaluatedProperties: false
    ListingPatch:
      allOf:
        - $ref: "#/components/schemas/ListingFields"
        - type: object
          minProperties: 1
      unevaluatedProperties: false
    Listing:
      allOf:
        - $ref: "#/components/schemas/ListingFields"
        - type: object
          required:
            - external_id
            - address
            - unit
            - bedrooms
            - availability_status
            - publication_status
            - created_at
            - updated_at
          properties:
            address: { $ref: "#/components/schemas/VerifiedAddress" }
            external_id: { type: string }
            published_at: { type: [string, "null"], format: date-time }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }
    UpsertMeta:
      type: object
      required: [created]
      properties:
        created: { type: boolean }
        replayed: { type: boolean }
        stale_ignored: { type: boolean }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string }
            message: { type: string }
            details: { type: object, additionalProperties: true }
            request_id: { type: string, example: req_6cb570da1b6b747ae190ef8f }
  responses:
    Upserted:
      description: Listing accepted
      content:
        application/json:
          schema:
            type: object
            required: [data, meta]
            properties:
              data: { $ref: "#/components/schemas/Listing" }
              meta: { $ref: "#/components/schemas/UpsertMeta" }
    BadRequest:
      description: Invalid JSON, field, query, or content type
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing, invalid, expired, or revoked credential
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Missing scope or firm is not currently writable
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: No listing with this external ID belongs to this firm's API connection
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Conflict:
      description: Source version, archived listing, or lifecycle state conflict
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unprocessable:
      description: Address is not Google-verified, listing cannot publish, or a plan limit is exceeded
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Rate limit exceeded; retry after the number of seconds in Retry-After
      headers:
        Retry-After:
          schema: { type: integer }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    ServiceUnavailable:
      description: Google address verification is temporarily unavailable; retry after Retry-After
      headers:
        Retry-After:
          schema: { type: integer }
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
