# 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. # # 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 This API moves **research product and its shipping status**. Nothing else. | | | |---|---| | **Is** | RUO lyophilized peptides for laboratory research | | **Is not** | A prescription rail. No prescriber, patient, DEA, state licence, diagnosis, sig or refills | | **Is not** | A billing rail. No prices, payments, cards, invoices or ledger | 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. **Billing happens outside this API.** The pharmacy invoices and settles with you separately. There is no amount anywhere in these payloads, and none will be added. Your agreed prices are on the price sheet that accompanies your agreement; the `/catalog` endpoint deliberately carries no price. --- ## 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 §3 | | `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 (§6); 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` | --- ## 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 rail would mean **shipping product twice**. ### 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 (§8) 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. --- ## 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 is not enabled for this rail** — see the note below | | `not_a_route` | 404 | no | Unknown path — see §3 | | `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** or not yet priced. 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 §5 | | `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. Split it | | `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 | | `not_found` | 404 | no | No order with that `partnerOrderId` for you | | `idempotency_key_reuse` | 409 | no | That `partnerOrderId` exists with **different** contents — see §6 | | `not_cancellable` | 409 | no | The order is past `accepted` — see §8 | | `rate_limited` | 429 | **yes** | Too many requests. Honour `Retry-After` — see §10.1 | | `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 (an rx-scoped key with no clinic binding, or a partner record > with no webhook 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 enabled for the rail you called. A key issued for > research ordering 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 enabling > the rail on your credential — not by re-activating your account. ### 10.1 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, but orders are marked `"mode": "sandbox"` and are **never picked or shipped**. 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 provide your **HTTPS webhook URL** on a public domain — **before** the key is minted; the console requires it, and never with a placeholder (an unreachable real domain would receive your order payloads). 3. 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. > **Do this BEFORE your first order — the ordering is load-bearing.** Lifecycle events for an > order submitted while your webhook URL is blank are marked dead immediately and are NOT > redelivered later; there is no replay mechanism. Your only recovery for those orders is > GET-poll reconciliation (§7). Registering the URL first means your very first order exercises > your receiver end to end instead of leaving a gap you must reconcile by hand. 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. --- contact: name: PureMedScript integrations url: https://puremedscript.com servers: - url: https://puremedscript.com/api/puo security: - PartnerSignature: [] tags: - 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. - name: catalog description: >- The RUO products enabled for your account. Empty-means-none: a product not explicitly enabled returns product_not_permitted. Never carries a price. 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 no price: commercial terms are 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. 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. requestBody: required: true content: application/json: schema: { $ref: '#/components/schemas/OrderRequest' } responses: '201': description: Accepted and queued for the pharmacy content: application/json: schema: { $ref: '#/components/schemas/OrderEnvelope' } '200': description: Duplicate of an order already accepted. The ORIGINAL order is returned. content: application/json: schema: { $ref: '#/components/schemas/OrderEnvelope' } '400': { $ref: '#/components/responses/Error' } '401': { $ref: '#/components/responses/Error' } '403': { $ref: '#/components/responses/Error' } '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': { $ref: '#/components/responses/Error' } '429': { $ref: '#/components/responses/RateLimited' } '500': { $ref: '#/components/responses/Error' } '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=`, 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 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' } '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 lower than the highest you have applied for that order. Respond 2xx to acknowledge; anything else is retried at 0, 1, 5, 15, 60, 180, 720 and 1440 minutes (about 40 hours) 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] 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. 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`. enum: - unauthenticated - partner_disabled - 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 - 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. trackingNumber: type: [string, 'null'] description: Mirrors shipments[0].trackingNumber. trackingUrl: { type: [string, 'null'] } shipments: type: array description: >- One entry per parcel. Today every order ships as one, so this has zero or one element. The array exists so a future split shipment is an extra element rather than a breaking change: a handler that iterates it needs no change then, one that reads only trackingNumber will see the first parcel and miss the rest. 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 } 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: NO PRICE FIELD EXISTS. Commercial terms are on your price sheet, never on the wire. 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. unavailableReason: type: string enum: ['', withdrawn, pricing_pending]