openapi: 3.1.0
info:
  title: Coggo PIMS Partner API
  version: "1.0"
  description: |
    Bidirectional integration between a Practice Information Management System (PIMS)
    and Coggo, the AI veterinary scribe.

    ## How the integration works
    1. **PIMS → Coggo (you call us):** send signed webhook events when appointments,
       clients, or patients change. Coggo creates/updates the matching records and a
       `scheduled` visit appears for the vet.
    2. **Coggo → PIMS (we call you):** Coggo calls endpoints **hosted by your PIMS**
       for patient search, appointment fetch (reconciliation), and to push signed
       SOAP notes back in `json`, `text`, and `html` formats.

    Endpoints are grouped with tags: `Coggo-hosted` (call these) and
    `PIMS-hosted — you implement` (Coggo calls these on your server; the base URL and
    push endpoint are registered per clinic in Coggo → Settings → Integrations).

    ## Authentication
    - **Your calls to Coggo:** `Authorization: Bearer cgk_live_…` — a per-clinic API
      key issued in Coggo (Pro plan required). Keys are scoped and revocable.
    - **Webhook signatures (both directions):** every webhook/push request carries
      `X-Coggo-Id`, `X-Coggo-Timestamp` (unix seconds), `X-Coggo-Signature`.
      Signature = base64( HMAC-SHA256( secret, "{id}.{timestamp}.{raw body}" ) ).
      Reject if |now − timestamp| > 300s. The shared secret is shown once when the
      integration is created.

    ## Conventions
    - Source of truth: the PIMS owns client/patient demographics — inbound updates
      overwrite them in Coggo. Coggo owns clinical documents.
    - All ids you send are YOUR external ids; Coggo maintains the id mapping.
    - Events are idempotent on `id` — retries are safe.
    - Rate limit: 120 requests/min per key (`429` with `Retry-After`).
  contact:
    name: Coggo integrations
    email: integrations@coggo.ai
    url: https://www.coggo.ai

servers:
  - url: https://api.coggo.ai/v1/partner
    description: Coggo Partner API (production)

tags:
  - name: Coggo-hosted
    description: Endpoints on api.coggo.ai that your PIMS calls.
  - name: PIMS-hosted — you implement
    description: >
      Endpoints your PIMS must expose. Coggo calls them using the base URL and
      credentials registered for the clinic's integration.

security:
  - PartnerApiKey: []

paths:
  /ping:
    get:
      tags: [Coggo-hosted]
      operationId: ping
      summary: Verify credentials and inspect the integration
      responses:
        "200":
          description: Key is valid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  clinicId: { type: string }
                  provider: { type: string }
                  scopes: { type: array, items: { type: string } }
                  status: { type: string, enum: [active, paused] }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/IntegrationPaused" }

  /webhooks:
    post:
      tags: [Coggo-hosted]
      operationId: receiveWebhook
      summary: Send an event to Coggo
      description: |
        Requires the Bearer API key. The HMAC signature headers are OPTIONAL:
        if `X-Coggo-Signature` is sent it is verified (defense in depth); if not,
        the Bearer key alone authorizes the request (secure over HTTPS). A
        timestamp sent without a signature still enforces the ±5min replay window.
        Idempotent on `id`. Appointment events
        embed full `client` and `patient` objects so no callback fetch is
        needed.

        **`appointment.created` and `appointment.updated` MUST include
        `data.patient`** — Coggo upserts the patient first, then links the
        appointment to it. Events missing the patient are rejected with
        `400 { code: "patient_required" }`. `appointment.cancelled` may omit it.
      parameters:
        - { $ref: "#/components/parameters/SigId" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/SigSignature" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            examples:
              appointmentCreated:
                summary: New appointment
                value:
                  id: evt_8f2c41
                  type: appointment.created
                  occurredAt: "2026-06-12T09:30:00Z"
                  data:
                    appointment:
                      externalId: apt_1029
                      startsAt: "2026-06-12T14:00:00"
                      timezone: America/New_York
                      reason: Lameness re-check
                      externalStaffId: stf_44
                    client:
                      externalId: cli_553
                      firstName: Peggy
                      lastName: Bartl
                      email: peggy.bartl@example.com
                      phone: "+15134041567"
                    patient:
                      externalId: pat_771
                      name: Sky
                      speciesKey: "1"
                      breed: Golden Retriever
                      color: Golden
                      sex: female
                      reproductiveStatus: spayed
                      dateOfBirth: "2023-06-08"
                      weight: { value: 11, unit: kg }
      responses:
        "200":
          description: Event accepted (or already processed).
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: boolean }
                  duplicate: { type: boolean }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Missing or invalid signature.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "402": { $ref: "#/components/responses/IntegrationPaused" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /pims/species:
    get:
      tags: [PIMS-hosted — you implement]
      operationId: pimsSpeciesList
      summary: "Species catalog (Coggo calls: GET {your base URL}/species)"
      description: |
        Returns your PIMS species list so the clinic can map each PIMS species to
        a Coggo species during integration setup. After mapping, your patient
        payloads send `speciesKey` (the `key` below) and Coggo resolves the
        Coggo species automatically. Called at setup and when the clinic clicks
        "Refresh species".
      security: []
      responses:
        "200":
          description: The PIMS species catalog.
          content:
            application/json:
              schema:
                type: object
                required: [species]
                properties:
                  species:
                    type: array
                    items:
                      type: object
                      required: [key]
                      properties:
                        key: { type: string, description: "Your PIMS species id/foreign key — echoed back as patient.speciesKey." }
                        label: { type: string, description: "Human-readable name shown in the mapping UI." }
              example:
                species:
                  - { key: "1", label: "Canine" }
                  - { key: "2", label: "Feline" }
                  - { key: "7", label: "Equine" }

  /pims/patients:
    get:
      tags: [PIMS-hosted — you implement]
      operationId: pimsPatientSearch
      summary: "Patient search (Coggo calls: GET {your base URL}/patients?q=)"
      description: |
        Called when a Coggo user searches a patient not found locally and chooses
        "Search in PIMS". Respond within 2 seconds. Return up to 20 matches.

        Each match is shown in the Coggo search result and can be imported with
        one click, so include as much as you have. Coggo displays and imports:
          - patient: name (required), speciesKey (preferred) or legacy species,
            breed, color, sex (male | female | unknown), reproductiveStatus,
            dateOfBirth or age {value,unit}, weight {value,unit} or legacy weightKg
          - owner: firstName, lastName, email, phone (under `owner`)
        `externalId` (patient) and `owner.externalId` are required so Coggo can
        de-duplicate and link records on import.
      security: []
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string, minLength: 2 }
          description: Free-text query (patient name, owner name, phone, email).
      responses:
        "200":
          description: Matches (empty array when none).
          content:
            application/json:
              schema:
                type: object
                required: [patients]
                properties:
                  patients:
                    type: array
                    maxItems: 20
                    items: { $ref: "#/components/schemas/PatientWithOwner" }
              example:
                patients:
                  - externalId: "P-1042"
                    name: "Kiva"
                    speciesKey: "1"
                    breed: "Labrador"
                    color: "Black"
                    sex: "female"
                    reproductiveStatus: "spayed"
                    dateOfBirth: "2020-02-14"
                    weight: { value: 13.2, unit: kg }
                    owner:
                      externalId: "C-557"
                      firstName: "Roxy"
                      lastName: "Mark"
                      email: "roxy@example.com"
                      phone: "+15138992300"

  /pims/appointments:
    get:
      tags: [PIMS-hosted — you implement]
      operationId: pimsAppointmentList
      summary: "Upcoming appointments (Coggo calls: GET {your base URL}/appointments?days=7)"
      description: |
        Used by on-demand reconciliation (the refresh button in Coggo): return
        all appointments starting within the next `days` days, each with the
        embedded client + patient (same shapes as webhook events). Coggo runs
        them through the same idempotent upsert pipeline. Max 100 processed
        per refresh.
      security: []
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, default: 7, minimum: 1, maximum: 30 }
      responses:
        "200":
          description: Upcoming appointments.
          content:
            application/json:
              schema:
                type: object
                required: [appointments]
                properties:
                  appointments:
                    type: array
                    items:
                      type: object
                      required: [appointment]
                      properties:
                        appointment: { $ref: "#/components/schemas/Appointment" }
                        client: { $ref: "#/components/schemas/Client" }
                        patient: { $ref: "#/components/schemas/Patient" }

  /pims/push:
    post:
      tags: [PIMS-hosted — you implement]
      operationId: pimsReceiveSoap
      summary: "SOAP push (Coggo calls: POST {your registered push endpoint})"
      description: |
        Coggo POSTs a signed envelope when a vet pushes a finalized SOAP note.
        Verify the signature headers exactly like Coggo verifies yours.
        Any 2xx response marks the push `sent`; anything else is retried with
        exponential backoff (5 attempts). Amended notes are re-pushed with an
        incremented `meta.version` — treat (noteId, version) as the idempotency key.
      security: []
      parameters:
        - { $ref: "#/components/parameters/SigId" }
        - { $ref: "#/components/parameters/SigTimestamp" }
        - { $ref: "#/components/parameters/SigSignature" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SoapPushEnvelope" }
      responses:
        "200":
          description: Stored. Coggo marks the push as sent.

components:
  securitySchemes:
    PartnerApiKey:
      type: http
      scheme: bearer
      bearerFormat: "cgk_live_… (per-clinic key issued in Coggo settings)"

  parameters:
    SigId:
      name: X-Coggo-Id
      in: header
      required: true
      schema: { type: string }
      description: Unique request/event id (idempotency + signature input).
    SigTimestamp:
      name: X-Coggo-Timestamp
      in: header
      required: true
      schema: { type: integer }
      description: Unix seconds. Reject if older/newer than 300s.
    SigSignature:
      name: X-Coggo-Signature
      in: header
      required: true
      schema: { type: string }
      description: 'base64( HMAC-SHA256( secret, "{id}.{timestamp}.{raw body}" ) )'

  responses:
    Unauthorized:
      description: Missing/invalid API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    IntegrationPaused:
      description: The clinic's plan no longer includes the Partner API, or the integration is paused.
      content:
        application/json:
          schema:
            allOf:
              - { $ref: "#/components/schemas/Error" }
            example: { error: integration_paused }
    BadRequest:
      description: Validation failed; `error` describes the exact field.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Over 120 req/min for this key.
      headers:
        Retry-After:
          schema: { type: integer }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
        detail: { type: string }

    Client:
      type: object
      required: [externalId]
      properties:
        externalId: { type: string }
        firstName: { type: string }
        lastName: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        addressLine1: { type: string }
        addressLine2: { type: string }
        city: { type: string }
        stateRegion: { type: string }
        postalCode: { type: string }

    Patient:
      type: object
      required: [externalId, name]
      properties:
        externalId: { type: string }
        name: { type: string }
        speciesKey:
          type: string
          description: >
            Preferred. The species' foreign key/id in your PIMS. Coggo resolves it
            via the per-integration species map the clinic configures during setup
            (see GET /species). Send this OR the legacy free-text `species`.
        species:
          type: string
          description: >
            Legacy fallback (e.g. "Canine"). Matched case-insensitively against
            Coggo's species. Prefer `speciesKey`. One of speciesKey/species should
            be present.
        breed:
          type: string
          description: >
            Breed name. If it doesn't exist under the resolved species for the
            clinic, Coggo adds it to that clinic's breed list automatically.
        color:
          type: string
          description: >
            Free text (e.g. "Black & Tan"). Added to the clinic's color list if new.
        sex: { type: string, enum: [male, female, unknown] }
        reproductiveStatus: { type: string, enum: [intact, neutered, spayed, unknown] }
        dateOfBirth: { type: string, format: date, description: "Wins over `age` if both are sent." }
        age:
          type: object
          description: "Used only when dateOfBirth is absent; Coggo derives an approximate birthdate."
          required: [value, unit]
          properties:
            value: { type: number }
            unit: { type: string, enum: [years, months, weeks] }
        weight:
          type: object
          description: "Preferred. Coggo stores weight in kg (converts from lb)."
          required: [value, unit]
          properties:
            value: { type: number }
            unit: { type: string, enum: [kg, lb] }
        weightKg: { type: number, description: "Legacy — weight in kg. Use `weight` instead." }
        externalClientId: { type: string, description: Owner's external id. }

    PatientWithOwner:
      allOf:
        - { $ref: "#/components/schemas/Patient" }
        - type: object
          required: [owner]
          properties:
            owner: { $ref: "#/components/schemas/Client" }

    Appointment:
      type: object
      required: [externalId, startsAt]
      properties:
        externalId: { type: string }
        startsAt:
          type: string
          description: Local clinic time, ISO 8601 without offset; pair with `timezone`.
        timezone: { type: string, description: IANA tz, e.g. America/New_York }
        durationMinutes: { type: integer }
        reason: { type: string }
        externalStaffId: { type: string, description: Attending vet in your system. }
        status: { type: string, enum: [scheduled, cancelled] }

    WebhookEvent:
      type: object
      required: [id, type, occurredAt, data]
      properties:
        id: { type: string, description: Your unique event id (idempotency key). }
        type:
          type: string
          enum:
            - appointment.created
            - appointment.updated
            - appointment.cancelled
            - client.updated
            - patient.updated
            - patient.merged
        occurredAt: { type: string, format: date-time }
        data:
          type: object
          properties:
            appointment: { $ref: "#/components/schemas/Appointment" }
            client: { $ref: "#/components/schemas/Client" }
            patient: { $ref: "#/components/schemas/Patient" }
            mergedIntoExternalId:
              type: string
              description: For patient.merged — the surviving patient's external id.

    SoapField:
      type: object
      description: >
        One structured field within a SOAP section. `value` holds the captured
        data; the other properties describe how Coggo presents the field (you can
        ignore them and just read `label` + `value`). A field with no captured
        data has value "Not mentioned" (free-text/select) or "" (checkbox groups).
      required: [name, label, type, value]
      properties:
        name: { type: string, description: "Stable machine key.", example: temperature }
        label: { type: string, description: "Human-readable label.", example: "Temperature (Rectal)" }
        type:
          type: string
          description: Data type of the value.
          enum: [free_text, number_with_unit, select, checkbox_text]
        ui_control:
          type: string
          description: How Coggo renders it (informational).
          enum: [textarea, number_input, dropdown, checkbox_group]
        unit: { type: string, description: "Present for number_with_unit.", example: "°C" }
        options:
          type: array
          description: Allowed values for select / checkbox_text fields.
          items: {}
        value:
          description: >
            Captured value. string for free_text/select; number or string for
            number_with_unit; array of strings (or "") for checkbox_text.
          oneOf:
            - type: string
            - type: number
            - type: array
              items: { type: string }
      example:
        name: temperature
        label: "Temperature (Rectal)"
        type: number_with_unit
        ui_control: number_input
        unit: "°C"
        value: 36.89

    SoapSection:
      type: object
      description: A SOAP section (Subjective/Objective/Assessment/Plan) — a titled list of fields.
      required: [title, fields]
      properties:
        title: { type: string, example: "Objective / Physical Exam" }
        fields:
          type: array
          items: { $ref: "#/components/schemas/SoapField" }

    SoapJson:
      type: object
      description: >
        Canonical structured SOAP. Each section is a { title, fields[] } object;
        read each field's `label` and `value`. `vitals` is a flat convenience copy
        of extracted vitals. `templateExtension` carries template-specific
        structured fields (e.g. lameness grids) when a custom template was used.
      required: [schemaVersion]
      properties:
        schemaVersion: { type: string, const: "1.0" }
        subjective: { $ref: "#/components/schemas/SoapSection" }
        objective: { $ref: "#/components/schemas/SoapSection" }
        assessment: { $ref: "#/components/schemas/SoapSection" }
        plan: { $ref: "#/components/schemas/SoapSection" }
        vitals:
          type: object
          description: >
            Flat extracted vitals. PRESENT ONLY WHEN at least one vital was
            captured (the whole node is omitted otherwise). Within it, an empty
            string means that particular vital was not captured. `confidence` is
            Coggo's extraction confidence and does not by itself cause the node
            to be included.
          properties:
            weight: { type: string }
            temperature:
              type: object
              properties:
                value: { type: number, description: "Normalized to the unit.", example: 36.89 }
                unit: { type: string, example: celsius }
                raw: { type: number, description: "As stated/recorded before normalization.", example: 98.4 }
            heartRate: { type: string }
            respiratoryRate: { type: string }
            crtSeconds: { type: string }
            painScore: { type: string }
            bcs: { type: string }
            mucousMembranes: { type: string }
            pulseQuality: { type: string }
            hydration: { type: string }
            confidence: { type: string, enum: [low, medium, high] }
        templateExtension:
          type: object
          properties:
            templateId: { type: string }
            fields: { type: object, additionalProperties: true }

    SoapPushEnvelope:
      type: object
      required: [id, occurredAt, meta, formats]
      properties:
        id: { type: string }
        occurredAt: { type: string, format: date-time }
        meta:
          type: object
          required: [noteId, version, externalPatientId]
          properties:
            externalPatientId: { type: string }
            externalAppointmentId: { type: string }
            externalClinicId: { type: string }
            vet:
              type: object
              properties:
                name: { type: string }
                externalStaffId: { type: string }
            noteId: { type: string }
            version: { type: integer, description: Increments on amended notes. }
            signedAt: { type: string, format: date-time }
            language: { type: string, example: en }
        formats:
          type: object
          description: At least one format is always present; typically all three.
          properties:
            json: { $ref: "#/components/schemas/SoapJson" }
            text:
              type: object
              description: >
                Plain-text SOAP split per section so each can be stored as its own
                EMR node. Only sections with captured data are present. When only a
                narrative exists it is returned under `narrative`.
              properties:
                subjective: { type: string }
                objective: { type: string }
                assessment: { type: string }
                plan: { type: string }
                narrative: { type: string, description: "Present instead of the sections when the note is narrative-only." }
            html: { type: string, description: "Compact HTML fragment (no doctype/head/body) for embedding as an EMR record node." }
