openapi: 3.1.0
info:
  title: PawPlacer Public API
  version: "1.5.0"
  summary: Public pet, people, adoption fee, and contract API for PawPlacer integrations.
  description: |
    Official OpenAPI contract for PawPlacer public endpoints.

    Authentication:
    - Send API key in header: `x-api-key`

    Notes:
    - Successful non-304 API responses return JSON.
    - Public GET endpoints include metadata headers such as `X-Request-Id`, `X-Api-Version`, and `X-RateLimit-*`.
    - Public GET endpoints support conditional requests with `ETag` and `If-None-Match`.
    - `POST /api/pets`, `PATCH /api/pets/{petIdentifier}`, and `POST /api/people` support `Idempotency-Key` for safe retries.
    - Pet updates accept either the PawPlacer pet UUID or the pet `custom_id` in the route. UUIDs are preferred when both identifiers are available.
    - `GET /api/pets` returns non-deleted pets where `show_public` is true and the pet custom status is public or unset.
    - Rate limits are enforced per API key and endpoint.
    - API keys can be provisioned with `read` or `write` access. Write keys also retain read access.
servers:
  - url: https://pawplacer.com
    description: Production
security:
  - ApiKeyAuth: []
tags:
  - name: Pets
    description: Public pet listing and detail endpoints.
  - name: People
    description: Public adopter and foster listing, detail, and intake endpoints.
  - name: Adoption Fees
    description: Public adoption fee configuration endpoints.
  - name: Contracts
    description: Public contract content endpoints.
  - name: Custom Fields
    description: Metadata for public custom form fields.
paths:
  /api/pets:
    get:
      tags: [Pets]
      operationId: listPublicPets
      summary: List public pets
      description: Returns non-deleted public pets with pagination and optional filters. Pets must have `show_public = true`; if a custom status is assigned, that status must be public.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Species'
        - $ref: '#/components/parameters/Status'
        - $ref: '#/components/parameters/Search'
        - $ref: '#/components/parameters/UpdatedSince'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Successful response with paginated pets.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PetListResponse'
              examples:
                default:
                  value:
                    pets:
                      - id: 123e4567-e89b-12d3-a456-426614174000
                        name: Max
                        species: dog
                        age_category: young
                        sex: male
                        size: medium
                        status: Available
                        health: good
                        breed: [Labrador Retriever]
                        color: [Black]
                        age_years: null
                        age_months: null
                        age_birthday: null
                        description: Friendly and energetic dog
                        spayed: true
                        adoption_fee: '250'
                        microchip_id: null
                        good_with: [families, kids]
                        bad_with: []
                        temperaments: [playful, social]
                        image_urls:
                          - https://pawplacer.com/pets/max.jpg
                        image_url: https://pawplacer.com/pets/max.jpg
                        coat_length: short
                        custom_field_data:
                          favorite_toy: Tennis ball
                        custom_id: EXT-1001
                        intake_date: null
                        outcome_date: null
                        primary_veterinarian: Downtown Vet Clinic
                        show_public: true
                        special_needs: []
                        tags: [Senior, Special Care]
                        status_change_notes: null
                        weight: null
                        adopted_on: null
                        adopted_by: null
                        created_at: '2026-02-17T12:00:00.000000+00:00'
                        updated_at: '2026-02-17T12:00:00.000000+00:00'
                    total: 1
                    limit: 20
                    offset: 0
                    hasMore: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Pets]
      operationId: createPet
      summary: Create a pet
      description: Creates a pet record using required intake fields.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePetRequest'
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Pet created successfully.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Post'
            Idempotency-Replay:
              $ref: '#/components/headers/Idempotency-Replay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '405':
          description: Empty request body. Use `GET /api/pets` for reads and send JSON to create pets.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                emptyBody:
                  value:
                    error: Empty request body. To list pets, use GET /api/pets. POST creates new pets.
                    code: method_not_allowed
                    request_id: 1d98f7d9-42cf-4f1a-9f7b-b3ce6a10a663
                    docs: https://pawplacer.com/docs/integrations/api-post
                    hint: Send JSON with Content-Type application/json
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/pets/{petIdentifier}:
    get:
      tags: [Pets]
      operationId: getPublicPetById
      summary: Get public pet by ID
      parameters:
        - name: petIdentifier
          in: path
          required: true
          description: Pet UUID.
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Successful single pet response.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      tags: [Pets]
      operationId: updatePet
      summary: Update a pet
      description: Updates an existing pet by PawPlacer pet UUID or `custom_id`. Send only the fields that should change. Supported fields include bio/description, status or `custom_status_id`, photos via `image_urls`, age fields, `show_public`, `custom_id`, and public custom field data. If the identifier could match both a UUID and a `custom_id`, the UUID match wins.
      parameters:
        - name: petIdentifier
          in: path
          required: true
          description: PawPlacer pet UUID or assigned `custom_id`.
          schema:
            type: string
          examples:
            uuid:
              summary: PawPlacer UUID
              value: 123e4567-e89b-12d3-a456-426614174000
            customId:
              summary: custom_id
              value: WEB-DOG-1001
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdatePetRequest'
            examples:
              updateCommonFields:
                value:
                  description: Updated bio from website CMS
                  status: available
                  image_urls:
                    - https://example.org/dogs/max-1.jpg
                  age_years: '4'
                  show_public: true
                  custom_id: WEB-DOG-1001
      responses:
        '200':
          description: Pet updated successfully.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Post'
            Idempotency-Replay:
              $ref: '#/components/headers/Idempotency-Replay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        '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'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/pets/custom-fields:
    get:
      tags: [Custom Fields]
      operationId: getPetCustomFields
      summary: Get pet custom field metadata
      parameters:
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Custom form field metadata.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PetCustomFieldsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/people:
    get:
      tags: [People]
      operationId: listPublicPeople
      summary: List adopters or fosters
      description: Returns adopters or fosters with pagination and optional filters.
      parameters:
        - $ref: '#/components/parameters/PersonType'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/PersonStatus'
        - $ref: '#/components/parameters/PeopleSearch'
        - $ref: '#/components/parameters/UpdatedSincePeople'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Successful response with paginated people.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [People]
      operationId: createPerson
      summary: Create an adopter or foster
      description: Creates an adopter or foster record. Use `GET /api/people/custom-fields` to discover custom field keys.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreatePersonRequest'
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Person created successfully.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Post'
            Idempotency-Replay:
              $ref: '#/components/headers/Idempotency-Replay'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Person'
        '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'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/people/{personId}:
    get:
      tags: [People]
      operationId: getPublicPersonById
      summary: Get adopter or foster by ID
      parameters:
        - name: personId
          in: path
          required: true
          description: Adopter or foster UUID.
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/PersonType'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Successful single person response.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Person'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/people/custom-fields:
    get:
      tags: [Custom Fields]
      operationId: getPersonCustomFields
      summary: Get adopter or foster custom field metadata
      description: Returns field keys for person `custom_field_data`. If a field has a public API key, that value is returned as `field_key`.
      parameters:
        - $ref: '#/components/parameters/PersonType'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Person custom form field metadata.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonCustomFieldsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/adoption-fees:
    get:
      tags: [Adoption Fees]
      operationId: getAdoptionFees
      summary: Get adoption fee configuration
      responses:
        '200':
          description: Adoption fee configuration.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdoptionFeesResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /api/contracts:
    get:
      tags: [Contracts]
      operationId: getContract
      summary: Get contract content
      parameters:
        - $ref: '#/components/parameters/ContractType'
        - $ref: '#/components/parameters/IfNoneMatch'
      responses:
        '200':
          description: Contract content.
          headers:
            X-Request-Id:
              $ref: '#/components/headers/X-Request-Id'
            X-Api-Version:
              $ref: '#/components/headers/X-Api-Version'
            X-Generated-At:
              $ref: '#/components/headers/X-Generated-At'
            X-RateLimit-Limit:
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/X-RateLimit-Reset'
            ETag:
              $ref: '#/components/headers/ETag'
            Cache-Control:
              $ref: '#/components/headers/Cache-Control-Get'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContractResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '304':
          $ref: '#/components/responses/NotModified'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
  parameters:
    Limit:
      name: limit
      in: query
      description: Number of records to return. Clamped to 1-100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
    Offset:
      name: offset
      in: query
      description: Number of records to skip.
      schema:
        type: integer
        minimum: 0
    Species:
      name: species
      in: query
      description: Species filter.
      schema:
        type: string
        enum: [dog, cat, rabbit]
    Status:
      name: status
      in: query
      description: Case-insensitive status label filter.
      schema:
        type: string
    Search:
      name: search
      in: query
      description: Case-insensitive fuzzy search over name, description, and breed.
      schema:
        type: string
    UpdatedSince:
      name: updated_since
      in: query
      description: Return only pets with updated_at >= this ISO-8601 timestamp.
      schema:
        type: string
        format: date-time
    PersonType:
      name: type
      in: query
      required: true
      description: Person record type to query.
      schema:
        type: string
        enum: [adopter, foster]
    PersonStatus:
      name: status
      in: query
      description: Case-insensitive person status filter.
      schema:
        type: string
    PeopleSearch:
      name: search
      in: query
      description: Case-insensitive fuzzy search over name, email, phone, and address.
      schema:
        type: string
    UpdatedSincePeople:
      name: updated_since
      in: query
      description: Return only people with updated_at >= this ISO-8601 timestamp.
      schema:
        type: string
        format: date-time
    ContractType:
      name: type
      in: query
      required: true
      description: Contract content type.
      schema:
        type: string
        enum: [adopter, foster, volunteer, surrender]
    IfNoneMatch:
      name: If-None-Match
      in: header
      description: Conditional GET token from a previous `ETag` response header.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: Stable caller-generated key used to safely retry create requests without creating duplicate records.
      schema:
        type: string
  headers:
    X-Request-Id:
      description: Unique request identifier for support and tracing.
      schema:
        type: string
        format: uuid
    X-Api-Version:
      description: API contract version string.
      schema:
        type: string
    X-Generated-At:
      description: Response generation timestamp.
      schema:
        type: string
        format: date-time
    X-RateLimit-Limit:
      description: Max requests allowed in the current hourly window.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Remaining requests in the current hourly window.
      schema:
        type: integer
    X-RateLimit-Reset:
      description: Unix epoch timestamp when the rate limit window resets.
      schema:
        type: integer
    ETag:
      description: Entity tag for conditional GET requests.
      schema:
        type: string
    Retry-After:
      description: Seconds until request can be retried after a rate limit response.
      schema:
        type: integer
    Idempotency-Replay:
      description: Present with value `true` when a successful create response was replayed from a previous request with the same `Idempotency-Key`.
      schema:
        type: string
        enum: ['true']
    Cache-Control-Get:
      description: Cache policy for GET responses.
      schema:
        type: string
        example: private, max-age=60, stale-while-revalidate=300
    Cache-Control-Post:
      description: Cache policy for POST responses.
      schema:
        type: string
        example: no-store
  responses:
    BadRequest:
      description: Invalid input or validation failure.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    Unauthorized:
      description: Missing or invalid API key.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    NotFound:
      description: Requested record not found.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    NotModified:
      description: Resource has not changed since the supplied `If-None-Match` value.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        ETag:
          $ref: '#/components/headers/ETag'
        Cache-Control:
          $ref: '#/components/headers/Cache-Control-Get'
    Forbidden:
      description: API key is valid but does not have sufficient access for this endpoint.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    Conflict:
      description: Request conflicts with an existing idempotent write.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    RateLimited:
      description: Request limit exceeded for the current hourly window.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        Retry-After:
          $ref: '#/components/headers/Retry-After'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
    InternalError:
      description: Unexpected server-side failure.
      headers:
        X-Request-Id:
          $ref: '#/components/headers/X-Request-Id'
        X-Api-Version:
          $ref: '#/components/headers/X-Api-Version'
        X-Generated-At:
          $ref: '#/components/headers/X-Generated-At'
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
  schemas:
    PetListResponse:
      type: object
      required: [pets, total, limit, offset, hasMore]
      properties:
        pets:
          type: array
          items:
            $ref: '#/components/schemas/Pet'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    Pet:
      type: object
      required:
        - id
        - name
        - species
        - age_category
        - sex
        - size
        - status
        - health
        - breed
        - color
        - age_years
        - age_months
        - age_birthday
        - description
        - spayed
        - adoption_fee
        - microchip_id
        - good_with
        - bad_with
        - temperaments
        - image_urls
        - image_url
        - coat_length
        - custom_id
        - intake_date
        - outcome_date
        - primary_veterinarian
        - show_public
        - special_needs
        - tags
        - status_change_notes
        - weight
        - adopted_on
        - adopted_by
        - custom_field_data
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        species:
          type: string
          enum: [dog, cat, rabbit]
        age_category:
          type: string
        sex:
          type: string
        size:
          type: string
        status:
          type: string
        health:
          type: string
        breed:
          type: array
          items: { type: string }
        color:
          type: array
          items: { type: string }
        age_years:
          type: string
          nullable: true
        age_months:
          type: string
          nullable: true
        age_birthday:
          type: string
          nullable: true
        description:
          type: string
        spayed:
          type: boolean
        adoption_fee:
          type: string
        global_adoption_fee:
          type: number
          nullable: true
        microchip_id:
          type: string
          nullable: true
        good_with:
          type: array
          items: { type: string }
        bad_with:
          type: array
          items: { type: string }
        temperaments:
          type: array
          items: { type: string }
        image_urls:
          type: array
          items: { type: string }
        image_url:
          type: string
          nullable: true
        coat_length:
          type: string
          nullable: true
        custom_field_data:
          type: object
          additionalProperties: true
        custom_id:
          type: string
          nullable: true
        intake_date:
          type: string
          nullable: true
        outcome_date:
          type: string
          nullable: true
        primary_veterinarian:
          type: string
          nullable: true
        show_public:
          type: boolean
        special_needs:
          type: array
          items: { type: string }
        tags:
          type: array
          items: { type: string }
        status_change_notes:
          type: string
          nullable: true
        weight:
          type: string
          nullable: true
          description: Single unit string value (for example "45 lbs" or "20 kg").
        adopted_on:
          type: string
          nullable: true
        adopted_by:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    PetCustomFieldsResponse:
      type: object
      required: [custom_fields]
      properties:
        custom_fields:
          type: array
          items:
            $ref: '#/components/schemas/PetCustomField'
    PetCustomField:
      type: object
      required: [field_key, label, field_type, required]
      properties:
        field_key:
          type: string
        label:
          type: string
        field_type:
          type: string
        required:
          type: boolean
        help_text:
          type: string
          nullable: true
        options:
          nullable: true
        section:
          type: string
          nullable: true
        hidden:
          type: boolean
          description: True when the field or its section is hidden in the form builder.
        internal_only:
          type: boolean
          description: True when the field is marked for staff-only use.
    CreatePetRequest:
      type: object
      required: [name, species, age_category, sex, size, status, health]
      additionalProperties: false
      properties: &PetWriteProperties
        name:
          type: string
        species:
          type: string
          enum: [dog, cat, rabbit]
        age_category:
          type: string
          enum: [youngest, young, adult, senior]
        sex:
          type: string
          enum: [male, female, unknown]
        size:
          type: string
          enum: [xSmall, small, medium, large, xLarge]
        status:
          type: string
          description: Custom status display label or known status value.
        health:
          type: string
          enum: [unknown, poor, good, great]
        description:
          type: string
        adoption_fee:
          oneOf:
            - type: string
            - type: number
        breed:
          type: array
          items: { type: string }
        color:
          type: array
          items: { type: string }
        image_urls:
          type: array
          items: { type: string }
        show_public:
          type: boolean
        custom_field_data:
          type: object
          additionalProperties: true
        custom_id:
          type: string
        custom_status_id:
          type: string
          format: uuid
          description: Optional active custom status UUID for the same account. When provided, it takes precedence over status-name matching.
        age_years:
          type: string
        age_months:
          type: string
        age_birthday:
          type: string
          format: date-time
        spayed:
          type: boolean
        microchip_id:
          type: string
        good_with:
          type: array
          items: { type: string }
        bad_with:
          type: array
          items: { type: string }
        temperaments:
          type: array
          items: { type: string }
        coat_length:
          type: string
        intake_date:
          type: string
          format: date-time
        location_found:
          type: string
          description: Optional intake metadata field. Stored in mapped custom field data when configured.
        outcome_date:
          type: string
          format: date-time
        primary_veterinarian_id:
          type: string
          format: uuid
        reason_for_surrender:
          type: string
          description: Optional intake metadata field. Stored in mapped custom field data when configured.
        special_needs:
          type: array
          items: { type: string }
        status_change_notes:
          type: string
        weight:
          type: string
        template_id:
          type: string
          format: uuid
    UpdatePetRequest:
      type: object
      minProperties: 1
      additionalProperties: false
      description: Partial pet update. Send only fields to change; at least one field is required.
      properties: *PetWriteProperties
    PersonListResponse:
      type: object
      required: [type, people, total, limit, offset, hasMore]
      properties:
        type:
          type: string
          enum: [adopter, foster]
        people:
          type: array
          items:
            $ref: '#/components/schemas/Person'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    Person:
      type: object
      required:
        - id
        - type
        - name
        - email
        - phone
        - address
        - status
        - status_change_notes
        - custom_field_data
        - tags
        - capacity
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        type:
          type: string
          enum: [adopter, foster]
        name:
          type: string
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, active, training, inactive, denied, suspended, blocked]
        status_change_notes:
          type: string
          nullable: true
        custom_field_data:
          type: object
          additionalProperties: true
        tags:
          type: array
          items: { type: string }
        capacity:
          type: number
          nullable: true
          description: Foster capacity. Adopter responses return null.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreatePersonRequest:
      type: object
      required: [type, name]
      properties:
        type:
          type: string
          enum: [adopter, foster]
        name:
          type: string
        email:
          type: string
          nullable: true
        phone:
          type: string
          nullable: true
        address:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, active, training, inactive, denied, suspended, blocked]
        status_change_notes:
          type: string
          nullable: true
        custom_field_data:
          type: object
          additionalProperties: true
          description: Use `field_key` values from `GET /api/people/custom-fields`. Public API keys are normalized to internal form field keys on create.
        capacity:
          type: integer
          minimum: 0
          nullable: true
          description: Foster capacity. Ignored for adopter records.
    PersonCustomFieldsResponse:
      type: object
      required: [type, custom_fields]
      properties:
        type:
          type: string
          enum: [adopter, foster]
        custom_fields:
          type: array
          items:
            $ref: '#/components/schemas/PersonCustomField'
    PersonCustomField:
      allOf:
        - $ref: '#/components/schemas/PetCustomField'
    AdoptionFeesResponse:
      type: object
      required: [fees]
      properties:
        fees:
          type: array
          items:
            $ref: '#/components/schemas/AdoptionFeeEntry'
    AdoptionFeeEntry:
      type: object
      required: [species, attribute_type, attribute_value, adjustment]
      properties:
        species:
          type: string
        attribute_type:
          type: string
        attribute_value:
          type: string
        adjustment:
          type: number
    ContractResponse:
      type: object
      required: [type, content, updated_at]
      properties:
        type:
          type: string
          enum: [adopter, foster, volunteer, surrender]
        content:
          type: string
          description: Contract content in Markdown.
        updated_at:
          type: string
          format: date-time
          nullable: true
    ApiError:
      type: object
      required: [error, code, request_id]
      properties:
        error:
          type: string
        code:
          type: string
        request_id:
          type: string
          format: uuid
        details:
          description: Optional structured detail payload.
        errors:
          description: Optional validation errors array/object.
        docs:
          type: string
        hint:
          type: string
