openapi: 3.1.0
info:
  title: JustLabs Partner API
  version: "1.0.0"
  description: |
    Server-to-server API for B2B lab-ordering partners. Lets a partner browse
    the JustLabs catalog, find Quest Diagnostics draw sites, place lab orders
    for their end users, poll order status, and retrieve requisitions and
    results — all charged to the partner's own Stripe payment method (never
    the individual patient's).

    All requests are JSON (`Content-Type: application/json`) and authenticate
    with a bearer API key (`Authorization: Bearer jl_live_...` in production,
    `jl_test_...` on the sandbox). Money is always integer cents. Dates are
    ISO-8601. Every error response has the shape documented under `Error`
    below.

    See https://docs.justlabs.health for the full integration guide, including webhook signature verification and retry semantics.
  contact:
    name: JustLabs Partner Support
    email: support@justlabs.health
servers:
  - url: https://justlabs.health/api/v1
    description: Production
  - url: https://labify-staging-3hnx3fg7ca-uc.a.run.app/api/v1
    description: Sandbox
security:
  - bearer: []

webhooks:
  orderRequisitionReady:
    post:
      operationId: webhookOrderRequisitionReady
      summary: 'order.requisition_ready — sent when an order reaches requisition_ready'
      description: &webhookDeliveryNote |
        Delivered by JustLabs to the partner's configured webhook URL, signed
        with `X-JustLabs-Signature` (see the Webhooks page at https://docs.justlabs.health for
        verification). At-least-once delivery — dedupe on
        `X-JustLabs-Delivery`, not on payload content. No ordering guarantee
        even for deliveries on the same order (an inline first attempt and a
        5-minute-granularity cron retry can race); treat `data.status` as a
        hint and re-fetch `GET /orders/{id}` if you need the authoritative
        current state.

        **Your endpoint must respond `2xx` directly.** A `3xx` redirect is
        treated as a failed delivery attempt, not followed — JustLabs never
        re-POSTs a signed webhook body to a different origin. Non-2xx
        (including 3xx) and timeouts are retried per the backoff schedule in
        the guide.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
      responses:
        '200':
          description: Acknowledged.
  orderResultsReady:
    post:
      operationId: webhookOrderResultsReady
      summary: 'order.results_ready — sent when an order reaches results_ready'
      description: *webhookDeliveryNote
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
      responses:
        '200':
          description: Acknowledged.
  orderCancelled:
    post:
      operationId: webhookOrderCancelled
      summary: 'order.cancelled — sent when a partner cancels an order via DELETE /orders/{id}'
      description: *webhookDeliveryNote
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPayload' }
      responses:
        '200':
          description: Acknowledged.

tags:
  - name: Catalog
  - name: Locations
  - name: Orders

paths:
  /catalog:
    get:
      operationId: getCatalog
      tags: [Catalog]
      summary: Get the current test/panel catalog and per-order fees
      description: |
        Returns every orderable test and panel, the partner's own per-order
        fees (lab collection + physician order), and the list of US states
        JustLabs cannot currently serve. Response is cacheable for a few
        minutes (`Cache-Control: private, max-age=300`).

        Panels do **not** carry a `biomarkers` field — only their member
        `tests` ids. Look up each test id in `tests[]` for its biomarkers.
      responses:
        '200':
          description: Catalog snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Catalog'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /locations:
    get:
      operationId: listLocations
      tags: [Locations]
      summary: Find nearby Quest Diagnostics draw sites
      description: |
        Search by ZIP (`?zip=`) or by coordinates (`?lat=&lng=&radiusMiles=`).
        Exactly one search mode is used per request — if `zip` is present it
        takes precedence; otherwise `lat`/`lng` are required.
      parameters:
        - name: zip
          in: query
          description: 5 or 9 digit US ZIP code. Mutually exclusive with lat/lng search.
          schema: { type: string, pattern: '^\d{5}(-?\d{4})?$' }
        - name: lat
          in: query
          description: Latitude. Required (with `lng`) when `zip` is omitted.
          schema: { type: number }
        - name: lng
          in: query
          description: Longitude. Required (with `lat`) when `zip` is omitted.
          schema: { type: number }
        - name: radiusMiles
          in: query
          description: Search radius in miles for the lat/lng mode. Capped at 250.
          schema: { type: number, default: 50, maximum: 250 }
        - name: limit
          in: query
          description: Max results, 1-200.
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        '200':
          description: Nearby Quest locations, nearest first
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Location'
        '400':
          description: Malformed or missing search parameters
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badZip: { value: { error: { code: validation_error, message: 'zip must be 5 or 9 digits.' } } }
                missingCoords: { value: { error: { code: validation_error, message: 'Provide lat and lng, or zip.' } } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /orders:
    post:
      operationId: createOrder
      tags: [Orders]
      summary: Create a lab order
      description: |
        Charges the partner's Stripe payment method for the order total
        (sum of item prices + the partner's per-order fees) and, on success,
        schedules the order for automated placement at Quest.

        **`Idempotency-Key` is required** (1–100 characters,
        `[A-Za-z0-9_.-]`). Retry behavior depends on the response — see
        "Idempotency & retries" below and https://docs.justlabs.health.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrderRequest'
      responses:
        '201':
          description: Order created (or idempotent replay of a previously completed request)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicOrder'
        '400':
          description: |
            Malformed request. `code` is one of: `validation_error` (body
            failed schema or field-level validation — see
            `details.fields`), `unavailable_in_state` (patient address is in
            a restricted state), `unknown_item` (an item id is not in the
            catalog), `minor_not_eligible` (an item is not available to a
            patient under 18), `unknown_location` (`preferredLocationId`
            does not match a Quest location), or `idempotency_key_required`
            (missing, >100 character, or containing characters outside
            `[A-Za-z0-9_.-]` `Idempotency-Key` header).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                validation: { value: { error: { code: validation_error, message: 'The request body is invalid.', details: { fields: { 'patient.phone': 'must be a 10-digit US number' } } } } }
                restrictedState: { value: { error: { code: unavailable_in_state, message: 'JustLabs cannot serve patients in NY.', details: { state: NY } } } }
                unknownItem: { value: { error: { code: unknown_item, message: 'One or more items are not in the catalog.', details: { ids: ['bad-id'] } } } }
                minorNotEligible: { value: { error: { code: minor_not_eligible, message: 'These items are not available to patients under 18.', details: { ids: ['test-x'] } } } }
                unknownLocation: { value: { error: { code: unknown_location, message: 'preferredLocationId does not match a Quest location.', details: { id: 'loc_123' } } } }
                idempotencyKeyRequired: { value: { error: { code: idempotency_key_required, message: 'Send an Idempotency-Key header (any unique string, 1-100 characters).' } } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: |
            Payment could not be charged. `code` is `no_payment_method` (no
            card on file for the partner — add one in the partner portal) or
            `payment_failed` (Stripe declined the charge, or the PaymentIntent
            ended in a TERMINAL non-`succeeded` status:
            `requires_payment_method` or `canceled`). Both are definitive —
            no charge occurred — and safe to retry with the **same**
            `Idempotency-Key`, within the 24-hour idempotency window, once
            the underlying issue is fixed.

            `payment_failed`'s `details` shape depends on how it failed:
            `{ declineCode }` when Stripe raised a card error
            (`declineCode` may itself be `null` if Stripe didn't supply one),
            or `{ status }` (the PaymentIntent's terminal non-`succeeded`
            status, no `declineCode`) when the intent resolved without an
            error but didn't complete.

            A PaymentIntent that is still live (`processing`,
            `requires_action`, `requires_confirmation`, `requires_capture`)
            is NOT a decline and is reported as `502 payment_pending` instead
            — see the 502 response.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                noPaymentMethod: { value: { error: { code: no_payment_method, message: 'No payment method on file. Add a card in the JustLabs partner portal.' } } }
                paymentFailedDeclined: { value: { error: { code: payment_failed, message: 'Your card was declined.', details: { declineCode: insufficient_funds } } } }
                paymentFailedNotSucceeded: { value: { error: { code: payment_failed, message: 'Payment not completed (status requires_payment_method).', details: { status: requires_payment_method } } } }
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: |
            `code` is `idempotency_in_progress` (another request with this
            key is still being processed — retry shortly; this also covers
            retrying within 5 minutes of a `500 internal_error` on the same
            key, since that key is left `in_progress`), `idempotency_conflict`
            (this key was already used with a different request body — use a
            new key), or `payment_in_flight` (the previous attempt's payment
            outcome is unknown; retry with the **same** key and body, within
            24 hours — a new key risks a second charge).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                inProgress: { value: { error: { code: idempotency_in_progress, message: 'A request with this Idempotency-Key is still being processed.' } } }
                conflict: { value: { error: { code: idempotency_conflict, message: 'This Idempotency-Key was already used with a different body.' } } }
                paymentInFlight: { value: { error: { code: payment_in_flight, message: 'A payment for this Idempotency-Key is still being processed or its parameters changed. Retry in a few minutes with the same key and body.' } } }
        '500':
          description: |
            `internal_error`. The order may or may not have been recorded —
            retry with the **same** `Idempotency-Key`, within 24 hours; never
            mint a new one. The key is left `in_progress`, so a retry within
            5 minutes gets `409 idempotency_in_progress` instead — that's
            expected; retry again shortly.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: |
            `code` is `payment_provider_error` (the payment provider failed
            or did not respond — covers both a Stripe outage and a
            non-network Stripe error other than a card decline or idempotency
            conflict) or `payment_pending` (the PaymentIntent was created but
            is still live — `details.status` is one of `processing`,
            `requires_action`, `requires_confirmation`, `requires_capture` —
            so whether the charge completes is not yet known). Either way:
            retry with the **same** `Idempotency-Key` and body, within 24
            hours; a new key could charge twice. For `payment_pending` the
            retry checks that same intent's CURRENT status rather than
            creating a new one, so it resolves to `201`/`402` once Stripe
            settles it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                providerError: { value: { error: { code: payment_provider_error, message: 'The payment provider did not respond. Retry with the SAME Idempotency-Key and body; a new key could charge twice.' } } }
                paymentPending: { value: { error: { code: payment_pending, message: 'The payment is still processing. Retry with the SAME Idempotency-Key and body.', details: { status: processing } } } }
    get:
      operationId: listOrders
      tags: [Orders]
      summary: List orders
      description: Partner-scoped, newest first. Supports cursor pagination.
      parameters:
        - name: status
          in: query
          description: Filter to one public status.
          schema: { $ref: '#/components/schemas/PublicOrderStatus' }
        - name: externalId
          in: query
          description: Filter to orders created with this `externalId`.
          schema: { type: string }
        - name: limit
          in: query
          description: Page size, 1-100.
          schema: { type: integer, default: 50, minimum: 1, maximum: 100 }
        - name: cursor
          in: query
          description: Opaque pagination cursor from a previous response's `nextCursor`.
          schema: { type: string }
      responses:
        '200':
          description: A page of orders
          content:
            application/json:
              schema:
                type: object
                required: [data, nextCursor]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/PublicOrder'
                  nextCursor:
                    type: [string, 'null']
                    description: |
                      Pass as `?cursor=` to fetch the next page; `null` when
                      there are no more results. Paginate until this is
                      `null` — do NOT stop as soon as `data` is empty: when
                      filtering with `?status=`, an intermediate page can
                      have `data: []` with a non-null `nextCursor` (the
                      underlying query window matched zero rows passing the
                      filter, but more unfiltered rows exist further back).
        '400':
          description: Unknown `status` value, or a malformed `cursor`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                unknownStatus: { value: { error: { code: validation_error, message: 'Unknown status filter.', details: { allowed: [processing, requisition_ready, results_ready, cancelled] } } } }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /orders/{id}:
    parameters:
      - $ref: '#/components/parameters/OrderId'
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Get a single order
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicOrder'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: cancelOrder
      tags: [Orders]
      summary: Cancel an order and refund the charge
      description: |
        Cancellable only while the order has **provably never reached
        Quest** — in practice, within about 60 minutes of creation and
        before a requisition has been issued. Refunds the partner's Stripe
        charge in the same request when possible. Takes no request body.
      responses:
        '200':
          description: Order cancelled
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PublicOrder'
                  - type: object
                    required: [refund]
                    properties:
                      refund:
                        type: string
                        enum: [issued, pending]
                        description: '`pending` means the cancellation succeeded but the Stripe refund itself failed and JustLabs staff were alerted to issue it by hand.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            `cannot_cancel` — the order can no longer be cancelled.
            `details.reason` is one of: `already_cancelled`,
            `requisition_issued` (the order is already at Quest),
            `may_exist_at_quest` (placement outcome is unconfirmed; JustLabs
            support is verifying with Quest), `placing` (placement is in
            progress right now), `placed`, `fullscript_draft_exists` (a
            JustLabs operator is preparing manual placement — contact
            support@justlabs.health), or `not_cancellable` (the order is
            not in a cancellable state — contact support@justlabs.health).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                requisitionIssued: { value: { error: { code: cannot_cancel, message: 'The requisition has been issued; the order is at Quest.', details: { reason: requisition_issued } } } }
                fullscriptDraft: { value: { error: { code: cannot_cancel, message: 'This order is being prepared for placement by JustLabs staff. Contact support@justlabs.health.', details: { reason: fullscript_draft_exists } } } }
        '500':
          description: internal_error — the cancellation could not be completed; contact support with the order id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orders/{id}/requisition:
    parameters:
      - $ref: '#/components/parameters/OrderId'
    get:
      operationId: getOrderRequisition
      tags: [Orders]
      summary: Get a signed download URL for the lab requisition PDF
      description: Available once the order's status is `requisition_ready`. The signed URL expires in 15 minutes — fetch a fresh one if it lapses.
      responses:
        '200':
          description: Signed URL for the requisition PDF
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignedUrl'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: not_ready — the requisition has not been issued yet.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /orders/{id}/results:
    parameters:
      - $ref: '#/components/parameters/OrderId'
    get:
      operationId: getOrderResults
      tags: [Orders]
      summary: Get structured results and signed PDF URLs
      description: Available once the order's status is `results_ready`.
      responses:
        '200':
          description: Results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResultsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: not_ready — results are not available yet.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer jl_live_...` on production, `Authorization: Bearer jl_test_...` on the sandbox.
        A key issued for the wrong environment is rejected with `401 wrong_environment`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        Required, 1–100 chars, `[A-Za-z0-9_.-]` (a UUID is fine). Any other
        character is rejected with `400 idempotency_key_required`.

        Keys are held for **24 hours** from first use (`IDEMPOTENCY_TTL_HOURS`).
        Within that window, reusing a key with the SAME body replays the
        original outcome instead of creating a new order/charge (see
        "Idempotency & retries" in the guide for the exact per-status-code
        rules — retry behavior is NOT simply "always safe to repeat"). Once a
        key expires, it is treated as never having been used: reusing it
        mints a genuinely new order and a new charge, even if the same key
        was used for a real order less than a day earlier.
      schema: { type: string, minLength: 1, maxLength: 100 }
    OrderId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: The JustLabs order id, e.g. `order_1758000000000_ab12cd34`.

  responses:
    Unauthorized:
      description: |
        `invalid_api_key` (missing, malformed, unknown, or revoked key) or
        `wrong_environment` (a live key used against the sandbox, or vice versa).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalidKey: { value: { error: { code: invalid_api_key, message: 'Unknown API key.' } } }
            wrongEnvironment: { value: { error: { code: wrong_environment, message: 'This is a sandbox key; use a jl_live_ key against production.' } } }
    Forbidden:
      description: partner_suspended — this partner account is suspended.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            suspended: { value: { error: { code: partner_suspended, message: 'This partner account is suspended. Contact support@justlabs.health.' } } }
    NotFound:
      description: not_found — no such order (also returned for another partner's order id, to avoid leaking existence).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            notFound: { value: { error: { code: not_found, message: 'No such order.' } } }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Machine-readable error code, e.g. `validation_error`, `unavailable_in_state`, `unknown_item`, `minor_not_eligible`, `unknown_location`, `idempotency_key_required`, `idempotency_in_progress`, `idempotency_conflict`, `payment_in_flight`, `no_payment_method`, `payment_failed`, `payment_provider_error`, `payment_pending`, `invalid_api_key`, `wrong_environment`, `partner_suspended`, `not_found`, `not_ready`, `cannot_cancel`, `internal_error`.
            message:
              type: string
              description: Human-readable, safe to log or surface to an operator (never to the patient).
            details:
              type: object
              description: 'Optional structured detail, shape depends on `code` (e.g. `{fields: {...}}` for `validation_error`, `{reason: ...}` for `cannot_cancel`).'

    PublicOrderStatus:
      type: string
      enum: [processing, requisition_ready, results_ready, cancelled]
      description: |
        `processing` — paid, not yet at a partner-visible checkpoint (also
        covers internal retry/placement trouble, which never surfaces to
        partners). `requisition_ready` — the lab requisition PDF is
        available; the patient can go get drawn. `results_ready` — results
        have been ingested. `cancelled` — cancelled and refunded (or refund
        pending).

    ResultFlag:
      type: string
      enum: [critical_low, low, borderline_low, normal, borderline_high, high, critical_high]
      description: Ordered from most to least concerning. `null` on the analyte (not one of these strings) means no clinical verdict was made — see `PublicAnalyte.flag`.

    Money:
      type: integer
      description: US cents.

    Catalog:
      type: object
      required: [fees, restrictedStates, tests, panels]
      properties:
        fees:
          type: object
          required: [labCollectionCents, physicianOrderCents]
          properties:
            labCollectionCents: { $ref: '#/components/schemas/Money' }
            physicianOrderCents: { $ref: '#/components/schemas/Money' }
        restrictedStates:
          type: array
          items: { type: string }
          description: Two-letter US state codes JustLabs cannot currently serve (e.g. NY, NJ, RI, HI).
        tests:
          type: array
          items: { $ref: '#/components/schemas/CatalogTest' }
        panels:
          type: array
          items: { $ref: '#/components/schemas/CatalogPanel' }

    CatalogTest:
      type: object
      required: [id, type, name, priceCents, category, biomarkers, fastingRequired, sampleTypes, turnaroundDays, minorEligible]
      properties:
        id: { type: string }
        type: { type: string, enum: [test] }
        name: { type: string }
        priceCents: { $ref: '#/components/schemas/Money' }
        category: { type: string }
        biomarkers:
          type: array
          items: { type: string }
        fastingRequired: { type: boolean }
        sampleTypes:
          type: array
          items: { type: string }
        turnaroundDays: { type: string }
        minorEligible: { type: boolean }

    CatalogPanel:
      type: object
      required: [id, type, name, priceCents, category, tests, fastingRequired, minorEligible]
      description: A bundle of tests. Note there is no `biomarkers` field — look up each id in `tests[]` against `CatalogTest` for its biomarkers.
      properties:
        id: { type: string }
        type: { type: string, enum: [panel] }
        name: { type: string }
        priceCents: { $ref: '#/components/schemas/Money' }
        category: { type: string }
        tests:
          type: array
          items: { type: string }
          description: Ids of the member tests.
        fastingRequired: { type: boolean }
        minorEligible: { type: boolean }

    Location:
      type: object
      required: [id, name, address, city, state, zip, phone, lat, lng, distanceMiles]
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string }
        city: { type: string }
        state: { type: string }
        zip: { type: string }
        phone: { type: [string, 'null'] }
        lat: { type: number }
        lng: { type: number }
        distanceMiles: { type: [number, 'null'], description: 'Distance from the query point, rounded to 0.1 miles. null for a ZIP search when the query ZIP had no known coordinates.' }

    PatientInput:
      type: object
      required: [firstName, lastName, dateOfBirth, sex, email, phone, address]
      properties:
        firstName: { type: string, minLength: 2, description: 'At least 2 characters once trimmed and internal whitespace runs collapsed (`isValidName` in patientValidation.ts).' }
        lastName: { type: string, minLength: 2, description: 'Same rule as firstName.' }
        dateOfBirth: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$', description: 'YYYY-MM-DD. Must be a real calendar date (no Feb 30, no rollover), strictly in the past, and not more than 110 years ago (`isValidDob` / `MAX_AGE_YEARS` in patientValidation.ts).' }
        sex: { type: string, enum: [male, female] }
        email: { type: string, format: email }
        phone: { type: string, description: 'A US (NANP) number: exactly 10 digits once normalized, with the area code and exchange code each starting 2-9 (`isValidPhone`/`NANP_PATTERN` in patientValidation.ts). Common formatting (dashes, parens, spaces, a leading "1") is accepted and stripped.' }
        address:
          type: object
          required: [line1, city, state, zip]
          properties:
            line1: { type: string, minLength: 1, maxLength: 100 }
            line2: { type: string, maxLength: 100 }
            city: { type: string, minLength: 1, maxLength: 60 }
            state: { type: string, pattern: '^[A-Za-z]{2}$', description: 'Two-letter US state code.' }
            zip: { type: string, pattern: '^\d{5}(-?\d{4})?$' }

    GuardianInput:
      type: object
      required: [firstName, lastName, email, relationship]
      description: Required when the patient is under 18 at time of purchase.
      properties:
        firstName: { type: string, minLength: 1 }
        lastName: { type: string, minLength: 1 }
        email: { type: string, format: email }
        relationship: { type: string, enum: [parent, legal_guardian, other] }

    CreateOrderRequest:
      type: object
      required: [patient, items]
      properties:
        externalId:
          type: [string, 'null']
          minLength: 1
          maxLength: 100
          description: Your own id for this order. Non-empty when present. Returned back on the order and filterable via `GET /orders?externalId=`.
        patient:
          $ref: '#/components/schemas/PatientInput'
        items:
          type: array
          minItems: 1
          maxItems: 25
          description: 'Duplicate ids are de-duplicated server-side before pricing — sending the same id twice does not double the charge or the order line.'
          items:
            type: object
            required: [id]
            properties:
              id: { type: string, description: 'A test or panel id from GET /catalog.' }
        guardian:
          $ref: '#/components/schemas/GuardianInput'
        preferredLocationId:
          type: [string, 'null']
          description: A Quest location id from GET /locations. Optional.
        metadata:
          type: [object, 'null']
          description: Up to 20 string key/value pairs (keys ≤40 chars, values ≤200 chars). Echoed back on the order, never used by JustLabs.
          maxProperties: 20
          propertyNames: { maxLength: 40 }
          additionalProperties: { type: string, maxLength: 200 }

    PublicOrderItem:
      type: object
      required: [id, type, name, priceCents]
      properties:
        id: { type: string }
        type: { type: string, enum: [test, panel] }
        name: { type: string }
        priceCents: { $ref: '#/components/schemas/Money' }

    PublicOrder:
      type: object
      required:
        - id
        - externalId
        - status
        - createdAt
        - items
        - fees
        - subtotalCents
        - totalCents
        - payment
        - patient
        - fastingRequired
        - preferredLocation
        - requisition
        - results
        - metadata
        - cancelledAt
      properties:
        id: { type: string }
        externalId: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/PublicOrderStatus' }
        createdAt: { type: string, format: date-time }
        items:
          type: array
          items: { $ref: '#/components/schemas/PublicOrderItem' }
        fees:
          type: object
          required: [labCollectionCents, physicianOrderCents]
          properties:
            labCollectionCents: { $ref: '#/components/schemas/Money' }
            physicianOrderCents: { $ref: '#/components/schemas/Money' }
        subtotalCents: { $ref: '#/components/schemas/Money' }
        totalCents: { $ref: '#/components/schemas/Money' }
        payment:
          type: object
          required: [stripePaymentIntentId, receiptUrl]
          properties:
            stripePaymentIntentId: { type: [string, 'null'] }
            receiptUrl: { type: [string, 'null'] }
        patient:
          type: object
          required: [firstName, lastName, dateOfBirth, sex]
          properties:
            firstName: { type: string }
            lastName: { type: string }
            dateOfBirth: { type: string }
            sex: { type: string, enum: [male, female] }
        fastingRequired: { type: boolean }
        preferredLocation:
          type: [object, 'null']
          required: [id, name, address]
          properties:
            id: { type: string }
            name: { type: string }
            address: { type: string }
        requisition:
          type: [object, 'null']
          required: [available]
          properties:
            available: { type: boolean, enum: [true] }
        results:
          type: [object, 'null']
          required: [available, reportedAt]
          properties:
            available: { type: boolean, enum: [true] }
            reportedAt: { type: [string, 'null'], format: date-time }
        metadata:
          type: object
          additionalProperties: { type: string }
        cancelledAt: { type: [string, 'null'], format: date-time }

    SignedUrl:
      type: object
      required: [url, expiresAt]
      properties:
        url: { type: string, format: uri }
        expiresAt: { type: string, format: date-time, description: 'Always 15 minutes after generation.' }

    WebhookPayload:
      type: object
      required: [id, type, createdAt, data]
      description: 'Deliberately minimal — ids and status only, never patient data or test names. Fetch GET /orders/{id} for full detail.'
      properties:
        id:
          type: string
          description: Delivery id. Same value as the `X-JustLabs-Delivery` header — use it to dedupe (at-least-once delivery).
        type:
          type: string
          enum: [order.requisition_ready, order.results_ready, order.cancelled]
        createdAt: { type: string, format: date-time }
        data:
          type: object
          required: [orderId, externalId, status]
          properties:
            orderId: { type: string }
            externalId: { type: [string, 'null'] }
            status: { $ref: '#/components/schemas/PublicOrderStatus' }

    PublicAnalyte:
      type: object
      required: [name, labTestCode, value, unit, referenceRange, referenceRangeLow, referenceRangeHigh, flag, notReported, reportedAt]
      properties:
        name: { type: string }
        labTestCode: { type: [string, 'null'] }
        value:
          oneOf:
            - type: number
            - type: string
        unit: { type: string }
        referenceRange: { type: [string, 'null'] }
        referenceRangeLow: { type: [number, 'null'] }
        referenceRangeHigh: { type: [number, 'null'] }
        flag:
          oneOf:
            - $ref: '#/components/schemas/ResultFlag'
            - type: 'null'
          description: '`null` means no clinical verdict was made for this analyte — never coerce to `normal`. This is distinct from `notReported`.'
        notReported: { type: boolean, description: The lab explicitly withheld this result (e.g. reflex-tested-out). }
        reportedAt: { type: [string, 'null'], format: date-time }

    ResultsResponse:
      type: object
      required: [status, reportedAt, source, pdfs, analytes]
      properties:
        status: { $ref: '#/components/schemas/PublicOrderStatus' }
        reportedAt: { type: [string, 'null'], format: date-time }
        source: { type: string, enum: [quest, upload], description: '`quest` = delivered electronically from the lab; `upload` = a JustLabs operator uploaded the PDF by hand.' }
        pdfs:
          type: array
          items:
            allOf:
              - type: object
                required: [id, label]
                properties:
                  id: { type: string }
                  label: { type: string }
              - $ref: '#/components/schemas/SignedUrl'
        analytes:
          type: array
          items: { $ref: '#/components/schemas/PublicAnalyte' }
