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: >
        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: >
        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.
  orderCancelled:
    post:
      operationId: webhookOrderCancelled
      summary: >-
        order.cancelled — sent when a partner cancels an order via DELETE
        /orders/{id}
      description: >
        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.
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'
