# GENERATED by docs-site/build.cjs — do not edit. Sources: docs/puo-partner-api.openapi.yaml + docs/RUO-PARTNER-API.md
# PureMedScript Partner API (PUO / Research Use Only) — machine-readable contract.
#
# Companion to docs/RUO-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.
#
# NAMING — PUO AND RUO ARE THE SAME API, NOT TWO. The public path and the canonical partner-facing
# name is PUO (`/api/puo`). RUO ("Research Use Only") is the product designation for what this door
# carries, and it is the word used by the internal identifiers a partner may see: `ruoProductId`,
# order ids prefixed `ruo_`, and some support and error text.
#
# 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 PUO door (POST /orders, /orders/cancel, /catalog; scope `puo`) and the Rx door
#     (POST /api/rx/orders; scope `rx`, rx-partner-api.openapi.yaml (published beside the page)).
#   - A RAIL is a FULFILMENT FACILITY — who actually fills the order. Rails are granted per
#     credential (`allowedRails`) and are never endpoints you call. There are three: two
#     PureMedScript-owned warehouses (office-use/research and patient-direct) and a pharmacy routing
#     rail — a licensed U.S. pharmacy that fills research orders. This PUO door dispatches to all
#     three. A submission whose lines fulfil from more than one of them is SPLIT into one parcel per
#     facility; it remains ONE order. The prescription pipeline is a different thing again, and is
#     reachable only through the Rx door.
#
# 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 the markdown is normative for that, and
#     functions/src/ruo/partnerApi.ts#canonicalSigningString is the implementation.
#   - Idempotency semantics. The same POST returns 201 then 200, or 409 if the contents changed.
#     Section 6 is normative.
#   - Webhooks are described under `webhooks:` (OpenAPI 3.1) so a partner can generate a handler,
#     but the at-least-once and revision-ordering rules are prose in section 9.
#
# 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 Partner API (Research Use Only)
  version: '2'
  description: |
    ## What this API is — and what it is not

    **The platform runs TWO doors, and this page documents both.** The **research door** (`/api/puo/*`,
    this chapter) moves RUO product with no clinical fields; the **prescription door**
    (`POST /api/rx/orders`, its own chapter further down this page) moves real prescriptions —
    prescriber, patient, sig — into a licensed dispensing pipeline. Your credential's scopes say which
    doors you may call, and the same pharmacy can sit behind both: through the research door it acts
    as a **fulfilment facility** filling research orders ("Fulfilment rails — who actually fills your order"), and through the prescription door it
    acts as a **dispensing pharmacy** filling prescriptions. The two flows never mix, by design and by
    law — which is exactly why the doors are separate.

    This chapter's door moves **research product and its shipping status**. Nothing else.

    | | |
    |---|---|
    | **Is** | RUO lyophilized peptides for laboratory research — fulfilled from the facilities in "Fulfilment rails — who actually fills your order", which include a licensed U.S. pharmacy |
    | **Is not** | A prescription door. No prescriber, patient, DEA, state licence, diagnosis, sig or refills travel through it — prescriptions travel through `POST /api/rx/orders`, the other door on this page. This row scopes the research door only, never the platform |
    | **Is not** | A door that accepts money fields. No price, amount or card field is ever **accepted** in a request. The one money field ever **returned** is your own `cogsUsd` on `/catalog` under a live key — your cost, at your agreed rates. See "How these orders are billed" |

    Three consequences worth reading twice:

    **Clinical and pricing fields are rejected, not ignored — at any depth.** If you send `patient`,
    `prescriber`, `dea`, `price` or similar *anywhere* in the payload, including nested inside `shipTo`
    or a line, the request fails with `clinical_field_rejected` or `price_field_rejected` and the
    `detail` names the exact path. We refuse rather than silently drop, because a partner who believes
    they submitted a clinical order and received a `201` would be wrong in a way that matters.

    **Unrecognised fields are also rejected.** This API uses an allow-list, not a deny-list: any key it
    does not consume returns `unknown_field` naming the path. A field we ignored silently would be a
    field you believed had arrived.

    **Money never enters a request, and one money field leaves.** No amount is accepted anywhere in a
    request body — `price_field_rejected` enforces it at any depth. In responses, exactly one money
    field exists: **`cogsUsd` on `/catalog`, under a live production key** — your own cost per unit at
    your agreed rates, so the number you integrate against and the number on your invoice are the same
    number. Sandbox keys see no money. Billing itself is transacted outside these endpoints — see "How these orders are billed".

    ### How these orders are billed

    **PureMedScript invoices you directly for the orders you submit here.** You are billed at the rates
    agreed with you, plus shipping, and you settle with PureMedScript — not with the pharmacy.

    | | |
    |---|---|
    | **Product** | Your agreed per-product rate **in force when you place the order** — the `cogsUsd` that `/catalog` shows your live key at that moment ("Your catalog"). Where no partner-specific rate is set, our standard partner list price applies. A rate change after you place an order never re-prices that order, whether you are billed per order or weekly |
    | **Shipping** | **$25 per parcel** — never per vial, per line or per unit. The unit is the parcel: one fulfilment chunk going to one destination. An order that ships as one parcel is one $25 charge, and four vials in that parcel is still $25. An order that **splits** into two parcels ("Fulfilment rails — who actually fills your order") is **two $25 charges — even when both parcels go to the same address**, because two boxes were shipped. The `201` tells you the count: `expectedParcels` is the number of parcels, and it is the number the fee was charged per |
    | **Invoice** | **Itemised per product at your agreed rates — your cost basis per line — plus one shipping row, totalled per order against the order ID.** Your card statement shows only the order ID and the total |

    **When you are billed is a setting on your partner record**, and your account manager sets it:

    - **Per order** — the card on file is charged when the order is placed, and an `order.charged` webhook confirms it. A charged order that does not go ahead is refunded automatically: whether **you** cancel it ("Cancel an order") or **we** reject or cancel it before anything in it has been picked, the charge is reversed in full through the same processor path, and when the processor accepts the reversal an `order.refunded` webhook confirms it — voided before the batch settles, refunded after — at the same `revision` as the `order.cancelled` or `order.rejected` event it follows ("Webhooks"). If the processor refuses the reversal (for example the transaction is outside its void/refund window) the cancellation or rejection still stands, no `order.refunded` is sent, and PureMedScript completes the reversal by hand. Treat `order.refunded` as the confirmation, and its absence after a cancel or a rejection as a reason to contact us — never as proof the charge was reversed.
    - **Weekly** — orders accrue and are invoiced as one weekly total. A weekly invoice is **due on receipt**. If we hold a card on file for a weekly account, it is kept as a backup only and is never charged automatically for a weekly invoice.

    Either way the amount is the same; only the timing differs. Both are priced from the rate in force
    when you place the order, so a rate change between placing an order and its shipping moves neither
    the charge nor the invoice line. Ask us to change the timing and we will — there is no API for it,
    deliberately, because it decides when money moves.

    > **If you are billed per order, the parcel count reaches you AFTER the card is charged. Read this
    > before you integrate the shipping fee.**
    >
    > The charge is made during submission, and `expectedParcels` is a field on the order in the `201`
    > response — so the moment you can first read "this order will ship as two parcels" is a moment
    > after two $25 shipping charges are already on the card. There is no quote endpoint, no
    > pre-flight check, and no field that asks us not to split: which facilities fill your lines is our
    > routing decision, not a choice you make ("Fulfilment rails — who actually fills your order").
    >
    > What you can do about it: `expectedParcels` is on the `201`, so you can reconcile the charge
    > immediately rather than discovering the second fee on an invoice. And if a two-parcel order is not
    > what you wanted, cancelling it while nothing has been picked reverses the whole charge in full
    > ("Cancel an order") — the shipping rows included.

    **On your card statement** the transaction is referenced by the **order ID** we returned on the
    `201` — `ruo_` followed by 32 hex characters ("Submit an order") — and the description is the standard
    PureMedScript wording every other invoice carries. Quote that id and we can find the order
    immediately.

    **A price you send is still refused.** `price_field_rejected` is unchanged: we set the price from
    your agreement, and an amount arriving in a request body would mean one of us is wrong about what
    this order costs. Better to fail loudly at submission than to reconcile it later.

    ## Fulfilment rails — who actually fills your order

    A **rail** is a fulfilment facility. It is not an endpoint, you never name one in a request, and you
    never choose one. The facility is decided per line from the product, the destination state, and the
    order's `recipientType`: an `organization` order fulfils office-use, an `individual` order fulfils
    patient-direct — from our own warehouse or from the pharmacy routing rail, depending on the product.
    The same product can therefore fulfil from a *different* facility depending on which `recipientType`
    you send, so do not cache a product-to-facility map learned from one order and reuse it for an order
    of the other recipient type.

    **There are three facilities**, and your credential is provisioned with the subset you may order
    from:

    | Facility | What it is | Reachable from |
    |---|---|---|
    | Office-use / research warehouse | PureMedScript's own warehouse. Clinic stock, no named patient. Fills `organization` orders | This PUO door |
    | Patient-direct warehouse | PureMedScript's own warehouse. Named patient, shipped to the home. Fills `individual` orders | This PUO door |
    | Pharmacy routing rail | A licensed U.S. pharmacy that fills research orders for us — patient-direct only, so `individual` orders only | This PUO door |

    **The pharmacy routing rail never fills an `organization` order.** An `organization` order is filled
    by our office-use warehouse. Through this door the pharmacy routing rail fills patient-direct orders
    only, so a product that only the pharmacy routing rail carries can be ordered on an `individual` order;
    on an `organization` order it is refused `product_unavailable`.

    **What this door does not reach.** It does not reach the **prescription** pipeline. A prescription
    is a different product with different law behind it and is submitted only through the Rx door
    (`POST /api/rx/orders`, scope `rx`). The doors are separate so the two can never be confused for one
    another.

    **Which facility filled your order is never published to you.** No response on this door names one:
    not `/catalog`, not the order object, not a webhook payload, not a refusal `detail`. What you are
    told is how many parcels are coming — never who packed them. That is deliberate and it is not
    coming: our fulfilment topology is ours, and an integration built on it would break the first time
    we moved a product.

    #### One submission is always ONE order — and it may ship as more than one parcel

    When the lines in one submission fulfil from **different** facilities, we do not refuse the cart and
    we do not ask you to resubmit it. We **split** it.

    The order stays one order. One `partnerOrderId`, one `orderId`, one read-back, one charge, one
    cancellation, one event stream. Underneath it is divided into one **fulfilment chunk** per facility,
    and each chunk ships as its own parcel with its own carrier and tracking number, arriving as an
    entry in `shipments[]` ("Shipments").

    The splitter is N-ary — it produces as many chunks as the cart needs — but with today's catalog and
    this door the most an order can reach is **two**: an `individual` order that mixes a line our own
    warehouse fills with a line the pharmacy routing rail fills. An `organization` order fulfils from one
    facility and always ships as one parcel.

    **`expectedParcels` tells you how many.** Every order object carries `expectedParcels`, an integer
    of at least 1 — the number of separate parcels this order will ship as. It is on the `201`, on every
    read-back and in every webhook payload. It is the number to compare `shipments.length` against, and
    it is the number the shipping fee was charged per. It is a count and nothing more: it names no
    facility, and an order that was accepted before splitting existed reports `1`.

    **Each parcel is its own $25 shipping charge.** Two parcels are two $25 charges, even when both go
    to the same address ("How these orders are billed"). If your account is billed per order, those charges are on the card
    before you can read `expectedParcels` — the note in "How these orders are billed" says so plainly, and it is the one thing on
    this page most worth reading twice.

    **`mixed_carrier_order` is no longer returned.** It used to refuse exactly this cart and tell you to
    resubmit it as two orders under two ids. It has no producer now. Its row stays in "Errors" because a
    published code is contractual and is never deleted — but if your integration has a branch for it,
    that branch is dead code.

    **What your key is granted.** Your account manager provisions which facilities your credential may
    order from; a grant is per credential, and the set can be widened later without any change on your
    side. The grant is checked before anything else, so a line that fulfils from a facility you were
    never enabled for is refused whatever else is true of it. (Rail grants have no effect on sandbox
    traffic at all — see "Sandbox".)

    | If you see | It means | What to do |
    |---|---|---|
    | `rail_not_enabled` (403) | A product in this order fulfils from a facility your credential is not provisioned for | Contact your account manager to have that facility enabled on your key. Nothing about your payload is wrong — do not retry unchanged, and do not re-sign |
    | `product_unavailable` (422) | Among its other causes: the line fulfils somewhere this door does not dispatch to at all, or the catalog entry is not fully set up on our side | Not a payload problem. `detail` always begins with the offending `<ruoProductId>:` and then states the cause — `not orderable through this API` (no grant changes it) or `not currently routable` (our setup, not yours). Branch on the **code**, never on the detail text. See "Errors" |

    **A refusal refuses the whole submission — and how much of it you are told depends on the refusal.**
    If any line cannot be placed, no order is created: we will not accept half a cart and ship it,
    leaving you to discover the rest never existed. A refused submission consumes nothing and claims no
    `partnerOrderId`.

    **Fulfilment refusals name every line.** `rail_not_enabled` and the routing form of
    `product_unavailable` are decided over the whole cart at once, so they report **every** offending
    line, each prefixed with its own `ruoProductId` and joined with `; ` in `detail` — a five-line cart
    with three fulfilment problems takes one round trip to diagnose.

    **Validation refusals name the first line only.** `product_not_permitted`, `unknown_product`,
    `invalid_quantity`, `quantity_not_allowed`, `destination_not_permitted`, and `product_unavailable`
    when it is caused by the item's catalog state (withdrawn, unpriced, not fully set up), are checked
    line by line and stop at the first failure. `detail` names that one line. Fix it and resubmit; if a
    second line has the same class of problem you will hear about it on the next attempt. Checking
    `/catalog` before you submit — `orderable`, `unavailableReason`, `minimumOrderQuantity`,
    `orderIncrement`, `restrictedStates` — answers all five of these locally and in one request.

    **A grant problem outranks every other refusal in the cart.** If one line is `rail_not_enabled` and
    another is `product_unavailable`, the answer is `rail_not_enabled` — because enabling the facility
    can change where lines land and may dissolve the rest of the problem. Fix the grant first, then
    resubmit and read what is left.

    **`/catalog` publishes no facility field, and none is coming.** Which facility a line fulfils from
    depends on the destination state and the `recipientType` of the order, neither of which `/catalog`
    receives, so there is no honest per-product answer to publish there. You no longer need one: you are
    not being asked to do the routing.

    ---

    ## Authentication

    Every request carries **four headers**. The API key says who you are; the signature proves the
    request was not altered and cannot be re-pointed at different content.

    | Header | Value |
    |---|---|
    | `X-PMS-Api-Key` | The key issued to you. Shown once at issuance and never recoverable |
    | `X-PMS-Timestamp` | Current time, **epoch milliseconds** |
    | `X-PMS-Signature-Version` | `2` |
    | `X-PMS-Signature` | `hex(HMAC_SHA256(secret, signingString))` — see below |

    ### The signing string

    ```
    {version}.{timestamp}.{METHOD}.{endpoint}.{query}.{rawBody}
    ```

    | Part | Value |
    |---|---|
    | `version` | `2` |
    | `timestamp` | Identical to the `X-PMS-Timestamp` header |
    | `METHOD` | Upper-case HTTP method — `POST` or `GET` |
    | `endpoint` | The **canonical endpoint name**, not the URL path — see the table in "Endpoints" |
    | `query` | Canonical query string, or empty. **Only for `GET`** — always empty on `POST` |
    | `rawBody` | The exact bytes you send. Empty string for `GET` |

    **`endpoint` is a name, not a path.** Sign `orders`, not `/api/puo/orders`. This is deliberate: it
    keeps your signature valid regardless of how the request is routed on our side.

    **Canonical query** — sort parameters by name, URL-encode each key and value, join with `&`:
    `partnerOrderId=RMM-2026-00184`. Sorting means your HTTP client reordering parameters cannot break
    your signature.

    Requests more than **5 minutes** from our clock are refused. Sync your clock.

    > **Encode exactly as JavaScript's `encodeURIComponent` does — a space is `%20`, never `+`.**
    > Do not build the canonical query with form-encoding helpers (`URLSearchParams`, Python's
    > `urlencode`): they encode a space as `+`, the server re-encodes with `encodeURIComponent`,
    > and your signature fails **only when a value contains a space or one of `!'()~`** — an
    > intermittent, value-dependent 401 that passes every happy-path test.
    >
    > **Within the 5-minute window, an identical request verifies again by design.** A replayed submit
    > answers as a duplicate ("Idempotency — retry safely"); a replayed read returns your own order. The signature's job is to
    > stop a captured request being *altered* or re-pointed, not to make the identical bytes
    > single-use.
    >
    > If your timestamp is refused as outside tolerance and your clock is right, check the **unit**:
    > the header is epoch **milliseconds**. A seconds value is refused with a hint saying so.

    ```js
    const crypto = require('crypto');

    function signedHeaders({ apiKey, secret, method, endpoint, query = '', rawBody = '' }) {
      const timestamp = String(Date.now());
      const signingString = `2.${timestamp}.${method.toUpperCase()}.${endpoint}.${query}.${rawBody}`;
      return {
        'X-PMS-Api-Key': apiKey,
        'X-PMS-Timestamp': timestamp,
        'X-PMS-Signature-Version': '2',
        'X-PMS-Signature': crypto.createHmac('sha256', secret).update(signingString).digest('hex'),
        'Content-Type': 'application/json',
      };
    }
    ```

    > **Sign the exact bytes you send.** Build the JSON string once, sign *that string*, and post *that
    > string*. Re-serializing between signing and sending reorders keys and the signature stops matching
    > — the most common integration failure and the hardest to see.

    ### Why the whole request is signed

    Version 1 signed only the timestamp and the body. A `GET` has no body, so every `GET` signed the
    same constant — which made one captured signature a five-minute key to read *any* of your orders.
    Binding the method, endpoint and query fixes that, and stops a signature for one call being replayed
    against another.

    ---

    ## Endpoints

    | Call | Method & path | `endpoint` to sign |
    |---|---|---|
    | Submit an order | `POST /api/puo/orders` | `orders` |
    | Read an order back | `GET /api/puo/orders?partnerOrderId=…` | `orders` |
    | Cancel an order | `POST /api/puo/orders/cancel` | `orders.cancel` |
    | List your catalog | `GET /api/puo/catalog` | `catalog` |

    > **The models on the published page describe this door only.** The prescription door's request and
    > payload schemas — `RxSubmission`, `RxPatient`, `RxMedication`, `RxSig`, `RxOrder` and its own error
    > envelope — ship as a separate machine-readable file,
    > [`rx-partner-api.openapi.yaml`](./rx-partner-api.openapi.yaml), because that door roots at a
    > different base path. Its narrative reference is the prescription-door chapter further down this
    > page.

    ---

    ## Submit an order

    > Requests larger than the platform transport limit (~10 MB) are refused upstream, **before this
    > API runs**, and that refusal does not carry the "Errors" JSON envelope. Everything within the
    > documented limits below stays far under it.

    ```
    POST /api/puo/orders
    ```

    ```json
    {
      "partnerOrderId": "RMM-2026-00184",
      "recipientType": "organization",
      "recipientReference": "",
      "referenceNote": "Rush — bench work Thursday",
      "shipTo": {
        "name": "Dr. Alicia Moreno",
        "company": "Northlake Research Labs",
        "street1": "418 Cedar Point Way",
        "street2": "Suite 210",
        "city": "Boulder",
        "state": "CO",
        "zip": "80301",
        "country": "US",
        "phone": "3035550142",
        "email": "lab@northlakeresearch.com"
      },
      "lines": [
        { "ruoProductId": "ruo-bpc-157-tb-500-05mg-05mg-03ml", "partnerSku": "BPC-TB-5", "quantity": 10 }
      ]
    }
    ```

    **Fields**

    | Field | Required | Notes |
    |---|---|---|
    | `partnerOrderId` | yes | Your identifier. Unique per partner. **This is the idempotency key** |
    | `recipientType` | **yes** | `organization` or `individual`. See below |
    | `recipientReference` | no | Your own opaque reference for the recipient. Echoed, never interpreted |
    | `shipTo.name` | yes | |
    | `shipTo.street1`, `city`, `state`, `zip` | yes | `state` is validated; a full state name is accepted and normalised |
    | `shipTo.company`, `street2`, `phone`, `email` | no | |
    | `shipTo.country` | no | Defaults to `US`. **`US` only** — other destinations are refused |
    | `lines[].ruoProductId` | yes | From `/catalog`. Must be enabled for your account |
    | `lines[].quantity` | yes | Positive integer, subject to the item's MOQ and increment |
    | `lines[].partnerSku` | no | Echoed for your reconciliation. Never used to resolve product |
    | `referenceNote` | no | Free text, up to 1000 characters. Never interpreted, and **not echoed** — it is stored with the order for our support and warehouse staff, but it does not appear in the response, in a read-back or in a webhook payload. Use `recipientReference` for a value you need returned to you |

    ### `recipientType` is mandatory

    `organization` — shipping to a laboratory, clinic or company.
    `individual` — shipping to a **named person** at their own address.

    Both are supported. It is required rather than defaulted because it is not inferable from a name,
    and it changes how the parcel is labelled and handled on our side. An order that does not say which
    it is gets `missing_field` rather than a guess.

    Sending `individual` does **not** make this a clinical order. It remains RUO product, and the
    clinical field refusals in "What this API is — and what it is not" still apply in full.

    ### Limits

    **Exceed these and the order is REFUSED:**

    | | Limit | Refusal |
    |---|---|---|
    | Lines per order | 250 | `order_too_large` (413) |
    | Quantity per line | 100,000 | `invalid_quantity` (422) |

    **Exceed these and the value is silently TRUNCATED to fit, not refused:**

    | | |
    |---|---|
    | `referenceNote` | 1,000 characters |
    | `reason` (cancel) | 1,000 characters |
    | `shipTo.zip`, `shipTo.phone` | 32 characters |
    | `shipTo.country` | 2 characters — ISO-3166-1 alpha-2 |
    | Other text fields | 256 characters — this includes `partnerOrderId`, `recipientReference` and `lines[].partnerSku` |

    Truncation matters most for `partnerOrderId`: it is cut to 256 characters **before the order's
    identity is derived from it**, so two ids that differ only past the limit name the same order. The
    second submission then answers as a retry of the first — `200` with `"duplicate": true` if its
    contents match, `409 idempotency_key_reuse` if they do not ("Idempotency — retry safely"). Keep `partnerOrderId` comfortably
    inside the limit and unique on its own prefix.

    ### Response — `201 Created`

    ```json
    {
      "ok": true,
      "duplicate": false,
      "order": {
        "orderId": "ruo_9f2c1e77a4b8d3e6019c4f5b2a7d8e30",
        "partnerOrderId": "RMM-2026-00184",
        "status": "accepted",
        "revision": 1,
        "mode": "live",
        "recipientType": "organization",
        "recipientReference": "",
        "lines": [
          { "ruoProductId": "ruo-bpc-157-tb-500-05mg-05mg-03ml", "partnerSku": "BPC-TB-5",
            "quantity": 10, "description": "BPC-157 / TB-500", "strengthMg": "5mg" }
        ],
        "shipTo": { "…": "as stored — trimmed, state upper-cased, email lower-cased" },
        "carrier": null,
        "trackingNumber": null,
        "trackingUrl": null,
        "shipments": [],
        "expectedParcels": 1,
        "rejectionReason": null,
        "createdAt": "2026-08-21T22:14:08.115Z",
        "updatedAt": "2026-08-21T22:14:08.115Z"
      }
    }
    ```

    `orderId` is always `ruo_` followed by 32 hex characters.

    `expectedParcels` is the number of separate parcels this order will ship as — always an integer of
    at least 1, and the only key the split adds to this object. See "Fulfilment rails — who actually fills your order" and "Shipments".

    ---

    ## Shipments

    An order carries a `shipments` array. Each entry is one parcel:

    ```json
    "shipments": [
      {
        "shipmentId": "shp_1",
        "carrier": "UPS",
        "trackingNumber": "1Z999AA10123456784",
        "trackingUrl": null,
        "shippedAt": "2026-08-22T15:02:44.900Z",
        "lines": []
      }
    ]
    ```

    `lines` empty means **the whole order was in this parcel** — the ordinary case, and the only case
    for an order whose `expectedParcels` is 1. When a parcel contains only part of an order, it lists
    what was in it.

    **The array grows as parcels ship.** An order that splits ("Fulfilment rails — who actually fills your order") has one entry per fulfilment chunk,
    appended when that chunk's parcel actually leaves — not up front. So between the first parcel
    shipping and the last, `shipments.length` is less than `expectedParcels`, and that difference is the
    only reliable way to tell "one parcel, shipped" from "two parcels, one still to come".

    **Compare `shipments.length` against `expectedParcels`, never against 1.** They are equal exactly
    when the order is fully shipped. A handler that treats the first `shipments` entry as the whole
    order will tell your customer their shipment is complete while a second box is still being packed.

    **The order-level `carrier`, `trackingNumber` and `trackingUrl` mirror `shipments[0]`.** They are
    never written independently, so they cannot disagree with the array. They describe the **first**
    parcel only. For a single-parcel order — every `organization` order, and most others — you can keep
    reading those three fields and you will never be wrong. For a split order they are the first of two
    tracking numbers, and reading only them loses the other parcel silently.

    > **Iterate the array.** This is no longer advice about a hypothetical future: split shipments
    > happen today. A handler that iterates `shipments` and sizes itself on `expectedParcels` is correct
    > for both shapes; one that reads only `trackingNumber` sees the first parcel and misses the rest.

    ---

    ## Idempotency — retry safely

    **Retrying is safe and expected.** If a request times out, send the identical request with the same
    `partnerOrderId`.

    - First accepted submission → **`201`**, `"duplicate": false`
    - Identical resubmission → **`200`**, `"duplicate": true`, **the original order**
    - **Different** contents under that same `partnerOrderId` → **`409`**, `idempotency_key_reuse`

    You can never create two orders — which on this door would mean **shipping product twice**.

    **An identical retry of an order we accepted always answers with the original order**, provided the
    request still authenticates ("Authentication"). We recognise it as a retry before we consult the catalog, your
    enabled products, routing or billing, so it still answers `200` with that order even if, since you
    placed it, a product in it was withdrawn or re-priced, your enabled products or facilities changed,
    or your billing terms changed. A retry is never priced again and never charged again: the order, and
    anything already charged for it, is exactly what your first accepted submission created.

    ### What counts as "the same order"

    We fingerprint the **normalised** `lines` (product id and quantity), `shipTo`, and
    `recipientType`. Normalised means after trimming, upper-casing `state`, lower-casing `email`,
    lower-casing `recipientType` and defaulting `country`. So none of the following breaks a retry:

    - JSON key order
    - Leading/trailing whitespace, or `co` vs `CO`, or a differently-cased email
    - Sending `"country": "US"` explicitly on one attempt and omitting it on the next
    - A changed `partnerSku` or `referenceNote`
    - `"recipientType": "Individual"` on one attempt and `"individual"` on the next

    ### The one conflict you must handle: `409 idempotency_key_reuse`

    A retry has to carry the same order. If a `partnerOrderId` comes back with different lines, a
    different address **or a different `recipientType`**, you are not retrying, you are **replacing an
    order that may already be picked**, and we answer `409` rather than silently shipping one of the
    two interpretations.

    > **Changing `recipientType` counts.** Organization and individual are different parcels — one is
    > addressed to a clinic, the other to a named person — so correcting it under the same
    > `partnerOrderId` answers `409`, not `200`. Submit the correction under a new `partnerOrderId`
    > and cancel the original if it has not shipped.

    So: **an order you have already submitted cannot be edited by resubmitting it.** Cancel it ("Cancel an order") and
    submit the corrected order under a **new** `partnerOrderId`. Handle `409` explicitly — do not treat
    it as transient and retry, because it will fail identically.

    > **An id is claimed the moment we accept the order, and it is never released — including if the
    > order is later rejected or cancelled.** Fix the problem and resubmit under a **new**
    > `partnerOrderId`.
    >
    > Resubmitting the same id after a rejection does not create a second order. If the contents match
    > you get `200 {"duplicate": true}` describing the order that will never ship; if they differ you get
    > `409 idempotency_key_reuse`. Neither is a new order, and the first is the more dangerous of the two
    > because it looks like success.
    >
    > This is deliberate. Releasing an id would mean the same identifier could name two different orders
    > over time, and the whole point of the fingerprint is that one id names one thing forever.

    ---

    ## Read an order back

    ```
    GET /api/puo/orders?partnerOrderId=RMM-2026-00184
    ```

    Returns the same `order` object. Use this to reconcile, or to recover if you missed a webhook.
    Webhooks are the intended path; this is the safety net.

    ---

    ## Cancel an order

    ```
    POST /api/puo/orders/cancel
    { "partnerOrderId": "RMM-2026-00184", "reason": "duplicate of RMM-2026-00185" }
    ```

    Only while the order is still `accepted`. Once the fulfilling facility has begun picking you get
    `409 not_cancellable` — at that point it is a conversation with a human, not an API call, and
    answering `200` would tell you the order stopped when it had not.

    A successful cancel returns the updated order in the same shape as "Submit an order":

    ```json
    { "ok": true, "order": { "partnerOrderId": "RMM-2026-00184", "status": "cancelled", "...": "..." } }
    ```

    and emits `order.cancelled`. If your account is billed **per order** ("How these orders are billed") and this order was
    already charged, we attempt the reversal immediately and, when the processor accepts it, an
    `order.refunded` webhook follows — voided before the batch settles, refunded after; like every
    webhook on this door it is notice that the reversal happened, never an amount. If the processor
    refuses the reversal, the cancellation still stands, no `order.refunded` is sent, and we finish the
    reversal by hand — so the absence of that webhook after a cancel is a reason to contact us, not
    proof the money moved.

    In one rare case `order` is `null` with `ok` still `true`: the cancel
    committed but the read-back of the updated document failed. The cancellation is real — do not
    resubmit it; re-fetch the order with `GET /api/puo/orders?partnerOrderId=...` when you need the body.

    > **Cancel is idempotent — retry it exactly like a submit.**
    >
    > If your cancel request times out or the response is lost, send the identical request again. An
    > order that is already `cancelled` answers **`200`** with the order, the same shape as the first
    > successful cancel. No second `order.cancelled` webhook is emitted, because the order was cancelled
    > once.
    >
    > This matters because a lost response is the case "Idempotency — retry safely" tells you to retry, and it is the one you are
    > most likely to meet. You do not need to read the order back first, and you should not treat a
    > retried cancel as an error.
    >
    > `409 not_cancellable` therefore means what it says: the order is `in_fulfilment`, `shipped`,
    > `delivered` or `rejected`, and stopping it is a conversation with a human. It is never returned
    > for an order you already cancelled.

    **The same reversal happens when we stop an order on our side.** While nothing in an order has been
    picked, our team may reject it (`order.rejected`, with the reason in `rejectionReason`) or cancel it
    (`order.cancelled`). If that order was charged, the charge is reversed automatically exactly as for
    your own cancel above — including when the processor refuses: the rejection or cancellation stands,
    no `order.refunded` is sent, and we finish the reversal by hand. When the reversal is accepted, the
    `order.refunded` that confirms it carries the same `revision` as the `order.rejected` or
    `order.cancelled` it follows.

    ---

    ## Webhooks

    We `POST` JSON to your registered HTTPS endpoint on every status change.

    Your endpoint must be a **public HTTPS URL on a real domain**. IP addresses, `localhost`,
    single-label hosts, `.internal`/`.local` names and URLs with embedded credentials are refused when
    your record is saved.

    ### Events

    | Event | Meaning |
    |---|---|
    | `order.accepted` | Queued for fulfilment. Which facility fills it is decided at acceptance and is never published to you (see "Fulfilment rails — who actually fills your order") |
    | `order.rejected` | Rejected by us after acceptance, before anything in it was picked — see `rejectionReason`. If the order was charged, `order.refunded` follows ("How these orders are billed") |
    | `order.in_fulfilment` | Being picked and packed. For a split order this may arrive more than once — see below |
    | `order.shipped` | Every parcel in the order has shipped. Fires **once** |
    | `order.delivered` | Delivery confirmed by our fulfilment team. Fires **once** |
    | `order.cancelled` | Cancelled before shipment — by you ("Cancel an order") or by us. If the order was charged, `order.refunded` follows ("How these orders are billed") |
    | `order.charged` | Your card on file was charged for this order (bill-at-time-of-order partners). The payload carries no amount — your rates live on the catalog's `cogsUsd` and your invoice |
    | `order.refunded` | A charge on this order was reversed in full — voided before settlement or refunded after. After a cancellation or a rejection it carries the same `revision` as the `order.cancelled` or `order.rejected` it follows |

    > `order.delivered` is recorded by our team from carrier confirmation, not by an automatic carrier
    > callback. Treat `order.shipped` plus tracking as the point from which you can track it yourself.
    >
    > **Do not build a workflow that waits on `order.delivered`.** Because a person records it, it
    > arrives on a human cadence — it can lag the actual delivery by days, and for some orders it may
    > never arrive at all. `order.shipped` carries `carrier` and `trackingNumber`, and from that
    > moment the carrier is the authoritative source for where the parcel is. Anything you need to
    > happen on delivery should key off your own carrier tracking, not off this event.

    ### What a split order's event stream looks like

    An order that ships as more than one parcel ("Fulfilment rails — who actually fills your order") still has **one** event stream, on the one
    `orderId`. The status you receive is always the whole order's status, never one parcel's.

    - **`order.shipped` fires once, when the LAST parcel ships.** It is not emitted per parcel. When you
      receive it, `shipments` carries every parcel and `shipments.length` equals `expectedParcels`.
    - **`order.delivered` fires exactly once**, at the end, for the order as a whole.
    - **`order.in_fulfilment` may arrive more than once.** When one parcel ships while a sibling is
      still being packed, the order is still `in_fulfilment` — telling you `order.shipped` there would
      be false — so we re-emit `order.in_fulfilment` at a **bumped `revision`**, carrying the grown
      `shipments` array. That is how the first tracking number reaches you before the order is complete.

    **You do not need new handling for the re-emit.** The two rules in "Two things you must handle"
    below already cover it exactly: dedupe on `eventId` (which includes the revision, so the re-emit is
    a genuinely new event and not a duplicate), and keep the highest `revision` you have applied
    (so the re-emit's larger revision is applied, and a retried older copy is discarded). A handler that
    already obeys those two rules is correct for split orders with no change — provided it treats the
    `order` object in the payload as the current truth rather than merging fields it remembers.

    ### Payload

    ```json
    {
      "eventId": "ruo_9f2c…e30:order.shipped:3",
      "eventType": "order.shipped",
      "occurredAt": "2026-08-22T15:02:44.900Z",
      "revision": 3,
      "order": { "…": "the same order object as "Submit an order"" }
    }
    ```

    Headers: `X-PMS-Timestamp`, `X-PMS-Signature`, `X-PMS-Event-Id`, `X-PMS-Event-Type`, and — only
    during a secret rotation — `X-PMS-Signature-Previous`.

    ### Verify the signature

    Outbound signing is **timestamp + body only** (there is one endpoint, yours, and the body carries a
    unique `eventId`):

    ```js
    function verify(req, rawBody, secret) {
      const expected = crypto
        .createHmac('sha256', secret)
        .update(`${req.headers['x-pms-timestamp']}.${rawBody}`)
        .digest('hex');

      // Compare against BOTH headers: during a secret rotation we send two signatures so your
      // deployment does not have to be timed. Either matching is a valid request.
      for (const header of ['x-pms-signature', 'x-pms-signature-previous']) {
        const sent = req.headers[header];
        if (typeof sent !== 'string' || sent.length !== expected.length) continue;
        if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sent))) return true;
      }
      return false;
    }
    ```

    > Note the length check before `timingSafeEqual` — it **throws** on a length mismatch, so a
    > malformed or absent header would crash your handler rather than return `false`.

    ### Two things you must handle

    **Duplicates.** Delivery is **at-least-once**. Exactly-once is not achievable over HTTP, so rather
    than pretend otherwise we give you a stable `eventId` — dedupe on it.

    **Out-of-order arrival.** A retried `shipped` can arrive *after* `delivered`. Every payload carries
    the order's monotonic **`revision`**. Store the highest revision you have applied per order and
    **discard any event with a lower one.** Without this you will eventually move an order backwards.

    > **`revision` is per ORDER, not per event — two events can share one.** `order.accepted` and
    > `order.charged` are both emitted at the order's revision `1`; `order.cancelled` (or
    > `order.rejected`) and the `order.refunded` that follows it are both emitted at the same bumped
    > revision. They are different events with
    > different `eventId`s, and both must be applied. So compare with **strictly less than**: discard an
    > event whose `revision` is *lower* than the highest you have applied, and keep one that is equal. A
    > handler written as `if (revision <= highest) skip` silently drops both money notices — the two
    > events that tell you your card was charged and refunded.

    ### Retries

    > **While your account is disabled, queued events are held, not dead.** Each is deferred roughly
    > six hours at a time and the retry ladder is not consumed. After re-enable, delivery resumes
    > automatically — but the in-flight deferrals must expire first, so allow **up to six hours plus
    > one sweep tick** of quiet before concluding anything is wrong.

    Respond **2xx** to acknowledge. Anything else — or a timeout past 10s — is redelivered, for **8 times** in all: the first attempt plus seven retries.

    The schedule is **`0, 1, 5, 15, 60, 180, 720, 1440` minutes** — and these are the gaps **between**
    attempts, not offsets from the first one. Cumulatively, measured from the first attempt:

    | Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    |---|---|---|---|---|---|---|---|---|
    | Minutes after the first | 0 | 1 | 6 | 21 | 81 | 261 | 981 | **2421** |

    So the last attempt lands about **40 hours** after the first. After it, the event is marked `dead`
    and surfaced in our owner console.

    > **These are earliest times, not exact ones.** The retry sweeper runs **every 5 minutes**, so an
    > attempt becomes *eligible* at the minute shown and is made on the next tick. In practice each row
    > lands between 0 and 5 minutes late, and the early rungs are the ones you will notice: the "1
    > minute" retry can arrive at 5. The number that matters is unchanged — the last attempt is still
    > **2421 minutes** after the first, because the delay is measured from each attempt rather than from
    > a fixed schedule, so lateness does not accumulate into the gaps. Size alerting on the table; do not
    > assert on it to the second.

    **What that means for you, and what to do about it.** A `dead` event is one we stopped trying to
    deliver. We do not replay it automatically, so if your endpoint was down for the whole window the
    state change is not lost — it is simply something you have not been told. Recover it by reading the
    order back (section 7); the `order` object always carries current truth, so one read replaces any
    number of missed events for that order.

    Size your reconciliation sweep on **2421 minutes** — the full ladder above — not on the largest
    single gap of 1440. A sweep that runs more often than every 40 hours can never leave an order whose
    last webhook died unreconciled. If you know your endpoint was down longer than that, do not wait for
    the sweep: poll `GET /api/puo/orders` for every order you have open.

    You do not need to detect that an event died. Reconciling on a timer is cheaper than reasoning about
    which specific notifications you missed, and it is the same code path that protects you from a
    deploy, a network partition, or a bug on our side. Run it after any incident where you cannot
    prove every delivery was processed, too — a delivery we recorded as accepted is one we will never
    send again.


    > One unhealthy endpoint cannot starve another partner's events: each sweep takes at most a fixed
    > number of events per partner, so a black-holed endpoint with a large backlog no longer fills every
    > delivery slot ahead of a healthy partner's fresh event.

    A `4xx` from you is retried too. A rotated secret or a redeployed endpoint is a temporary state from
    our side, and giving up on the first `401` would strand a shipped order with tracking you never
    received.

    We read at most 8 KB of your response body and store the first 300 characters for support.

    ---

    ## Errors

    ```json
    {
      "ok": false,
      "error": {
        "code": "product_not_permitted",
        "message": "This partner is not enabled for that RUO product.",
        "retryable": false,
        "detail": "ruo-ghkcu-50mg"
      }
    }
    ```

    `code` is stable — program against it, not against `message`. **Never retry a `retryable: false`
    error**; it will fail identically.

    | Code | HTTP | Retryable | Cause |
    |---|---|---|---|
    | `unauthenticated` | 401 | no | Key, timestamp, version or signature missing/invalid, or clock skew > 5 min |
    | `partner_disabled` | 403 | no | Your account is disabled **or your key does not carry the scope for this door** — see the note below |
    | `billing_not_configured` | 403 | no | Billing for your account is not ready to accept orders on our side — for example, no billing arrangement has been set up yet, or your account is billed per order and has no usable card on file — and we do not accept an order we could not bill. This is an account condition, not a problem with the request — do not retry it; contact PureMedScript. `detail` names the machine-readable reason. **A live-key condition only**: a sandbox order is never billed, so a sandbox key never receives this — see "Sandbox" |
    | `not_a_route` | 404 | no | Unknown path — see "Endpoints" |
    | `method_not_allowed` | 405 | no | Wrong verb for that endpoint |
    | `invalid_json` | 400 | no | Body is not a JSON object |
    | `missing_field` | 400 | no | A required field is absent — see `detail` |
    | `unknown_field` | 400 | no | A field this API does not accept, **or a field it accepts carrying a value it does not** — see `detail`, which names the path and, for a bad value, shows what you sent. An invalid `recipientType` arrives as this code |
    | `clinical_field_rejected` | 400 | no | Remove the named clinical field. RUO is not a prescription |
    | `price_field_rejected` | 400 | no | Remove the named pricing field. Billing is separate |
    | `unknown_product` | 422 | no | The id is enabled for you but missing from the catalog. This is a provisioning fault on OUR side, not a bad request — **a mistyped id does not reach it**, see below |
    | `product_unavailable` | 422 | no | The id is valid but the item is **withdrawn**, not yet priced, not fully set up on our side, **or fulfils from a facility this door does not dispatch to**. `detail` always begins with the offending `<ruoProductId>:` and then states the cause. The two you will meet are `<ruoProductId>: not orderable through this API` (the line fulfils from a facility this door does not dispatch to — no grant changes it) and `<ruoProductId>: not currently routable` (the catalog entry is not fully set up on our side — our fault, tell your contact). Branch on the CODE, never on the detail text: it is diagnostic prose and more phrasings may appear. Not a typo |
    | `product_not_permitted` | 403 | no | The id is not on your enabled list. **This is also what a typo returns** — the allow-list is checked before the catalog, so an id that does not exist anywhere is simply an id you are not enabled for |
    | `invalid_quantity` | 422 | no | `quantity` must be a positive integer within the limits in "Submit an order" |
    | `quantity_not_allowed` | 422 | no | Violates that item's minimum or increment — see `detail` |
    | `order_too_large` | 413 | no | Too many lines, or the order is too large to store. Submit it as several smaller orders under distinct `partnerOrderId`s — unrelated to the fulfilment split in "Fulfilment rails — who actually fills your order", which we do for you |
    | `invalid_ship_to` | 422 | no | Address component missing or invalid — see `detail` |
    | `destination_not_permitted` | 422 | no | That item cannot be shipped to that state |
    | `rail_not_enabled` | 403 | no | The facility that fulfils one of these products is not enabled on your account. Contact your account manager to have it enabled — this is a provisioning setting, not a payload problem. It outranks every other refusal in the cart, because enabling the facility can change where the other lines land. See "Fulfilment rails — who actually fills your order" |
    | `mixed_carrier_order` | 422 | no | **No longer returned.** Retired 2026-08-31 when a submission whose lines fulfil from different facilities began being SPLIT into one parcel per facility instead of refused; see "Fulfilment rails — who actually fills your order". The row remains because codes are contractual and never deleted; if your handler special-cased it, that branch is now dead |
    | `card_declined` | 402 | no | Bill-at-time-of-order partners only: the card on file declined. NO order was created — update the card with your account manager and resubmit the identical request |
    | `not_found` | 404 | no | No order with that `partnerOrderId` for you |
    | `idempotency_key_reuse` | 409 | no | That `partnerOrderId` exists with **different** contents — see "Idempotency — retry safely" |
    | `not_cancellable` | 409 | no | The order is past `accepted` — see "Cancel an order" |
    | `rate_limited` | 429 | **yes** | Too many requests. Honour `Retry-After` — see "Rate limits" |
    | `storage_unavailable` | 503 | **yes** | Transient (includes a credential-store fault on our side). Retry the identical request |
    | `internal_error` | 500 | **yes** | **Reserved — not currently emitted.** Every unexpected fault is answered `503 storage_unavailable` instead. Listed so the code is not reused for something else; you do not need a handler for it today |

    > **Debugging a `product_not_permitted`.** Check the spelling against `/catalog` FIRST, before asking
    > us to enable anything. Because the allow-list is checked before the catalog, `ruo-bpc-157` and
    > `ruo-bpc-1S7` produce the identical refusal — one is a permission question and the other is a typo,
    > and this code cannot tell you which. `/catalog` lists every enabled id that exists in our
    > catalog — an entry with `orderable: false` is listed but will be refused at submission, and an
    > enabled id MISSING from `/catalog` entirely is a provisioning fault on our side (tell us). So an id
    > that is absent from it is either misspelled or genuinely not enabled, and comparing the two answers
    > that in one request. `unknown_product` is deliberately NOT used for this: telling an unauthenticated
    > guess whether an id exists would let anyone enumerate our catalog one request at a time.

    > `storage_unavailable` can also come from the **authentication** step. If our credential store is
    > briefly unreachable we answer `503`, never `401` — a `401` would send you to rotate a key that was
    > never the problem. Treat `503` as "retry the identical request", exactly as after a timeout —
    > **with one exception**: a `503` whose message says *contact PureMedScript support* is a
    > provisioning fault on our side (a partner record with no signing secret) and will not clear
    > however long you retry. Stop and contact us.

    > **`partner_disabled` has two causes and they need different fixes.** Your account may genuinely be
    > disabled — or your key may be active but **not carry the scope for the door you called**. A key
    > issued for research ordering (scope `puo`) cannot call the prescription endpoints, and the reverse
    > is also true. The `detail` field distinguishes them. If you are calling an endpoint you have never
    > successfully called before and everything else works, it is the second cause, and it is fixed by
    > us adding the scope — `puo` or `rx`, name the one you need — to your credential, not by
    > re-activating your account.
    >
    > **This is a different failure from `rail_not_enabled`**, which is about a fulfilment facility
    > rather than a door: your key was allowed through, and the product inside the order fulfils
    > somewhere your key is not provisioned for. See "Fulfilment rails — who actually fills your order".

    ### Rate limits

    | Limit | Window | Keyed on | Applied |
    |---|---|---|---|
    | 120 requests | 60 s | your partner id | after authentication |
    | 600 requests | 60 s | source IP | **before** authentication |

    Both answer `429` with `retryable: true` and a **`Retry-After`** header in whole seconds. Honour it
    — a shorter self-invented backoff just earns another `429`.

    Batching a day's orders fits comfortably inside these limits; a tight retry loop does not. If the
    limiter itself is unavailable we **deny** rather than wave requests through, so a `429` during an
    incident still means "wait and retry".

    ---

    ## Sandbox

    Sandbox keys behave identically through authentication, signing, validation, `/catalog` and
    idempotency — but sandbox orders are marked `"mode": "sandbox"`, are **never picked or shipped**,
    are never billed, and are **never routed**. Routing runs on live orders only, so `rail_not_enabled`
    cannot occur against a sandbox key, and a sandbox order is never split — every sandbox order reports
    `expectedParcels: 1` and ships as one sandbox parcel, whatever its lines would have done live. An
    order sandbox accepts may still be refused on your live key, and one sandbox accepts as a single
    parcel may ship live as two.

    **A sandbox key needs no billing configuration.** Because a sandbox order is never billed, there is
    nothing a billing arrangement could apply to, so we do not ask for one before accepting your test
    traffic: `billing_not_configured` is a **live-key condition only** and is never returned against a
    sandbox key. You can integrate and exercise this API end to end before commercial terms are set up
    on your account.

    To exercise your webhook handler end to end, ask your PureMedScript contact to **run the test
    sequence** on a sandbox order. It emits `order.in_fulfilment`, `order.shipped` and
    `order.delivered` in order, with `carrier: "SANDBOX"` and a tracking number prefixed `SANDBOX-`.
    That is the full lifecycle, with signatures, revisions and retries behaving exactly as in live.

    Check `order.mode` in your handler if you branch on it.

    ---

    ## Credentials

    You receive two secrets at onboarding, each shown **once**:

    - **API key** (`pms_ruo_…`) — identifies you. Sent as `X-PMS-Api-Key`.
    - **Signing secret** (`whsec_…`) — signs your inbound requests **and** verifies our outbound
      webhooks. It is the second factor; treat it as you would a password.

    Neither can be read back. If one is lost or exposed, it is **rotated**, not recovered.

    ### Rotation is a roll, not a cut-off

    | | Overlap |
    |---|---|
    | API key | The previous key keeps working for **24 hours** |
    | Signing secret | Both secrets are accepted inbound for **24 hours**, and every webhook in that window carries **both** signatures |

    So you can deploy on your own schedule inside the window. Tell us when you have, and if you need an
    immediate cut-off say so explicitly — that is a different operation.

    ---

    ## Getting set up

    1. Your PureMedScript contact creates your partner record.
    2. You receive an **API key** and a **signing secret** (both shown once). The secret signs
       **every API request you send** and verifies our webhooks — you need both values before your
       first call.
    3. You provide your **HTTPS webhook URL** on a public domain. The URL may be provided **after**
       the key is minted — the console does not require it to issue credentials — but never provide
       a placeholder (an unreachable real domain would receive your order payloads).
       > **Provide the URL before your first live order — strongly recommended, not enforced.**
       > Lifecycle events raised while your webhook URL is blank are **queued, not dead**: each is
       > deferred roughly six hours at a time, the retry ladder is not consumed, and delivery starts
       > automatically once the URL is provisioned — the same holding pattern as a disabled account
       > ("Webhooks"), so allow up to six hours plus one sweep tick after setting the URL before concluding
       > anything is wrong. Registering the URL first means your very first order exercises your
       > receiver end to end, instead of its events arriving later as a burst of held deliveries —
       > and GET-poll reconciliation ("Read an order back") remains the safety net either way.
    4. The RUO products you may order are enabled on your account. The allow-list is
       empty-means-none — a product not explicitly enabled returns `product_not_permitted`.
    5. Test against a sandbox key, ask for the test sequence to be run, then switch to live.

    ---

    ## Changelog

    **Last updated: 2026-10-04.** Newest first. Every entry below is **additive**: no signing change, no
    field removed, no error code renamed or deleted, and nothing here requires a change to a working
    integration. Each entry says what you may now read or receive.

    | The number you can see | What it actually versions |
    |---|---|
    | **Contract version `2`** | The **signature scheme** — the literal `2` you send as `X-PMS-Signature-Version`. It moves only for a change your existing signing code could not survive, and then with an overlap period and written notice. It is frozen because nothing has broken, not because nothing has shipped |
    | **`openapi: 3.1.0`** | The **OpenAPI specification format** the machine-readable file beside this page is written in. It describes the document, not this API, and nothing about our release cadence |

    **2026-10-04 — ADDITIVE.** A charged order that **we** reject or cancel is now refunded
    automatically, exactly like one you cancel through the API: once the processor accepts the reversal,
    `order.refunded` follows `order.rejected` or `order.cancelled` at the same `revision`. A handler that
    already keeps equal-revision events ("Webhooks") needs no change. If no `order.refunded` follows a rejection
    of a charged order, the processor refused the reversal and we are completing it by hand: contact us.

    **2026-10-04 — ADDITIVE.** An order is billed at your rate in force **when you place it** — the
    `cogsUsd` `/catalog` shows your live key at that moment. A later change to your rates re-prices
    future orders only. Until now an order was invoiced at the rate in force when it shipped; an order
    accepted before this change is still invoiced that way. A weekly invoice is due on receipt,
    and a card we hold on file for a weekly account is a backup only, never charged automatically for a
    weekly invoice.

    **2026-10-04 — ADDITIVE.** An identical retry of an order we accepted now always answers `200` with
    the original order: it is recognised before the catalog, your enabled products, routing or billing
    are consulted. Previously a retry made after a product in it was withdrawn, or after your enabled
    products or facilities changed, could be refused `product_unavailable`, `product_not_permitted` or
    `rail_not_enabled` about an order we already held.

    **2026-10-04 — ADDITIVE.** `billing_not_configured` keeps its code, HTTP status (403) and
    `retryable: false`, but its `message` no longer claims your account has no billing configuration —
    that was false when the actual cause was, for example, a per-order account with no card on file.
    It now reads that billing for your account is not ready to accept orders, and `detail` carries the
    specific machine-readable reason.

    **2026-10-04 — DOCUMENTATION CORRECTION.** The facility table in "Fulfilment rails — who actually fills your order" said the pharmacy routing rail
    also shipped to the clinic. It does not, through this door: it fills `individual` (patient-direct)
    orders only, and an `organization` order is filled by our office-use warehouse, as the paragraph
    above the table already said. Routing did not change; the table now agrees with it, and "Fulfilment rails — who actually fills your order" now
    states that a product only the pharmacy routing rail carries is refused `product_unavailable` on an
    `organization` order.

    **2026-08-31 — ADDITIVE.** One submission is still exactly one order — one `partnerOrderId`, one
    `orderId`, one charge, one cancellation, one event stream — but it may now ship as more than one
    parcel. Every order object gains `expectedParcels`, an integer of at least 1, on the `201`, on every
    read-back and in every webhook payload. Size your handler on it: compare `shipments.length` against
    `expectedParcels`, never against 1. An order accepted before this existed reports `1`, so no stored
    order changed.

    **2026-08-31 — ADDITIVE.** A submission whose lines fulfil from different facilities is no longer
    refused `mixed_carrier_order` (422). We split it for you into one parcel per facility and accept it
    as one order. The error code is not deleted — published codes are contractual — but nothing produces
    it any more. If your integration has a branch for it, that branch is now dead code.

    **2026-08-31 — ADDITIVE to the wire, but the billed amount can change.** Shipping is $25 per
    **parcel**, never per vial, line or unit. Four vials in one parcel is one $25 charge. An order that
    splits into two parcels is two $25 charges — even when both parcels go to the same address, because
    two boxes shipped. `expectedParcels` on the `201` is the count the fee was charged per. Before this,
    the fee counted distinct destination addresses, so a split order to one address was under-billed one
    fee per extra parcel.

    **2026-08-31 — ADDITIVE.** There are three fulfilment facilities, not two: the office-use / research
    warehouse, the patient-direct warehouse, and the pharmacy routing rail — a licensed U.S. pharmacy
    that fills research orders for us. All three are now reachable through this door. A patient-direct
    line that the pharmacy rail fills used to be refused `product_unavailable` here; it now fills,
    provided your credential is provisioned for that facility. You never name a facility in a request
    and we never publish which one filled your order.

    **2026-08-30 — ADDITIVE.** New `/catalog` `unavailableReason` value `not_routable`, for an entry
    that is active and priced but not fully set up on our side. It was previously reported as
    `orderable: true` and then refused at submission.

    **2026-08-30 — ADDITIVE.** The prescription door now has its own machine-readable schema published
    beside this page as `rx-partner-api.openapi.yaml`. It was written on 2026-08-25 with the door, but
    only became downloadable on this date.

    **2026-08-29 — ADDITIVE.** When you are billed is now a setting on your partner record. **Per
    order**: the card on file is charged at submission and `order.charged` confirms it; a decline answers
    `402 card_declined` and NO order is created. **Weekly**: orders accrue and are invoiced as one weekly
    total — unchanged, and still the behaviour for every account that has not been moved. Either way the
    amount is the same; only the timing differs. There is no API to change it. Note that on per-order
    billing the shipping fee is charged before you can read `expectedParcels` in the `201`.

    **2026-08-29 — ADDITIVE.** Two money notices join the webhook vocabulary. `order.charged` fires when
    a bill-at-time-of-order placement charge succeeds. `order.refunded` fires when the processor accepts
    a reversal, whether you cancelled through the API or we reversed it on our side. Neither carries an
    amount — amounts live on your invoice and in `/catalog`'s `cogsUsd`. If a cancel produces no
    `order.refunded`, the processor refused the reversal and we are completing it by hand: contact us, do
    not treat the silence as confirmation.

    **2026-08-29 — ADDITIVE.** `GET /api/puo/catalog` now returns `cogsUsd` on each entry for live
    production keys: your own cost per unit at your agreed rates, resolved by the same ladder that prices
    your invoice, so the number you integrate against and the number you are billed are one number.
    Sandbox keys see no money field. This is still the only money field anywhere in a response, and no
    amount is ever accepted in a request.

    **2026-08-29 — ADDITIVE.** Live orders are now routed line-by-line through the platform's own
    fulfilment engine at intake, and your credential's facility grant is enforced there.
    `rail_not_enabled` (403) becomes a refusal you can actually receive: a line fulfils from a facility
    your key is not provisioned for. It outranks every other refusal in the cart, because enabling that
    facility can change where the other lines land. Nothing about your payload is wrong when you see it —
    do not retry unchanged, and do not re-sign. Sandbox traffic is unaffected by rail grants.

    **2026-08-28 — ADDITIVE.** The prescription door's `rx.shipped` webhook now carries `carrier`,
    `trackingNumber` and `trackingUrl`, matching what the research door has always sent on
    `order.shipped`. Each key is present only once a parcel reference exists — an absent key means "no
    parcel yet"; we never send an empty string. Pre-shipment payloads are unchanged, byte for byte. These
    are carrier references, not PHI.

    **2026-08-26 — ADDITIVE (new door).** The prescription door opened on 2026-08-25 and was published
    here on this date: `POST /api/rx/orders`, scope `rx`, documented in its own chapter on this page with
    its own schema. It moves real prescriptions — prescriber, patient, sig — into a licensed dispensing
    pipeline, with consent as your attestation and pass-through patient identity. It never mixes with the
    research door, and sandbox credentials are refused there before the payload is parsed. Two of its
    refusal codes were retired the same week (`clinical_payload_not_open`, `rx_rail_not_open`); their rows
    remain, because codes are contractual. If your credential has no `rx` scope, nothing about the
    research door changed for you.

    **2026-08-21 — ADDITIVE.** `shipments` is an array, one entry per parcel. The order-level `carrier`,
    `trackingNumber` and `trackingUrl` remain, mirrored from `shipments[0]`, so the common one-parcel case
    still has one obvious answer to "where is my order". Added before any partner had shipped against the
    single-triple shape — which is why the 2026-08-31 order split required no change on your side.

    ---

    ## Quick reference

    ```bash
    BASE=https://puremedscript.com/api/puo
    BODY='{"partnerOrderId":"RMM-2026-00184","recipientType":"organization","shipTo":{"name":"Dr. Alicia Moreno","street1":"418 Cedar Point Way","city":"Boulder","state":"CO","zip":"80301"},"lines":[{"ruoProductId":"ruo-bpc-157-tb-500-05mg-05mg-03ml","quantity":10}]}'
    TS=$(date +%s000)
    SIG=$(printf '2.%s.POST.orders..%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/.* //')

    curl -X POST "$BASE/orders" \
      -H "X-PMS-Api-Key: $API_KEY" \
      -H "X-PMS-Timestamp: $TS" \
      -H "X-PMS-Signature-Version: 2" \
      -H "X-PMS-Signature: $SIG" \
      -H "Content-Type: application/json" \
      -d "$BODY"
    ```

    Note the two consecutive dots in the signing string: the `endpoint` is `orders` and the `query` is
    empty, so `…POST.orders..{body}` is correct.

    # The prescription door — POST /api/rx/orders

    Available only to credentials granted the `rx` scope. Sign with endpoint name `rx.orders`.

    ## Endpoint

    ```
    POST https://puremedscript.com/api/rx/orders
    ```

    Same base host, same signing scheme (v2 HMAC, method + endpoint + query bound — see
    `RUO-PARTNER-API.md` "Consent — what submitting attests"), same key ceremony.

    > **Sign with the endpoint name `rx.orders` — not `orders` — and an empty query.** The canonical
    > signing string for a submission is `2.{timestamp}.POST.rx.orders..{body}`. This is the single
    > most important line in this document: an integrator who signs `orders` (the PUO submission's
    > name) receives 401 `invalid signature` on every request, and by design the refusal cannot say
    > that the endpoint *name* is the mismatched part. The difference is the **scope**: your credential
    > must carry the `rx` scope, and an rx-scoped credential must be bound to exactly one clinic
    > account. A credential with a blank binding receives **503 `partner_not_provisioned`**, never a
    > 401 — the fault is provisioning on our side, not your signature.

    **Who fills a prescription.** Every accepted submission is dispensed by a **licensed U.S.
    pharmacy** — the pharmacy routing rail, the same facility class described in "Fulfilment rails — who actually fills your order" (research door). As on the
    research door, you never name, choose, or learn the facility: a prescription routes to it as part
    of acceptance, and what comes back to you is order state and tracking, never fulfilment topology.

    Only your **live** credential works here. A sandbox credential is refused with
    `sandbox_not_supported` (422) *before the body is even parsed* — see "Sandbox credentials are refused".

    ## Consent — what submitting attests

    Every submission must carry:

    ```json
    "consent": { "hipaaAcknowledged": true, "treatmentConsent": true }
    ```

    inside the `patient` object, with both values the literal boolean `true`.

    **Submitting this attestation asserts that your organisation holds the patient's HIPAA
    acknowledgment and treatment consent.** Consent on this door is *partner-attested*: you are the
    party with the patient relationship, you collected and retain the consent records, and each
    request re-asserts that they exist. The attestation is stored immutably with the order, per
    request.

    A missing or false attestation returns `consent_not_attested` (422), which is deliberately
    distinct from `invalid_field`: the remedy is legal and organisational, not a payload fix. Do not
    set these booleans `true` unless the attestation is actually true for that patient.

    Consent is checked **before** any per-field validation of the patient, so if you are integrating
    incrementally you will learn about the attestation requirement first — it is the one field that
    may require organisational work rather than a code change.

    ## The request

    A complete submission:

    ```json
    {
      "partnerOrderId": "your-unique-id-001",
      "prescriber": { "npi": "1234567893" },
      "patient": {
        "partnerPatientRef": "your-patient-ref-42",
        "firstName": "Example",
        "lastName": "Patient",
        "dob": "1980-01-31",
        "sex": "female",
        "phone": "5055551234",
        "email": "example.patient@example.com",
        "address": { "street": "1 Example St", "city": "Albuquerque", "state": "NM", "zip": "87109" },
        "allergies": ["penicillin"],
        "consent": { "hipaaAcknowledged": true, "treatmentConsent": true }
      },
      "medication": {
        "sku": "SKU-FROM-YOUR-PRODUCT-SHEET",
        "quantity": 1,
        "sig": { "dose": "5 mg", "route": "subcutaneous", "frequency": "once weekly", "duration": "12 weeks", "prn": false },
        "daysSupply": 84,
        "refillsAuthorized": 0,
        "dispenseAsWritten": false,
        "icd10": ["E66.9"]
      },
      "referenceNote": "optional free text for your own records"
    }
    ```

    **Every object level is a strict allow-list.** A key not documented below — at *any* depth —
    returns `unknown_field` (400) with the dotted path in `detail`. There is no level at which an
    unlisted key is silently dropped. In particular: no pharmacy selection, no payment, no insurance,
    no shipping — the platform routes, the clinic's arrangement covers commercial terms.

    Validation refuses one problem at a time, in a stable order: request shape and `partnerOrderId`,
    then `prescriber.npi`'s presence, then the consent attestation, then patient identity, contact and
    address, then medication — and finally the prescriber's NPI check digit, your allowed list and the
    prescriber lookup. So fix-and-resubmit converges quickly. Consent is still checked before any
    per-field validation of the patient, which is what lets an incremental integration meet the
    attestation requirement early.

    ### Top level

    | Field | Required | Rules |
    |---|---|---|
    | `partnerOrderId` | yes | String, your idempotency key, unique per credential. At most 256 characters (longer values are truncated before use). |
    | `prescriber.npi` | yes | The prescriber's NPI. Non-digits are stripped; must be a valid 10-digit NPI with a correct CMS check digit, and must be on your credential's allowed list. |
    | `patient` *(object)* | yes | See "`patient`". |
    | `medication` *(object)* | yes | See "`medication`". |
    | `referenceNote` | no | Free text for your own records, at most 256 characters (truncated). Echoed on the order row; **excluded from the idempotency fingerprint** ("Idempotency and patient identity"), so correcting a note does not burn the `partnerOrderId`. |

    ### `patient`

    | Field | Required | Rules |
    |---|---|---|
    | `patient.partnerPatientRef` | yes | Your stable identifier for this human, at most 128 characters (truncated). Binds to exactly one platform chart per clinic account — see "Idempotency and patient identity". |
    | `patient.firstName` | yes | 1–100 characters after trimming; longer is refused (`invalid_field`). |
    | `patient.lastName` | yes | 1–100 characters after trimming. |
    | `patient.dob` | yes | `YYYY-MM-DD`. Must be a real calendar date, not in the future, and less than 130 years ago. |
    | `patient.sex` | yes | `male` or `female` (case-insensitive; stored lower-cased). `unspecified` is deliberately not accepted — dispensing pharmacies refuse unspecified sex, which would strand a signed prescription. |
    | `patient.phone` | yes | Must contain a 10-digit US number after stripping formatting; a leading `1` country code is tolerated. Stored as the bare 10 digits. |
    | `patient.email` | no | If present and non-empty: trimmed, lower-cased, at most 254 characters, must be a plausible address, and must not contain `+pmsp-` (reserved by the patient portal's identifier scheme). |
    | `patient.address.street` | yes | 1–120 characters. |
    | `patient.address.city` | yes | 1–120 characters. |
    | `patient.address.state` | yes | Two-letter US state or territory code (upper-cased on intake): the 50 states plus DC, PR, VI, GU, AS, MP. |
    | `patient.address.zip` | yes | 5 digits, or ZIP+4 (`12345` or `12345-6789`). |
    | `patient.allergies` | see rules | Array of non-empty strings, at most 50 entries, each at most 120 characters. |
    | `patient.noKnownAllergies` | see rules | The literal boolean `true` to state the patient has no known allergies. |
    | `patient.consent.hipaaAcknowledged` | yes | The literal boolean `true` — see "Consent — what submitting attests". |
    | `patient.consent.treatmentConsent` | yes | The literal boolean `true` — see "Consent — what submitting attests". |

    **Allergy knowledge is required, one way or the other.** Send either a non-empty
    `patient.allergies` array **or** `patient.noKnownAllergies: true` — never neither (pharmacies
    that require allergies treat an empty list without the explicit flag as missing data,
    post-signature) and never both (they contradict each other). Either violation returns
    `invalid_field` (400).

    ### `medication`

    | Field | Required | Rules |
    |---|---|---|
    | `medication.sku` | yes | A SKU from your PureMedScript product sheet, at most 64 characters (truncated). Resolved server-side; a SKU this door cannot order returns `product_not_recognized` (422). |
    | `medication.quantity` | yes | Integer, 1–1000. |
    | `medication.sig.dose` | yes | 1–200 characters. |
    | `medication.sig.route` | yes | 1–200 characters. |
    | `medication.sig.frequency` | yes | 1–200 characters. |
    | `medication.sig.duration` | no | At most 200 characters (truncated). |
    | `medication.sig.prn` | no | The literal boolean `true` for as-needed; anything else is treated as `false`. |
    | `medication.daysSupply` | no | Integer, 1–365. |
    | `medication.refillsAuthorized` | **yes** | Integer, 0–11. **Required, not defaulted** — `0` means no refills and must be said explicitly; a defaulted refill count on a prescription is a clinical decision nobody made. |
    | `medication.dispenseAsWritten` | no | The literal boolean `true`; anything else is treated as `false`. |
    | `medication.icd10` | no | Array of at most 10 ICD-10 codes (e.g. `E66.9`); upper-cased on intake; a malformed code is refused. |

    ## Idempotency and patient identity

    **Idempotency** mirrors the PUO door (`RUO-PARTNER-API.md` "Errors"): `partnerOrderId` is the key.

    - An **identical** resubmission returns `200` with `duplicate: true` and the original order.
      "Identical" is computed over the *normalised, persisted* values — whitespace, key order, phone
      formatting and name casing do not make a request "different". `referenceNote` is excluded, so a
      corrected note still counts as the same request.
    - The **same `partnerOrderId` with different clinical content** returns `409`
      `idempotency_key_reuse`. The remedy is a new `partnerOrderId` — never mutate an id's meaning.
    - A **refused** request claims nothing: every error in "Errors" leaves the `partnerOrderId` unused, so
      you can correct and resubmit under the same id.

    **Patient identity is pass-through.** You are the source of truth for your patient's demographics.
    The first accepted submission for a `partnerPatientRef` creates one patient chart under your bound
    clinic account; every later accepted submission with the same ref reuses that chart and
    *refreshes* its contact details, address, allergies and consent attestation from your payload.

    Name and DOB are **identity** and are never silently rewritten: a submission whose
    `firstName`/`lastName` (case-insensitive) or `dob` differ from the chart already bound to that
    `partnerPatientRef` returns `409` `patient_identity_conflict`. The stored identity wins. If they
    are genuinely different humans, use a distinct `partnerPatientRef` per person; if you corrected a
    typo in a name, contact support — do not reuse the ref and hope.

    ## The success envelope

    `201 Created` for a new submission; `200` with `duplicate: true` for an identical resubmission
    (the **original** order is returned):

    ```json
    {
      "ok": true,
      "duplicate": false,
      "order": {
        "partnerOrderId": "your-unique-id-001",
        "status": "received",
        "sku": "SKU-FROM-YOUR-PRODUCT-SHEET",
        "prescriberNpi": "1234567893",
        "receivedAt": "2026-08-25T15:02:44.900Z",
        "note": "Received for clinical review by the prescriber. This is not a commitment to dispense."
      }
    }
    ```

    That is the whole object, deliberately. It carries **no patient identity** (you hold it — we do
    not repeat it back over the wire), **no internal identifiers**, and no prescriber identity beyond
    the NPI you yourself submitted. `status` walks `received → signed → transmitted → shipped`, with
    `rejected` as the refusal terminal — you hear each move by webhook ("Webhooks").

    `"status": "received"` means received **for clinical review by the prescriber**. It is not a
    commitment to dispense: the prescriber can decline, and the pharmacy can refuse. Do not tell a
    patient a fulfilment date on the strength of a 201.

    ## Errors

    The envelope is identical to the PUO door:

    ```json
    { "ok": false, "error": { "code": "...", "message": "...", "retryable": false, "detail": "" } }
    ```

    `retryable: true` means retry the identical request after a backoff; `false` means fix the request
    or contact us — retrying will not change the answer. For `missing_field`, `unknown_field` and
    `invalid_field`, `detail` names the dotted field path and, for `invalid_field`, the expectation.

    **Refused before the rx handler runs:** both doors are served by one front door, so a request that
    fails routing, authentication, or the pre-auth rate limit answers with the shared-door codes
    defined in `RUO-PARTNER-API.md` "Errors" (research door) — `not_a_route` (404, unknown path), `unauthenticated`
    (401, key/timestamp/signature), `partner_disabled` (403, revoked or wrong-scope credential) and
    `rate_limited` (429). Your parser must accept these on the rx path too; an error handler keyed
    only to the table below will treat its own first signing mistake as a contract violation.

    | Code | HTTP | Retryable | Meaning |
    |---|---|---|---|
    | `method_not_allowed` | 405 | no | Use POST to submit a prescription request. |
    | `invalid_json` | 400 | no | Body must be a JSON object. |
    | `missing_field` | 400 | no | A required field is absent or empty. `detail` names the dotted path. |
    | `unknown_field` | 400 | no | A key this API does not accept, at any depth. `detail` is the dotted path. |
    | `invalid_field` | 400 | no | A field is present but invalid. `detail` names the path and the expected shape. One code for every per-field refusal — the expectation travels in `detail`, not in the code. |
    | `consent_not_attested` | 422 | no | `patient.consent.hipaaAcknowledged` and `patient.consent.treatmentConsent` must both be the literal `true`. Submitting them asserts your organisation holds the patient's consent records — see "Consent — what submitting attests". |
    | `invalid_npi` | 422 | no | `prescriber.npi` is not a valid NPI (10 digits, CMS check digit). |
    | `prescriber_not_permitted` | 403 | no | This credential is not enabled to file for that NPI. |
    | `prescriber_not_recognized` | 422 | no | No verified, active prescriber for that NPI is on file for this partner. Ask PureMedScript support to review the credentials for your clinic account. |
    | `prescriber_ambiguous` | 409 | no | That NPI resolves to more than one prescriber record for this clinic account. PureMedScript support must resolve the duplicate. |
    | `product_not_recognized` | 422 | no | `medication.sku` does not name a product this door can order. Check the SKU against your PureMedScript product sheet, or contact support. |
    | `patient_identity_conflict` | 409 | no | This `partnerPatientRef` is already bound to a patient with a different name or date of birth. Use a distinct `partnerPatientRef` per person — see "Idempotency and patient identity". |
    | `idempotency_key_reuse` | 409 | no | This `partnerOrderId` was already used for a different prescription request. Use a new `partnerOrderId`. |
    | `order_too_large` | 413 | no | This request is too large to store. |
    | `sandbox_not_supported` | 422 | no | The prescription door does not accept sandbox submissions. Use your live credential — see "Sandbox credentials are refused". |
    | `partner_not_provisioned` | 503 | no | This credential is not bound to a clinic account — a provisioning fault on our side, permanent until support acts. Do not retry; contact PureMedScript support. |
    | `rate_limited` | 429 | yes | Too many requests. Slow down and retry. |
    | `storage_unavailable` | 503 | yes | Datastore temporarily unavailable. Retry the identical request. |
    | `clinical_payload_not_open` | 422 | no | **No longer returned.** Retired 2026-08-25 when this door began accepting the clinical payload; it is now required, not refused. The row remains because codes are contractual and never deleted. |
    | `rx_rail_not_open` | 503 | no | **No longer returned.** Retired 2026-08-25 — the door accepts live submissions. The row remains because codes are contractual and never deleted; if your handler special-cased it, that branch is now dead. (The code name keeps the older "rail" wording because a code, once published, is never renamed.) |

    Two deliberate design notes, so nobody reads them as bugs:

    - **`prescriber_not_recognized` is deliberately collapsed.** It does not distinguish
      never-heard-of, inactive, and unverified — the distinction would be an oracle about clinicians
      who never consented to be probed through your credential. `product_not_recognized` collapses
      "no such SKU", "not orderable patient-specific" and "office-use only" for the same reason.
    - **`prescriber_not_permitted` is answered from your own configuration**, before any platform
      lookup — the only NPIs ever looked up are ones you were already provisioned for.

    ## Webhooks

    Lifecycle events are delivered to the **same registered HTTPS endpoint** as your PUO events, on
    the **same outbox**: identical signing (outbound is timestamp + body), identical
    `X-PMS-Timestamp` / `X-PMS-Signature` / `X-PMS-Signature-Previous` / `X-PMS-Event-Id` /
    `X-PMS-Event-Type` headers, identical at-least-once delivery, identical `eventId` dedupe and
    `revision` ordering rules, and the identical retry ladder — 8 attempts over about 40 hours, then
    `dead`. **`RUO-PARTNER-API.md` "Webhooks" (research door) is normative for all of that**; this section documents only
    what is different on the rx door: the event vocabulary and the payload.

    ### Events

    | Event | Order `status` becomes | Meaning |
    |---|---|---|
    | `rx.received` | `received` | Received **for clinical review** by the prescriber — **not a commitment to dispense**. Emitted when your submission is accepted. |
    | `rx.signed` | `signed` | The prescriber reviewed and signed the prescription. |
    | `rx.transmitted` | `transmitted` | The signed prescription is on the wire to the dispensing pharmacy. |
    | `rx.shipped` | `shipped` | The pharmacy shipped it. **Terminal** — vendor "delivered" / "picked up" signals map here; track the parcel with the carrier from this point. |
    | `rx.rejected` | `rejected` | The prescriber declined it, a human cancelled it, or the pharmacy refused it. Terminal from any state. |
    | `rx.delivered` | — | **Reserved — documented but not yet emitted.** `shipped` is the last state this platform asserts; we will not fabricate a delivery fact we do not hold. If we ever gain an authoritative delivery signal, it will arrive under this name — accept it in your parser, but build no workflow that waits for it. |

    There is no event for internal states (routing, payment, pharmacy-internal processing) — you
    cannot act on our internals, and a noisy vocabulary trains your receiver to ignore it.

    ### Payload

    ```json
    {
      "eventId": "pxo_1f9c…a2e:rx.shipped:4",
      "eventType": "rx.shipped",
      "occurredAt": "2026-08-25T15:02:44.900Z",
      "revision": 4,
      "order": {
        "partnerOrderId": "your-unique-id-001",
        "sku": "SKU-FROM-YOUR-PRODUCT-SHEET",
        "status": "shipped",
        "carrier": "UPS",
        "trackingNumber": "1Z999AA10123456784",
        "trackingUrl": "https://www.ups.com/track?tracknum=1Z999AA10123456784"
      }
    }
    ```

    `order` carries these keys, and only these:

    - `partnerOrderId` — always. Your own id, echoed back; correlate on it.
    - `sku` — always. The SKU you submitted.
    - `status` — always. Our status word, the second column of the event table above.
    - `carrier` — once a parcel reference exists. The carrier's name, as the pharmacy recorded it.
    - `trackingNumber` — once a parcel reference exists. That carrier's number for the parcel.
    - `trackingUrl` — once a parcel reference exists. A link to that carrier's tracking page.

    **The three parcel keys appear only once a parcel reference exists** — in practice on `rx.shipped`,
    and on any later event for an order that already has one. Until then they are **absent, not
    empty**: an absent key already means "no parcel yet", whereas `"trackingNumber": ""` is a value you
    would have to special-case. Every pre-shipment payload is therefore byte-identical to the
    three-key shape. They are **carrier references, not PHI** — a parcel's identifier names no person.

    **The payload carries no patient identity, as a hard contract.** No patient name, no DOB, no
    address, no MRN, no prescriber identity, no diagnosis, no sig. You hold the clinical context; we do
    not repeat it back over a webhook. Correlate by `partnerOrderId`.

    Dedupe on `eventId` (`{orderId}:{eventType}:{revision}`), keep the highest `revision` applied per
    order, and discard lower ones — exactly as in `RUO-PARTNER-API.md` "Webhooks" (research door). A dead event is recovered
    the same way conceptually: the state you missed is not lost. There is no rx read-back endpoint
    yet, so if your endpoint was down past the ladder, contact support to reconcile open requests.

    ## Sandbox credentials are refused

    The prescription door does not accept sandbox submissions — `sandbox_not_supported` (422), refused
    **before the body is parsed**, so a test payload is never even examined for identity content.
    Prescriptions mint real patient charts under a real clinic; a sandbox credential must never do
    that.

    Your sandbox credential still exercises auth, signatures, endpoint-name signing and scope checks —
    every step up to the refusal — which is the useful half of integration testing. Test the clinical
    payload against this document and the machine-readable schema in
    [`rx-partner-api.openapi.yaml`](./rx-partner-api.openapi.yaml);
    first live submission should be a real prescription you expect to be signed.
  contact:
    name: PureMedScript integrations
    url: https://puremedscript.com

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

security:
  - PartnerSignature: []

tags:
  # THE TAG DESCRIPTIONS LIVE HERE, NOT IN docs-site/build.cjs.
  # They used to be injected by the generator, which made them the one piece of partner-facing
  # copy that partnerDocContract could not pin: the test reads docs/, the sentence lived in the
  # build script, and it went stale — it kept denying that the catalog carries any money field
  # at all, months after `cogsUsd` shipped on it. Source copy belongs in the source.
  - name: orders
    description: |-
      Submit, read back and cancel Research Use Only orders. Idempotent by partnerOrderId — an
      identical retry answers the original order, changed content answers 409.

      FULFILMENT FACILITIES ("rails") ARE GRANTED PER CREDENTIAL, AND ARE NOT ENDPOINTS. A rail is
      the facility that actually fills your order, and your credential names the closed set you have
      been granted — you may hold one or more. There are three in total: PureMedScript's own
      office-use / research warehouse, PureMedScript's own patient-direct warehouse, and a pharmacy
      routing rail — a licensed U.S. pharmacy that fills research orders for us. This PUO door
      dispatches to all three. It does not reach the prescription pipeline at all; that is the Rx
      door (`POST /api/rx/orders`, scope `rx`). Which facility a line fulfils from is decided from
      the product, the destination state and the order's `recipientType`, so it is not a fixed
      property of the product — and no response on this door ever names one. An `organization`
      order is filled by the office-use warehouse; an `individual` order is filled patient-direct,
      by the patient-direct warehouse or, for the products it carries, the pharmacy routing rail,
      which fills `individual` orders only. A product that fulfils
      from a facility your credential was not granted is refused `rail_not_enabled` (403); that
      refusal outranks every other in the cart. A product that fulfils from a facility this door
      does not dispatch to at all is refused `product_unavailable` (422). Neither is a payload bug
      you can fix by editing the request: ask your account manager which fulfilment facilities your
      credential is granted.

      A SUBMISSION WHOSE LINES FULFIL FROM DIFFERENT FACILITIES IS SPLIT, NOT REFUSED. It stays ONE
      order — one `partnerOrderId`, one `orderId`, one read-back, one charge, one cancellation — and
      is divided internally into one fulfilment chunk per facility, each shipping as its own parcel
      with its own tracking number in `shipments`. `Order.expectedParcels` is how many parcels to
      expect; compare `shipments.length` against it, never against 1. The most an order can reach
      today is two. `mixed_carrier_order` is NO LONGER RETURNED — it refused exactly this cart until
      2026-08-31 and now has no producer; the code keeps its enum member because a published code is
      never deleted.

      SHIPPING IS BILLED AT $25 PER PARCEL — per fulfilment chunk times destination, never per vial,
      line or unit. Four vials in one parcel is one $25 charge. An order that splits into two parcels
      is two $25 charges, EVEN WHEN BOTH PARCELS GO TO THE SAME ADDRESS, because two boxes shipped.
      Orders are never collapsed with one another either: three submissions to the same address on
      the same day are three $25 charges. For partners billed per order the card is charged during
      submission, so those charges land BEFORE `expectedParcels` reaches you in the 201. The fee is
      billed out of band with the rest of your invoice: no price is ever ACCEPTED in a request, and
      the only money field this API returns is `cogsUsd` on `/catalog` — your own per-unit cost,
      under a live key.
  - name: catalog
    description: >-
      The RUO products enabled for your account. Empty-means-none: a product not explicitly
      enabled returns product_not_permitted. The catalog carries exactly ONE money field,
      `cogsUsd` — your own per-unit cost — and only for live-keyed partners; every other
      commercial term remains on the price sheet accompanying your agreement.

paths:
  /catalog:
    get:
      tags: [catalog]
      operationId: listCatalog
      summary: The RUO products enabled for your account
      description: >-
        Sign with endpoint name `catalog` and an empty query. Carries exactly ONE money field,
        `cogsUsd` — your own per-unit cost, present only for live-keyed partners; every other
        commercial term remains on the price sheet accompanying your agreement.
      responses:
        '200':
          description: Your entitlement list
          content:
            application/json:
              schema:
                type: object
                required: [ok, products]
                properties:
                  ok: { type: boolean, const: true }
                  products:
                    type: array
                    items: { $ref: '#/components/schemas/CatalogEntry' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Error' }

  /orders:
    post:
      tags: [orders]
      operationId: submitOrder
      summary: Submit an order
      description: |-
        Sign with endpoint name `orders` and an empty query. Requires the `puo` scope. Idempotent on
        `partnerOrderId`: an identical resubmission returns 200 with `duplicate: true`; different
        contents under the same id return 409. See section 6 of the guide.

        BILLING, which this ENDPOINT carries no number for — the one money field on this API is
        `cogsUsd` on `/catalog`, your own per-unit cost under a live key: goods are billed at your
        agreed rate IN FORCE WHEN THE ORDER IS PLACED (the `cogsUsd` /catalog shows at that moment;
        a later rate change never re-prices a placed order), and shipping is billed at $25 PER
        PARCEL — four vials in one parcel is one $25 charge, and an order that splits into two
        parcels is two charges even when both go to the same address.
        `expectedParcels` on the returned order is the count the fee was charged per, and for
        partners billed per order it arrives AFTER the card has been charged. See the `orders` tag
        notes above, which also cover which fulfilment facilities your credential is granted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/OrderRequest' }
      responses:
        '201':
          description: >-
            Accepted and queued for fulfilment. Which facility fills it is decided at acceptance and
            is never named in any response on this door.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
        '200':
          description: >-
            Duplicate of an order already accepted. The ORIGINAL order is returned — always, provided
            the request still authenticates: an identical retry is recognised before the catalog,
            your enabled products, routing or billing are consulted, so a withdrawal, a re-price or a
            change to your grants or billing since you placed it does not change the answer. A retry
            is never priced or charged again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderEnvelope' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '402':
          description: >-
            `card_declined` — bill-at-time-of-order partners only: the card on file declined this
            submission. NO order was created and no `partnerOrderId` was consumed. Not retryable
            unchanged: update the card with your account manager and resubmit the identical
            request. `detail` carries the processor's decline code.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '403':
          description: >-
            `rail_not_enabled` — the product fulfils from a FULFILMENT FACILITY your credential was
            not granted. Facilities are granted per credential and you may hold one or more, so the
            remedy is a provisioning call, not a payload edit: ask your account manager to add that
            facility to your key. It outranks every other refusal in the cart, because enabling the
            facility can change where the remaining lines land. Also `product_not_permitted` (the
            product itself is not on your allow-list), `partner_disabled` (account deactivated, or
            the key lacks the `puo` scope for this door) and `billing_not_configured` (billing for
            your account is not ready to accept orders on our side; `detail` names the reason). None
            is retryable unchanged.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: >-
            `idempotency_key_reuse` — that partnerOrderId exists with different contents. Do NOT
            retry; cancel and resubmit under a new id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '413': { $ref: '#/components/responses/Error' }
        '422':
          description: >-
            `product_unavailable`, which covers a withdrawn or unpriced item AND a line whose
            fulfilment facility this door does not dispatch to. `detail` always begins with the
            offending `<ruoProductId>:` and then states the cause; the two you will meet are
            `<ruoProductId>: not orderable through this API` (the line fulfils from a facility this
            door does not dispatch to — no grant changes it) and
            `<ruoProductId>: not currently routable` (the catalog entry is not fully set up on our
            side). Branch on the CODE, never on the detail text: it is diagnostic prose and more
            phrasings may appear. Plus `unknown_product`, `invalid_quantity`,
            `quantity_not_allowed`, `invalid_ship_to` and `destination_not_permitted`. A refusal
            refuses the WHOLE submission; nothing is created and no `partnerOrderId` is consumed.
            The ROUTING refusals (`rail_not_enabled`, and `product_unavailable` in its
            "not orderable through this API" form) name every offending line in `detail`, joined
            with `; `. The per-line validation refusals — `unknown_product`, `invalid_quantity`,
            `quantity_not_allowed`, `destination_not_permitted`, and `product_unavailable` caused by
            the item's catalog state — stop at the first offending line and name only that one.
            `mixed_carrier_order` is NO LONGER RETURNED: a submission
            fulfilling from two facilities is now split into two parcels on one order rather than
            refused. The code remains in the enum because a published code is never deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: >-
            RESERVED and not currently emitted. Unexpected faults answer 503 `storage_unavailable`.
            Documented so the status and its code are never reused for something else.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '503': { $ref: '#/components/responses/Error' }
    get:
      tags: [orders]
      operationId: readOrder
      summary: Read one order back
      description: >-
        Sign with endpoint name `orders` and the canonical query string
        (`partnerOrderId=<value>`, URL-encoded). Scoped to your own orders.
      parameters:
        - name: partnerOrderId
          in: query
          required: true
          schema: { type: string, maxLength: 256 }
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema:
                type: object
                required: [ok, order]
                properties:
                  ok: { type: boolean, const: true }
                  order: { $ref: '#/components/schemas/Order' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Error' }

  /orders/cancel:
    post:
      tags: [orders]
      operationId: cancelOrder
      summary: Cancel an order that has not been picked
      description: >-
        Sign with endpoint name `orders.cancel` and an empty query. Legal only while the order is
        `accepted`; afterwards returns 409 `not_cancellable`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partnerOrderId]
              additionalProperties: false
              properties:
                partnerOrderId: { type: string, maxLength: 256 }
                reason: { type: string, maxLength: 1000 }
      responses:
        '200':
          description: Cancelled. An `order.cancelled` webhook is queued.
          content:
            application/json:
              schema:
                type: object
                required: [ok, order]
                properties:
                  ok: { type: boolean, const: true }
                  order:
                    oneOf:
                      - $ref: '#/components/schemas/Order'
                      - type: 'null'
                    description: >-
                      NULL means the cancellation committed but we could not read the order back to
                      describe it. The order IS cancelled — do not retry; read it with
                      GET /orders?partnerOrderId=... when you need the body.
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Error' }

webhooks:
  orderStatusChanged:
    post:
      operationId: onOrderStatusChanged
      summary: Delivered to your registered HTTPS endpoint on every status change
      description: >-
        AT-LEAST-ONCE and NOT ORDERED. Dedupe on `eventId`; discard any event whose `revision` is
        STRICTLY LOWER than the highest you have applied for that order — revision is per order, not
        per event, so `order.charged` shares a revision with `order.accepted` and `order.refunded`
        with the `order.cancelled` or `order.rejected` it follows — a charged order is refunded
        automatically whether you cancel it or we reject or cancel it; an equal revision is a
        different event, not a duplicate. Those two rules are also all a split
        order needs: `order.in_fulfilment` may be re-emitted at a bumped `revision` carrying a grown
        `shipments` array when one parcel ships while a sibling is still being packed, while
        `order.shipped` fires ONCE at the last parcel and `order.delivered` exactly once at the end.
        Respond 2xx to acknowledge; anything
        else is retried with growing gaps between attempts - 0, 1, 5, 15, 60, 180, 720 and 1440 minutes between consecutive tries, about 40 hours end to end and then
        marked dead. Verify the signature over `{timestamp}.{rawBody}` — note this differs from the
        INBOUND scheme, which also binds method, endpoint and query.
      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. Computed under the OUTGOING secret so a
            partner mid-deployment can verify with whichever secret they currently hold. Accept the
            request if EITHER header 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: [order.accepted, order.rejected, order.in_fulfilment, order.shipped, order.delivered, order.cancelled, order.charged, order.refunded]
                occurredAt: { type: string, format: date-time }
                revision: { type: integer, minimum: 1 }
                order: { $ref: '#/components/schemas/Order' }
      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 (`orders`, `orders.cancel`, `catalog`), not the URL path.
        `query` is the sorted, URL-encoded query string and is empty on POST. Requests more than
        5 minutes from our clock are refused. OpenAPI cannot express a composite scheme like this;
        section 2 of docs/RUO-PARTNER-API.md is normative.

        THE TWO SCOPE NAMES, so you can ask for the right one. A credential carries a scope naming
        the API door it may knock on: `puo` opens this door (`/api/puo/orders`,
        `/api/puo/orders/cancel`, `/api/puo/catalog`); `rx` opens the prescription door
        (`/api/rx/orders`, documented in rx-partner-api.openapi.yaml, published beside this page). A
        key that authenticates
        but lacks the scope for the door it called is refused `partner_disabled` (403) — the same
        answer 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 — `puo` or `rx` — when you contact us.

        CREDENTIALS ARE ISSUED IN SANDBOX. `Order.mode` reports which one you are on. A live key is
        enabled for your account only after we have proven the fulfilment path end to end for you,
        so plan your launch against that sequence rather than against this document alone. `cogsUsd`
        on `/catalog` is present only under a live key, for the same reason.

  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`. One value is RESERVED and not
                currently emitted — `internal_error`: every unexpected fault answers 503
                `storage_unavailable` instead. It is listed because a published code is never
                reused or deleted, not because you need a handler for it.
              enum:
                - unauthenticated
                - partner_disabled
                - billing_not_configured
                - not_a_route
                - method_not_allowed
                - invalid_json
                - missing_field
                - unknown_field
                - clinical_field_rejected
                - price_field_rejected
                - unknown_product
                - product_unavailable
                - product_not_permitted
                - invalid_quantity
                - quantity_not_allowed
                - order_too_large
                - invalid_ship_to
                - destination_not_permitted
                - rail_not_enabled
                - mixed_carrier_order
                - card_declined
                - not_found
                - idempotency_key_reuse
                - not_cancellable
                - rate_limited
                - storage_unavailable
                - internal_error
            message: { type: string }
            retryable:
              type: boolean
              description: NEVER retry a false. It will fail identically.
            detail:
              type: string
              description: Which field or value. For unknown_field, a dotted path.

    ShipTo:
      type: object
      required: [name, street1, city, state, zip]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 256 }
        company: { type: string, maxLength: 256 }
        street1: { type: string, maxLength: 256 }
        street2: { type: string, maxLength: 256 }
        city: { type: string, maxLength: 256 }
        state:
          type: string
          description: >-
            US state or territory. A 2-letter code or a full state name; stored upper-cased.
            Validated — an unrecognised value is refused rather than accepted and discovered at the
            label.
        zip: { type: string, maxLength: 32 }
        country:
          type: string
          default: US
          const: US
          description: US only. Other destinations are refused at submission.
        phone: { type: string, maxLength: 32 }
        email: { type: string, maxLength: 256 }

    OrderLineRequest:
      type: object
      required: [ruoProductId, quantity]
      additionalProperties: false
      properties:
        ruoProductId: { type: string, description: From /catalog. Must be enabled for you. }
        partnerSku: { type: string, maxLength: 256, description: Echoed back. Never used to resolve. }
        quantity:
          type: integer
          minimum: 1
          maximum: 100000
          description: Also subject to the item's minimumOrderQuantity and orderIncrement.

    OrderRequest:
      type: object
      required: [partnerOrderId, recipientType, shipTo, lines]
      additionalProperties: false
      description: >-
        STRICT ALLOW-LIST. Any key not named here returns `unknown_field`. Clinical keys
        (patient, prescriber, dea, npi, sig, diagnosis, …) and pricing keys (price, total, amount,
        …) are refused AT ANY DEPTH.
      properties:
        partnerOrderId:
          type: string
          maxLength: 256
          description: Your identifier, unique per partner. This is the idempotency key.
        recipientType:
          type: string
          enum: [organization, individual]
          description: >-
            REQUIRED, not defaulted. `individual` means a named person at their own address. It is
            not inferable from a name and changes how the parcel is labelled. Sending `individual`
            does NOT make this a clinical order.
        recipientReference:
          type: string
          maxLength: 256
          description: Your own opaque reference. Echoed, never interpreted or matched.
        referenceNote: { type: string, maxLength: 1000 }
        shipTo: { $ref: '#/components/schemas/ShipTo' }
        lines:
          type: array
          minItems: 1
          maxItems: 250
          items: { $ref: '#/components/schemas/OrderLineRequest' }

    Order:
      type: object
      properties:
        orderId:
          type: string
          pattern: '^ruo_[0-9a-f]{32}$'
        partnerOrderId: { type: string }
        status:
          type: string
          enum: [accepted, in_fulfilment, shipped, delivered, cancelled, rejected]
        revision:
          type: integer
          description: Monotonic. Discard any webhook whose revision is lower than the newest applied.
        mode: { type: string, enum: [live, sandbox] }
        recipientType: { type: string, enum: [organization, individual] }
        recipientReference: { type: string }
        lines:
          type: array
          items:
            type: object
            properties:
              ruoProductId: { type: string }
              partnerSku: { type: string }
              quantity: { type: integer }
              description: { type: string, description: Snapshot at accept time. }
              strengthMg: { type: string, description: Snapshot at accept time. }
        shipTo: { $ref: '#/components/schemas/ShipTo' }
        carrier:
          type: [string, 'null']
          description: >-
            Mirrors shipments[0].carrier. Never written independently of the array. It describes the
            FIRST parcel only — for a split order there is another one it does not name.
        trackingNumber:
          type: [string, 'null']
          description: >-
            Mirrors shipments[0].trackingNumber — the FIRST parcel's. Reading only this field loses
            the other parcel of a split order silently.
        trackingUrl: { type: [string, 'null'] }
        shipments:
          type: array
          description: >-
            One entry per parcel, appended as each parcel actually ships. An order that fulfils from
            one facility has zero or one element; a split order has one per fulfilment chunk, so
            between the first parcel shipping and the last this array is SHORTER than
            expectedParcels. Compare shipments.length against expectedParcels, never against 1: they
            are equal exactly when the order is fully shipped.
          items:
            type: object
            properties:
              shipmentId: { type: string, description: 'Stable within the order: shp_1, shp_2.' }
              carrier: { type: string }
              trackingNumber: { type: string }
              trackingUrl: { type: [string, 'null'] }
              shippedAt: { type: [string, 'null'], format: date-time }
              lines:
                type: array
                description: >-
                  What was in THIS parcel. EMPTY MEANS THE WHOLE ORDER - the ordinary case - not
                  that the parcel was empty.
                items:
                  type: object
                  properties:
                    ruoProductId: { type: string }
                    quantity: { type: integer }
        expectedParcels:
          type: integer
          minimum: 1
          description: >-
            How many separate parcels this order will ship as — one per fulfilment chunk. Always at
            least 1, and 1 for every order accepted before splitting existed and for every sandbox
            order. It is a COUNT and nothing more: no facility is named anywhere in this projection.
            It is also the number the $25 shipping fee was charged per, and for partners billed per
            order it is first readable on the 201, after the card has already been charged.
        rejectionReason: { type: [string, 'null'] }
        createdAt: { type: [string, 'null'], format: date-time }
        updatedAt: { type: [string, 'null'], format: date-time }

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

    CatalogEntry:
      type: object
      description: The one money field is cogsUsd — YOUR cost per unit at your agreed rates, present only under a live production key. Our costs and list-price internals never appear.
      properties:
        ruoProductId: { type: string }
        name: { type: string }
        description: { type: string }
        strengthMg: { type: string }
        presentation: { type: string }
        packSize: { type: integer }
        unitOfMeasure: { type: string }
        minimumOrderQuantity:
          type: integer
          description: Enforced at submission, not merely published. 0 means no minimum.
        orderIncrement:
          type: integer
          description: Quantity must be a multiple of this. 1 means any quantity.
        leadTimeBusinessDays: { type: integer, description: 0 means not stated — ask. }
        coaAvailable: { type: boolean }
        storageHandling: { type: string }
        restrictedStates:
          type: array
          items: { type: string }
          description: States this item cannot ship to. Empty means no restriction.
        orderable:
          type: boolean
          description: >-
            The field to branch on. Accounts for withdrawal AND unfinished pricing. It does NOT
            account for routing: TRUE means the entry is active, priced and fully set up — not that
            every submission of it will route. A line whose fulfilment facility this door does not
            dispatch to is still refused `product_unavailable` at submission.
        cogsUsd:
          type: number
          description: >-
            YOUR cost per unit, USD, at your agreed rates (your per-product rate where set, else
            the standard partner list price) — the same number your invoice bills. An order is
            billed at the cogsUsd in force when it is placed; a later rate change never re-prices
            it. Present only
            under a live production key, and absent while pricing_pending. Absent-not-zero: zero
            would read as free.
        unavailableReason:
          type: string
          description: >-
            Empty when `orderable` is true. `withdrawn` — discontinued or recalled, stop ordering it.
            `pricing_pending` — a real product whose commercial terms are not finalised. This entry
            carries no facility field, and none is coming: which facility a line fulfils from depends
            on the destination state and the order's `recipientType`, neither of which this endpoint
            receives, so no honest per-product answer exists here.
            `not_routable` — the entry is not fully set up on our side (no source product behind it)
            and cannot be dispatched; that is our fault, not yours, so tell your contact.
          enum: ['', withdrawn, pricing_pending, not_routable]
