openapi: 3.1.0
info:
  title: TRIM FITT Partner API
  version: 1.0.0-read
  summary: Authentication and catalogue for headless partners.
  description: |
    The read surface of the TRIM FITT partner API. A partner key is scoped, bound to one or more
    clinics, rate limited and revocable. The catalogue carries names, forms and safety flags and
    never a price.

    There are no write endpoints yet. Patients, orders, quotes, payment tokens and webhooks are
    documented on the roadmap page and are not part of this contract.
servers:
  - url: https://app.trimfitt.com/api/partner
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Identity
  - name: Catalogue
paths:
  /v1/me:
    get:
      tags: [Identity]
      operationId: getMe
      summary: The key, its scopes, and the clinics it may address
      description: |
        Answers from the principal the credential gate already resolved. It reads no catalogue row
        and no patient row, and it declares no scope.
      responses:
        '200':
          description: The credential resolved
          content:
            application/json:
              schema:
                type: object
                required: [ok, partner, credential, clinics]
                properties:
                  ok:
                    type: boolean
                    const: true
                  partner:
                    type: object
                    required: [id]
                    properties:
                      id:
                        type: string
                        format: uuid
                  credential:
                    type: object
                    required: [keyId, scopes]
                    properties:
                      keyId:
                        type: string
                        description: The public key id. The secret is never returned.
                      scopes:
                        type: array
                        items:
                          type: string
                  clinics:
                    type: array
                    items:
                      type: object
                      required: [id]
                      properties:
                        id:
                          type: string
                          format: uuid
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
  /v1/products:
    get:
      tags: [Catalogue]
      operationId: listProducts
      summary: The catalogue a credential's clinics can order
      description: |
        Keyset pagination over (name, id). `next_cursor` is null exactly when there is no next page.
        A cursor that was not issued by this API is a 400 and is never silently ignored.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          description: Page size. Digits only, no leading zero. Defaults to 25.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          required: false
          description: |
            Opaque position from a previous `next_cursor`. Omit the parameter entirely when you have
            none; an empty value is a 400.
          schema:
            type: string
      responses:
        '200':
          description: One page of the catalogue
          headers:
            x-request-id:
              description: Correlation id; equals request_id in the body.
              schema:
                type: string
            cache-control:
              description: Always no-store, because the answer is one tenant's catalogue.
              schema:
                type: string
                const: no-store
          content:
            application/json:
              schema:
                type: object
                required: [ok, request_id, products, truncated, next_cursor]
                properties:
                  ok:
                    type: boolean
                    const: true
                  request_id:
                    type: string
                    format: uuid
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  truncated:
                    type: boolean
                    description: Same fact as next_cursor being non-null.
                  next_cursor:
                    type: [string, 'null']
                    description: Where to resume, or null on the last page.
        '400':
          description: limit or cursor present and unacceptable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/Unavailable'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: tfk_live_<key id>.<secret>
      description: Shown once at mint or rotation; stored only as a peppered hash.
  schemas:
    Product:
      type: object
      additionalProperties: false
      required:
        - productId
        - name
        - displayName
        - category
        - unit
        - route
        - purchaseGate
        - notFdaApproved
        - isControlledSubstance
        - nameAliases
        - priced
        - orderableInClinicIds
      properties:
        productId:
          type: string
          format: uuid
        name:
          type: string
          description: Strength and pack size live inside the name; there is no separate strength column.
        displayName:
          type: [string, 'null']
        category:
          type: [string, 'null']
        unit:
          type: [string, 'null']
        route:
          type: [string, 'null']
          description: The form, for example subcutaneous, nasal or oral.
        purchaseGate:
          type: [string, 'null']
          description: '`request` means a clinician reviews a request rather than a self-serve purchase.'
        notFdaApproved:
          type: boolean
        isControlledSubstance:
          type: boolean
        nameAliases:
          type: array
          items:
            type: string
        priced:
          type: boolean
          description: Whether an agreed price is configured. Never the amount.
        orderableInClinicIds:
          type: array
          items:
            type: string
            format: uuid
    Error:
      type: object
      additionalProperties: false
      required: [ok, error]
      properties:
        ok:
          type: boolean
          const: false
        error:
          type: string
          enum:
            - validation_failed
            - unauthorized
            - forbidden_clinic
            - forbidden_scope
            - forbidden_ip
            - rate_limited
            - internal_error
            - service_unavailable
  responses:
    Unauthorized:
      description: No credential, or one that did not verify. Do not retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: The key verified and is not allowed (no live clinic grant, or missing scope).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Quota exhausted.
      headers:
        retry-after:
          description: Delta-seconds until the window resets.
          schema:
            type: integer
        ratelimit-limit:
          schema:
            type: integer
        ratelimit-remaining:
          schema:
            type: integer
            const: 0
        ratelimit-reset:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unavailable:
      description: |
        The credential or the quota could not be checked. Retry with the SAME key after the stated
        delay. This is never a statement about the key.
      headers:
        retry-after:
          schema:
            type: integer
            const: 5
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
