# PureMedScript Rx Partner API — machine-readable contract.
#
# Companion to docs/RX-PARTNER-API.md, which is the document a partner reads. This file is what a
# partner's toolchain reads: it generates clients, drives contract tests, and populates a Postman
# or Bruno collection.
#
# VOCABULARY, so the two words that look alike are never confused:
#   - A DOOR is an API endpoint. There are two, and a credential's SCOPE names the one it may knock
#     on: this Rx door (POST /api/rx/orders, scope `rx`) and the PUO door (POST /api/puo/orders,
#     plus orders/cancel and catalog; scope `puo`, docs/puo-partner-api.openapi.yaml).
#   - A RAIL is a FULFILMENT FACILITY — who actually fills the order. Rails are granted per
#     credential and are never endpoints you call. The prescription pharmacy network is reachable
#     only through this Rx door.
#   - PUO and RUO name the SAME API. PUO is the public path and the partner-facing name; RUO
#     ("Research Use Only") is the product designation that door carries, and the word used by
#     internal identifiers a partner may see.
#
# WHAT OpenAPI CANNOT EXPRESS HERE, and where to look instead:
#   - The HMAC signing string. `securitySchemes` can say "these headers exist"; it cannot say what
#     goes into the digest. Section 2 of docs/RUO-PARTNER-API.md is normative for the scheme, and
#     the canonical endpoint NAME at this door is `rx.orders` — the string is
#     `2.{timestamp}.POST.rx.orders..{body}`. Section 1 of RX-PARTNER-API.md is normative.
#   - Consent attestation semantics. The schema can require two `true` booleans; it cannot say
#     that sending them asserts your organisation holds the patient's HIPAA and treatment consent
#     records. Section 2 of RX-PARTNER-API.md is normative.
#   - The allergies / noKnownAllergies exactly-one rule and idempotency semantics (fingerprint
#     excludes referenceNote; identity conflicts). Sections 3–4 of RX-PARTNER-API.md are normative.
#   - Webhooks are described under `webhooks:` (OpenAPI 3.1) so a partner can generate a handler,
#     but the at-least-once, dedupe and revision-ordering rules are prose in RUO-PARTNER-API.md §9,
#     which this door shares verbatim.
#
# Keep this file and the markdown in step. Where they disagree, the markdown wins and this file is
# the bug.

openapi: 3.1.0

info:
  title: PureMedScript Rx Partner API
  version: '2'
  description: |-
    Submit prescription REQUESTS for your patients to a verified prescriber's signing queue on the
    PureMedScript platform, and receive lifecycle webhooks as each one is signed, transmitted and
    shipped. `rx.received` means received for clinical review — never a commitment to dispense.
    See docs/RX-PARTNER-API.md for the normative guide.

    **Two doors, two scopes.** A door is an API endpoint; a credential carries a scope naming the
    door it may knock on. This document describes the **Rx door** — `POST /api/rx/orders`, scope
    `rx`. The other is the **PUO door** — `POST /api/puo/orders`, plus `/api/puo/orders/cancel` and
    `/api/puo/catalog`, scope `puo`, documented in `docs/puo-partner-api.openapi.yaml`. A key issued
    for one door cannot call the other. Ask your account manager for the scope by name.

    **PUO and RUO are two names for the same API.** The public path and the canonical
    partner-facing name is **PUO** (`/api/puo`). **RUO** — "Research Use Only" — is the product
    designation for what that door carries, and it is the word used by the internal identifiers you
    may see: `ruoProductId`, order ids prefixed `ruo_`, and some support and error text. They mean
    the same API, not a second one.

    **"Rail" means a fulfilment facility, never an endpoint.** Rails are the facilities that
    actually fill an order — an office-use / research warehouse, a patient-direct warehouse, the
    prescription pharmacy network. They are granted per credential, and the prescription pharmacy
    network is reachable only through this Rx door.

    **Credentials are issued in sandbox.** This door requires a LIVE credential, so a sandbox key is
    refused with `sandbox_not_supported`. A live key is enabled for your account only after we have
    proven the fulfilment path end to end for you. Plan your launch against that sequence rather
    than against this document alone.
  contact:
    name: PureMedScript integrations
    url: https://puremedscript.com

servers:
  - url: https://puremedscript.com/api/rx

security:
  - PartnerSignature: []

tags:
  - name: orders

paths:
  /orders:
    post:
      tags: [orders]
      operationId: submitPrescriptionRequest
      summary: Submit a prescription request
      description: >-
        Sign with endpoint name `rx.orders` and an empty query. Requires the `rx` scope on a LIVE
        credential bound to exactly one clinic account. Idempotent on `partnerOrderId`: an
        identical resubmission returns 200 with `duplicate: true`; different clinical content under
        the same id returns 409 `idempotency_key_reuse`. A refused request claims nothing and is
        resubmittable under the same id. See sections 4–5 of the guide.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RxSubmission' }
      responses:
        '201':
          description: >-
            Received for clinical review by the prescriber. NOT a commitment to dispense — the
            prescriber can decline and the pharmacy can refuse.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RxOrderEnvelope' }
        '200':
          description: Duplicate of a request already received. The ORIGINAL order is returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RxOrderEnvelope' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '405': { $ref: '#/components/responses/Error' }
        '409':
          description: >-
            `idempotency_key_reuse` (same partnerOrderId, different content),
            `patient_identity_conflict` (partnerPatientRef bound to a different name/DOB) or
            `prescriber_ambiguous`. None is retryable unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '413': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Error' }

webhooks:
  rxStatusChanged:
    post:
      operationId: onRxStatusChanged
      summary: Delivered to your registered HTTPS endpoint on every prescription lifecycle change
      description: >-
        Same outbox, signing, headers, at-least-once delivery and retry ladder as the PUO door —
        RUO-PARTNER-API.md section 9 is normative. Dedupe on `eventId`; discard any event whose
        `revision` is lower than the highest applied for that order. The payload is PHI-FREE by
        hard contract: your partnerOrderId, the SKU you submitted, and our status word — nothing
        else. `rx.delivered` is reserved and NOT YET EMITTED: `shipped` is the last state the
        platform asserts. Verify the signature over `{timestamp}.{rawBody}`.
      parameters:
        - name: X-PMS-Timestamp
          in: header
          required: true
          schema: { type: string }
        - name: X-PMS-Signature
          in: header
          required: true
          schema: { type: string }
          description: hex(HMAC_SHA256(signingSecret, "{timestamp}.{rawBody}"))
        - name: X-PMS-Signature-Previous
          in: header
          required: false
          schema: { type: string }
          description: Present only during a signing-secret rotation. Accept if EITHER verifies.
        - name: X-PMS-Event-Id
          in: header
          required: true
          schema: { type: string }
        - name: X-PMS-Event-Type
          in: header
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [eventId, eventType, occurredAt, revision, order]
              properties:
                eventId:
                  type: string
                  description: Stable dedupe key, `{orderId}:{eventType}:{revision}`.
                eventType:
                  type: string
                  enum: [rx.received, rx.rejected, rx.signed, rx.transmitted, rx.shipped, rx.delivered]
                occurredAt: { type: string, format: date-time }
                revision: { type: integer, minimum: 1 }
                order:
                  type: object
                  description: >-
                    Carries no patient identity, by hard contract. Correlate by partnerOrderId.
                    The three parcel keys are OPTIONAL and appear only once a parcel reference
                    exists (in practice from rx.shipped onward); they are carrier references,
                    not PHI, and are absent rather than empty before that.
                  required: [partnerOrderId, sku, status]
                  properties:
                    partnerOrderId: { type: string }
                    sku: { type: string }
                    status:
                      type: string
                      enum: [received, signed, transmitted, shipped, rejected]
                    carrier:
                      type: string
                      description: Present only once a parcel reference exists. Absent, never empty.
                    trackingNumber:
                      type: string
                      description: Present only once a parcel reference exists. Absent, never empty.
                    trackingUrl:
                      type: string
                      description: Present only once a parcel reference exists. Absent, never empty.
      responses:
        '200':
          description: Acknowledged. Any 2xx is treated as success.

components:
  securitySchemes:
    PartnerSignature:
      type: apiKey
      in: header
      name: X-PMS-Api-Key
      description: |-
        TWO FACTORS. The API key identifies you; an HMAC signature proves the request was not
        altered and is not a replay. Every request must ALSO carry X-PMS-Timestamp (epoch ms),
        X-PMS-Signature-Version (`2`) and X-PMS-Signature, computed as
        hex(HMAC_SHA256(signingSecret, "{version}.{timestamp}.{METHOD}.{endpoint}.{query}.{rawBody}")).
        `endpoint` is the canonical NAME — at this door, `rx.orders`, never the URL path and never
        the PUO submission's `orders`. `query` is empty on POST. Requests more than 5 minutes from
        our clock are refused. The credential must carry the `rx` scope and be LIVE — sandbox is
        refused with `sandbox_not_supported`. OpenAPI cannot express a composite scheme like this;
        RUO-PARTNER-API.md section 2 and RX-PARTNER-API.md section 1 are normative.

        THE TWO SCOPE NAMES, so you can ask for the right one: `rx` opens this door
        (`/api/rx/orders`); `puo` opens the PUO door (`/api/puo/orders`, `/api/puo/orders/cancel`,
        `/api/puo/catalog`). A key that authenticates but lacks the scope for the door it called is
        refused `partner_disabled` (403) — the same code as a genuinely deactivated account, with
        `detail` distinguishing the two. That refusal is fixed by us adding the scope to your
        credential, not by re-activating your account, so name the scope you need — `rx` or `puo` —
        when you contact us.

  responses:
    Error:
      description: Refused. `code` is stable; `retryable` says whether trying again can help.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Too many requests. Honour Retry-After.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Whole seconds to wait.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    ErrorEnvelope:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, const: false }
        error:
          type: object
          required: [code, message, retryable]
          properties:
            code:
              type: string
              description: >-
                Stable. Program against this, never against `message`. The first four are the
                shared front door's (see RUO-PARTNER-API.md section 10); `clinical_payload_not_open`
                and `rx_rail_not_open` are RETIRED — contractual, never deleted, no longer returned.
              enum:
                - unauthenticated
                - partner_disabled
                - not_a_route
                - rate_limited
                - method_not_allowed
                - invalid_json
                - missing_field
                - unknown_field
                - invalid_field
                - consent_not_attested
                - product_not_recognized
                - patient_identity_conflict
                - idempotency_key_reuse
                - order_too_large
                - sandbox_not_supported
                - invalid_npi
                - prescriber_not_permitted
                - prescriber_not_recognized
                - prescriber_ambiguous
                - partner_not_provisioned
                - clinical_payload_not_open
                - rx_rail_not_open
                - storage_unavailable
            message: { type: string }
            retryable:
              type: boolean
              description: NEVER retry a false. It will fail identically.
            detail:
              type: string
              description: >-
                For missing_field/unknown_field/invalid_field, the dotted field path (and, for
                invalid_field, the expectation).

    RxConsent:
      type: object
      description: >-
        THE ATTESTATION. Sending both booleans as literal true asserts your organisation holds the
        patient's HIPAA acknowledgment and treatment consent records. Anything else returns 422
        `consent_not_attested` — an organisational remedy, not a payload fix.
      required: [hipaaAcknowledged, treatmentConsent]
      additionalProperties: false
      properties:
        hipaaAcknowledged: { type: boolean, const: true }
        treatmentConsent: { type: boolean, const: true }

    RxAddress:
      type: object
      required: [street, city, state, zip]
      additionalProperties: false
      properties:
        street: { type: string, minLength: 1, maxLength: 120 }
        city: { type: string, minLength: 1, maxLength: 120 }
        state:
          type: string
          description: Two-letter US state or territory code (50 states + DC, PR, VI, GU, AS, MP). Upper-cased on intake.
        zip:
          type: string
          pattern: '^\d{5}(-\d{4})?$'

    RxPatient:
      type: object
      description: >-
        Pass-through identity — you are the source of truth. One platform chart per
        (credential, partnerPatientRef); name and DOB are identity and are never silently
        rewritten (409 `patient_identity_conflict` on mismatch); contact, address, allergies and
        consent are refreshed on every accepted submission. Send a non-empty `allergies` array OR
        `noKnownAllergies: true` — never neither, never both.
      required: [partnerPatientRef, firstName, lastName, dob, sex, phone, address, consent]
      additionalProperties: false
      properties:
        partnerPatientRef:
          type: string
          maxLength: 128
          description: Your stable identifier for this human. Use a distinct ref per person.
        firstName: { type: string, minLength: 1, maxLength: 100 }
        lastName: { type: string, minLength: 1, maxLength: 100 }
        dob:
          type: string
          pattern: '^\d{4}-\d{2}-\d{2}$'
          description: A real calendar date, not in the future, less than 130 years ago.
        sex:
          type: string
          enum: [male, female]
          description: Case-insensitive on intake; `unspecified` is refused — dispensing pharmacies require sex.
        phone:
          type: string
          description: Must contain a 10-digit US number; a leading `1` country code is tolerated. Stored as bare 10 digits.
        email:
          type: string
          maxLength: 254
          description: Optional. Trimmed and lower-cased; must not contain `+pmsp-` (reserved).
        address: { $ref: '#/components/schemas/RxAddress' }
        allergies:
          type: array
          maxItems: 50
          items: { type: string, minLength: 1, maxLength: 120 }
        noKnownAllergies:
          type: boolean
          const: true
          description: Explicit "no known allergies". Mutually exclusive with a non-empty `allergies`.
        consent: { $ref: '#/components/schemas/RxConsent' }

    RxSig:
      type: object
      required: [dose, route, frequency]
      additionalProperties: false
      properties:
        dose: { type: string, minLength: 1, maxLength: 200 }
        route: { type: string, minLength: 1, maxLength: 200 }
        frequency: { type: string, minLength: 1, maxLength: 200 }
        duration: { type: string, maxLength: 200 }
        prn:
          type: boolean
          description: Literal true means as-needed; anything else is treated as false.

    RxMedication:
      type: object
      required: [sku, quantity, sig, refillsAuthorized]
      additionalProperties: false
      properties:
        sku:
          type: string
          maxLength: 64
          description: From your PureMedScript product sheet. Resolved server-side; 422 `product_not_recognized` on a miss.
        quantity: { type: integer, minimum: 1, maximum: 1000 }
        sig: { $ref: '#/components/schemas/RxSig' }
        daysSupply: { type: integer, minimum: 1, maximum: 365 }
        refillsAuthorized:
          type: integer
          minimum: 0
          maximum: 11
          description: REQUIRED, not defaulted — 0 means no refills and must be said explicitly.
        dispenseAsWritten:
          type: boolean
          description: Literal true means dispense as written; anything else is treated as false.
        icd10:
          type: array
          maxItems: 10
          items:
            type: string
            pattern: '^[A-TV-Z][0-9][0-9A-Z](\.[0-9A-Z]{1,4})?$'
          description: Upper-cased on intake; a malformed code is refused.

    RxSubmission:
      type: object
      description: >-
        STRICT ALLOW-LIST AT EVERY LEVEL. Any key not named here, at any depth, returns 400
        `unknown_field` with the dotted path in `detail`. No pharmacy selection, no payment, no
        insurance, no shipping.
      required: [partnerOrderId, prescriber, patient, medication]
      additionalProperties: false
      properties:
        partnerOrderId:
          type: string
          maxLength: 256
          description: Your identifier, unique per credential. This is the idempotency key.
        prescriber:
          type: object
          required: [npi]
          additionalProperties: false
          properties:
            npi:
              type: string
              description: >-
                10-digit NPI with a valid CMS check digit; non-digits are stripped. Must be on
                your credential's allowed list (403 `prescriber_not_permitted` otherwise).
        patient: { $ref: '#/components/schemas/RxPatient' }
        medication: { $ref: '#/components/schemas/RxMedication' }
        referenceNote:
          type: string
          maxLength: 256
          description: Your own free text. Excluded from the idempotency fingerprint.

    RxOrder:
      type: object
      description: >-
        The whole partner-facing projection, deliberately: no patient identity (you hold it), no
        internal identifiers, no prescriber identity beyond the NPI you submitted.
      required: [partnerOrderId, status, sku, prescriberNpi, receivedAt, note]
      properties:
        partnerOrderId: { type: string }
        status:
          type: string
          enum: [received, signed, transmitted, shipped, rejected]
          description: >-
            Walks received → signed → transmitted → shipped; `rejected` is the refusal terminal.
            `received` means received for clinical review — not a commitment to dispense.
        sku: { type: string }
        prescriberNpi: { type: string }
        receivedAt: { type: string, format: date-time }
        note: { type: string }

    RxOrderEnvelope:
      type: object
      required: [ok, duplicate, order]
      properties:
        ok: { type: boolean, const: true }
        duplicate:
          type: boolean
          description: TRUE means this partnerOrderId was already received; the ORIGINAL is returned.
        order: { $ref: '#/components/schemas/RxOrder' }
