openapi: 3.1.0
info:
  title: PlusMedical Public API
  description: |
    The **stable public contract** for PlusMedical integrations
    (`/api/public/v1`). This spec is **separate from the internal API** on
    purpose: the internal spec evolves freely with our own clients, while
    this one is a stability promise.

    ## Versioning policy
    Within `v1`, changes are **additive only**: new endpoints, new OPTIONAL
    request fields, new response fields. Removing or renaming a field,
    changing a type, or changing semantics is a breaking change and ships as
    `/api/public/v2`. Deprecations are announced in the changelog at least
    90 days before any removal (which can only happen in a new major
    version).

    ## Authentication
    `Authorization: Bearer pm_live_...` — organization API keys created in
    the PlusMedical admin (Settings → API). The full key is shown ONCE at
    creation; only a hash is stored. Keys carry granular scopes and a
    per-minute rate limit.

    ## No clinical data
    v1 exposes **no clinical data**: no visit content, no lab values, no
    treatment plans, no documents. This is a deliberate GDPR-driven design
    decision, documented in the developer portal.

    ## Localization
    Error messages default to English; send `Accept-Language: ro` for
    Romanian.

    ## Rate limiting
    Sliding-window per key (default 60 req/min, max 300). Every response
    carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and
    `X-RateLimit-Reset`; exceeding the limit yields `429` with error code
    `rate_limited`.
  version: 1.0.0
  contact:
    name: PlusMedical Developers
    url: https://plusmedical.ro/developers
servers:
  - url: https://api.plusmedical.ro/api/public/v1
    description: Production
security:
  - apiKey: []

tags:
  - name: catalog
    description: Public-safe directory — locations, doctors, services
  - name: availability
  - name: patients
    description: Identity/contact only — never clinical data
  - name: appointments
  - name: invoices
    description: Issued documents, read-only

paths:
  /locations:
    get:
      tags: [catalog]
      operationId: listLocations
      summary: List locations
      description: Requires scope `read:locations`.
      parameters:
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Location' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /doctors:
    get:
      tags: [catalog]
      operationId: listDoctors
      summary: List doctors
      description: Requires scope `read:doctors`. Public-safe fields only.
      parameters:
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Doctor' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /services:
    get:
      tags: [catalog]
      operationId: listServices
      summary: List services
      description: |
        Requires scope `read:services`. `price` is **indicative** — final
        amounts come from invoicing.
      parameters:
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Service' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /availability:
    get:
      tags: [availability]
      operationId: getAvailability
      summary: Available slots
      description: |
        Requires scope `read:availability`. Served by the **same engine** as
        every internal booking surface, including the strict working-hours
        rule. The public booking-widget anti-abuse rules (blackout dates,
        minimum advance, daily caps) are NOT applied — API consumers are
        trusted organization integrations. Range capped at 31 days.
      parameters:
        - name: service_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date }
        - name: doctor_id
          in: query
          schema: { type: string, format: uuid }
        - name: location_id
          in: query
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Slot' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /patients:
    get:
      tags: [patients]
      operationId: listPatients
      summary: List patients (org-linked only)
      description: |
        Requires scope `read:patients`. Phone/email are **masked** unless
        the key also holds `write:patients`. Patients under a GDPR Art. 18
        processing restriction are excluded.
      parameters:
        - name: search
          in: query
          description: Whole-word name search.
          schema: { type: string }
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Patient' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [patients]
      operationId: createPatient
      summary: Create (or resolve) a patient
      description: |
        Requires scope `write:patients`. Identities are global in
        PlusMedical: when the phone/CNP/email matches an existing identity
        it is linked to your organization instead of duplicated —
        `existing_identity` flags this. The response shape is identical
        either way.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_name, last_name]
              properties:
                first_name: { type: string, maxLength: 100 }
                last_name: { type: string, maxLength: 100 }
                phone: { type: [string, 'null'], maxLength: 30 }
                email: { type: [string, 'null'], format: email }
                cnp: { type: [string, 'null'], description: Romanian CNP (checksum-validated) }
                date_of_birth: { type: [string, 'null'], format: date }
                internal_code: { type: [string, 'null'], maxLength: 50 }
      responses:
        '201':
          description: Created (or linked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Patient'
                      - type: object
                        properties:
                          existing_identity: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /patients/{patientId}:
    get:
      tags: [patients]
      operationId: getPatient
      summary: Get a patient
      description: Requires scope `read:patients`. Access is audited.
      parameters:
        - $ref: '#/components/parameters/patientId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Patient' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      tags: [patients]
      operationId: updatePatient
      summary: Update contact fields
      description: |
        Requires scope `write:patients`. **Contact fields only** — identity
        fields (names, CNP) cannot be changed over the public API.
      parameters:
        - $ref: '#/components/parameters/patientId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                phone: { type: [string, 'null'], maxLength: 30 }
                email: { type: [string, 'null'], format: email }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Patient' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /appointments:
    get:
      tags: [appointments]
      operationId: listAppointments
      summary: List appointments
      description: Requires scope `read:appointments`.
      parameters:
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
        - name: doctor_id
          in: query
          schema: { type: string, format: uuid }
        - name: location_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/AppointmentStatus' }
        - name: patient_id
          in: query
          schema: { type: string, format: uuid }
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Appointment' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [appointments]
      operationId: createAppointment
      summary: Book an appointment
      description: |
        Requires scope `write:appointments`. Validated by the **same
        engine** as internal booking: outside working hours → `422`
        `outside_working_hours`; slot race lost → `409` `slot_taken`.
        Bookings are created `confirmed` with `source=api`. Pass either
        `patient_id` or an inline `patient` (resolved like
        `POST /patients`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [location_id, doctor_id, service_id, starts_at]
              properties:
                location_id: { type: string, format: uuid }
                doctor_id: { type: string, format: uuid }
                service_id: { type: string, format: uuid }
                starts_at: { type: string, format: date-time }
                patient_id: { type: string, format: uuid }
                patient:
                  type: object
                  properties:
                    first_name: { type: string }
                    last_name: { type: string }
                    phone: { type: [string, 'null'] }
                    email: { type: [string, 'null'] }
                    cnp: { type: [string, 'null'] }
      responses:
        '201':
          description: Booked
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Appointment'
                      - type: object
                        properties:
                          existing_identity:
                            type: boolean
                            description: Present only when an inline patient was sent.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '409': { $ref: '#/components/responses/SlotTaken' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /appointments/{appointmentId}:
    get:
      tags: [appointments]
      operationId: getAppointment
      summary: Get an appointment
      description: Requires scope `read:appointments`.
      parameters:
        - $ref: '#/components/parameters/appointmentId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Appointment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /appointments/{appointmentId}/cancel:
    post:
      tags: [appointments]
      operationId: cancelAppointment
      summary: Cancel an appointment
      description: Requires scope `write:appointments`. A reason is mandatory.
      parameters:
        - $ref: '#/components/parameters/appointmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Appointment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /appointments/{appointmentId}/reschedule:
    post:
      tags: [appointments]
      operationId: rescheduleAppointment
      summary: Reschedule an appointment
      description: |
        Requires scope `write:appointments`. Re-runs the working-hours rule
        and slot-collision checks with the new time.
      parameters:
        - $ref: '#/components/parameters/appointmentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [starts_at]
              properties:
                starts_at: { type: string, format: date-time }
                doctor_id: { type: string, format: uuid }
                location_id: { type: string, format: uuid }
      responses:
        '200':
          description: Rescheduled
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Appointment' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/SlotTaken' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices:
    get:
      tags: [invoices]
      operationId: listInvoices
      summary: List issued invoices
      description: |
        Requires scope `read:invoices`. **Issued documents only** — drafts
        are internal working state. No PDF in v1.
      parameters:
        - name: from
          in: query
          schema: { type: string, format: date }
        - name: to
          in: query
          schema: { type: string, format: date }
        - name: patient_id
          in: query
          schema: { type: string, format: uuid }
        - name: doc_type
          in: query
          schema: { type: string, enum: [fiscal, proforma, storno] }
        - name: paid_status
          in: query
          schema: { type: string, enum: [paid, partial, unpaid] }
        - $ref: '#/components/parameters/perPage'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  meta: { $ref: '#/components/schemas/CursorMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /invoices/{invoiceId}:
    get:
      tags: [invoices]
      operationId: getInvoice
      summary: Get an issued invoice with lines
      description: Requires scope `read:invoices`.
      parameters:
        - name: invoiceId
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Invoice'
                      - type: object
                        properties:
                          lines:
                            type: array
                            items: { $ref: '#/components/schemas/InvoiceLine' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

webhooks:
  event:
    post:
      operationId: webhookEvent
      summary: Webhook event envelope
      description: |
        Every webhook POST carries this envelope. `id` is the event id —
        your **idempotency key**: redeliveries and retries reuse it, so
        process each id at most once.

        Headers: `X-PlusMedical-Event` (type), `X-PlusMedical-Delivery`
        (delivery id), `X-PlusMedical-Signature`
        (`t={unix},v1={hmac_sha256(secret, "{t}.{raw_body}")}` — verify
        with a 5-minute timestamp tolerance).

        Event types: `appointment.created`, `appointment.updated`,
        `appointment.cancelled`, `appointment.no_show`, `patient.created`,
        `patient.updated`, `invoice.issued`, `invoice.paid`,
        `invoice.stornoed`, `lab_order.validated` (reference only — analysis
        CODES, never values), plus `ping` (endpoint test).

        `data` uses the SAME public DTOs as this API — a field absent here
        is absent in webhooks too. Respond `2xx` within 10s; failures retry
        at 1m, 5m, 30m, 2h, 6h, 24h, then the delivery is dead. 20
        consecutive failures auto-disable the endpoint (admins are emailed).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [id, type, created_at, data]
              properties:
                id: { type: string, format: uuid }
                type: { type: string }
                created_at: { type: string, format: date-time }
                data: { type: object }
      responses:
        '200':
          description: Acknowledge with any 2xx within 10 seconds.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: pm_live_...
      description: Organization API key. Scopes are attached to the key.

  parameters:
    perPage:
      name: per_page
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    cursor:
      name: cursor
      in: query
      description: Opaque cursor from `meta.next_cursor`.
      schema: { type: string }
    patientId:
      name: patientId
      in: path
      required: true
      schema: { type: string, format: uuid }
    appointmentId:
      name: appointmentId
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Unauthorized:
      description: Missing/invalid/revoked/expired API key (`invalid_api_key`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InsufficientScope:
      description: |
        The key lacks the required scope (`insufficient_scope`);
        `error.details.required_scope` names it.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Not found (`not_found`) — includes resources of other organizations.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    SlotTaken:
      description: The slot was taken concurrently (`slot_taken`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationFailed:
      description: Validation failed (`validation_failed`) or domain rule rejected (e.g. `outside_working_hours`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Rate limit exceeded (`rate_limited`). Check `X-RateLimit-Reset`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string, description: "Localized (en default, ro via Accept-Language)" }
            details: {}

    CursorMeta:
      type: object
      properties:
        next_cursor: { type: [string, 'null'] }

    Location:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        address: {}
        phone: { type: [string, 'null'] }
        working_hours: {}

    Doctor:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        specialties:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              slug: { type: string }
              name: { type: string }
        location_ids:
          type: array
          items: { type: string, format: uuid }

    Service:
      type: object
      properties:
        id: { type: string, format: uuid }
        code: { type: [string, 'null'] }
        name:
          type: object
          description: Raw localized map, e.g. {"ro" :"...", "en":"..."}
        name_localized: { type: string }
        specialty:
          type: [object, 'null']
          properties:
            id: { type: string, format: uuid }
            slug: { type: string }
        duration_minutes: { type: integer }
        price:
          type: [string, number, 'null']
          description: Indicative only.
        currency: { type: [string, 'null'] }

    Slot:
      type: object
      properties:
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        doctor_id: { type: string, format: uuid }
        resource_id: { type: [string, 'null'], format: uuid }

    Patient:
      type: object
      description: Identity/contact ONLY — no clinical fields, ever.
      properties:
        id: { type: string, format: uuid }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        phone:
          type: [string, 'null']
          description: Masked unless the key holds write:patients.
        email:
          type: [string, 'null']
          description: Masked unless the key holds write:patients.
        internal_code: { type: [string, 'null'] }

    AppointmentStatus:
      type: string
      enum: [requested, confirmed, checked_in, in_progress, completed, cancelled, no_show]

    Appointment:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { $ref: '#/components/schemas/AppointmentStatus' }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        source: { type: string }
        patient: { $ref: '#/components/schemas/Ref' }
        doctor: { $ref: '#/components/schemas/Ref' }
        location: { $ref: '#/components/schemas/Ref' }
        service:
          type: [object, 'null']
          properties:
            id: { type: [string, 'null'], format: uuid }

    Ref:
      type: object
      properties:
        id: { type: string, format: uuid }

    Invoice:
      type: object
      properties:
        id: { type: string, format: uuid }
        number: { type: [string, 'null'] }
        doc_type: { type: string, enum: [fiscal, proforma, storno] }
        issue_date: { type: [string, 'null'], format: date }
        due_date: { type: [string, 'null'], format: date }
        currency: { type: string }
        subtotal: { type: [string, number] }
        vat_total: { type: [string, number] }
        total: { type: [string, number] }
        paid_status: { type: [string, 'null'], enum: [paid, partial, unpaid, null] }
        company:
          type: object
          properties:
            name: { type: [string, 'null'] }
            cui: { type: [string, 'null'] }
        patient:
          type: [object, 'null']
          properties:
            id: { type: string, format: uuid }

    InvoiceLine:
      type: object
      properties:
        position: { type: integer }
        description: { type: string }
        quantity: { type: [string, number] }
        unit_price: { type: [string, number] }
        line_subtotal: { type: [string, number] }
        line_vat: { type: [string, number] }
        line_total: { type: [string, number] }
