> ## Documentation Index
> Fetch the complete documentation index at: https://docs.purplelabelmd.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Submit an answer and get the next question

> Submits the answer to the question the session is currently on. The answer is re-validated on the server and the session advances exactly one step, returning the next question — or `status: complete` once every required answer is in. The client never decides completion. If the answer fails validation the response is `422` with the specific issues and the session does not advance. Send `node_id` (the served `node.node_id`) to KEY the answer to its node: a stale replay of an already-passed node then resyncs cleanly (200, current node re-served) instead of misbinding to the node after a server-side skip, and a submit for a node the session has not reached fails closed with a `renderer.node_mismatch` issue (422). This op never presents a question that collects a government ID, and never waits on one to report `complete` — proof of identity is handled in the member portal's identity step, not here.



## OpenAPI

````yaml /openapi/public-openapi.json post /v1/instrument/next
openapi: 3.1.0
info:
  title: Purple API
  version: 0.0.0
servers:
  - url: https://api.dev.purplelabelmd.com
security: []
tags:
  - description: Sign a patient in and out and manage the browser session.
    name: Sessions & authentication
  - description: Run the server-driven intake questionnaire question by question.
    name: Intake
  - description: Address autocomplete for the intake flow.
    name: Addresses
  - description: Read the public status of a patient's enrollment.
    name: Enrollment
  - description: Track a client's onboarding progress.
    name: Onboarding
  - description: Read back the platform configuration stored for your brand.
    name: Brand configuration
  - description: Enable offerings for your brand and set their display copy.
    name: Offering configuration
  - description: Start and track payments for an order.
    name: Payments
  - description: Subscribe to platform events and manage delivery endpoints.
    name: Webhooks
paths:
  /v1/instrument/next:
    parameters:
      - $ref: '#/components/parameters/BrandIdHeader'
    post:
      tags:
        - Intake
      summary: Submit an answer and get the next question
      description: >-
        Submits the answer to the question the session is currently on. The
        answer is re-validated on the server and the session advances exactly
        one step, returning the next question — or `status: complete` once every
        required answer is in. The client never decides completion. If the
        answer fails validation the response is `422` with the specific issues
        and the session does not advance. Send `node_id` (the served
        `node.node_id`) to KEY the answer to its node: a stale replay of an
        already-passed node then resyncs cleanly (200, current node re-served)
        instead of misbinding to the node after a server-side skip, and a submit
        for a node the session has not reached fails closed with a
        `renderer.node_mismatch` issue (422). This op never presents a question
        that collects a government ID, and never waits on one to report
        `complete` — proof of identity is handled in the member portal's
        identity step, not here.
      operationId: nextInstrument
      parameters:
        - $ref: '#/components/parameters/CorrelationIdHeader'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InstrumentNextRequest'
        required: true
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstrumentStep'
          description: the next node, or completion
        '404':
          $ref: '#/components/responses/EdgeInstrumentSessionUnknown'
        '422':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstrumentStep'
          description: >-
            server-side control rejection, a keyed submit for a node the session
            has not reached (`renderer.node_mismatch`), or a question the served
            questionnaire cannot define an answer contract for
            (`renderer.fact_unavailable` — a server-side configuration gap,
            surfaced with the question re-served and nothing recorded); no
            advance. MEDIA ATTACHMENT (DELIVERY-146): when the node the session
            is ON is a `file_capture`, the answer MUST carry an upload reference
            that resolves to a grant issued to this member for THIS journey and
            THIS node, whose bytes have landed. Anything else — an unknown
            reference, an absent one, a reference the grant store no longer
            holds (a task roll), or media dependencies being unavailable — is
            refused here under the `intake-media-attachment-refused` problem
            type, with nothing recorded and no advance. UNLIKE the control-issue
            422 above, this one is application/problem+json (EdgeProblem-shaped)
            rather than a re-presented step — a consumer distinguishes them by
            content type, and every cause answers the ONE body so the wire
            carries no oracle. The requirement is derived from the NODE, so
            ordinary answers to non-capture questions are unaffected and no
            value is ever inspected to decide whether the rule applies.
            CONTRADICTORY SELECTIONS: a submission that carries an option
            DECLARED `exclusive` alongside any other option is refused here
            under `renderer.exclusive_violation` — a named 422, never a
            200-advance, with the question re-served and nothing recorded. The
            issue names WHICH options contradicted (`exclusive_codes` /
            `conflicting_codes`). A question whose options declare no
            `exclusive` flag is unaffected. An option set the server cannot read
            is refused the same way (`renderer.control.unknown_code`) and never
            becomes a 5xx.
        '429':
          $ref: '#/components/responses/EdgeRateLimited'
        '500':
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/EdgeProblem'
          description: >-
            The answer could not be recorded, and sending it again will never
            change that — this is the one response on this operation you must
            not retry. A single problem `type` is answered here:
            `instrument-answer-not-recordable` — the questionnaire service
            received the submission, understood it, and refused to record it
            outright, so the identical answer sent again earns the identical
            refusal at every future moment. The fault is on this service's side,
            not with anything your patient entered: a rejection your patient can
            act on is the `422` above, which re-serves the question with the
            issues named. Nothing was recorded and the session did not advance.
            No `Retry-After` accompanies this response and no delay clears it —
            do not retry automatically, and do not offer your patient a retry
            for it. Send them to human support and quote the correlation id,
            which is what lets the underlying condition be found and repaired.
        '503':
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/EdgeProblem'
          description: >-
            The step could not be completed right now. Two distinct problem
            `type`s are answered here and they are cured differently:
            `instrument-bundle-unavailable` — the questionnaire itself is still
            being loaded; `instrument-answer-store-unavailable` — the
            questionnaire served fine but the answer could not be SAVED, so
            nothing was recorded and the session did not move on. Both of these
            are transient — nothing answered, or what answered reported a fault
            of its own — so re-submitting the same answer shortly is the cure. A
            refusal that no amount of re-submission can clear is never answered
            here: it takes the `500` above, under its own problem `type`.
      security:
        - apiKey: []
components:
  parameters:
    BrandIdHeader:
      description: >-
        Opaque brand id (brd_...). Validated to belong to the resolved client
        (§2).
      in: header
      name: X-Brand-Id
      required: true
      schema:
        pattern: ^brd_[A-Za-z0-9][A-Za-z0-9_-]*$
        type: string
    CorrelationIdHeader:
      description: >-
        An optional id you send to tie this request to your own logs. Send one
        and it is echoed back unchanged; omit it and one is assigned for you.
        Either way the id is returned in the X-Correlation-Id response header on
        every response, including errors, so you can match a response to the
        request that produced it.
      in: header
      name: X-Correlation-Id
      required: false
      schema:
        maxLength: 128
        pattern: ^[A-Za-z0-9][A-Za-z0-9._:-]*$
        type: string
  schemas:
    InstrumentNextRequest:
      description: >-
        An answer for the question the session is currently on. Send `node_id`
        (recommended) to KEY the answer to the node it was collected for so a
        server-side skip or a restart can never land it on the wrong node.
      properties:
        answer:
          $ref: '#/components/schemas/AnswerValue'
        node_id:
          description: >-
            The id of the node this answer was collected for (echo the served
            `node.node_id`). ADDITIVE and OPTIONAL. When present the server
            binds the answer to that node instead of blindly to the current
            cursor: if it matches the current node the submit proceeds; if it
            names a node ALREADY PASSED (a stale replay after a serve-time skip
            or a restart) the server re-serves the current node unchanged
            (idempotent resync — no misbind, no write); if it names a node the
            session has not reached (or an unknown node) the submit fails closed
            with a `renderer.node_mismatch` issue (422), the current node
            re-presented. Absent ⇒ the legacy un-keyed binding (byte-identical)
            — which is why an un-keyed client can desync after a skip.
          type: string
        session_id:
          type: string
      required:
        - session_id
      type: object
    InstrumentStep:
      description: >-
        The result of a resolve, next, or abandon call — the session identity,
        the current question (or null when finished), the status, any flags
        (e.g. ship_state_mismatch), and any validation issues. On completion it
        may carry the entry-link hand-off.
      properties:
        blocked:
          $ref: '#/components/schemas/BlockedOutcome'
        flags:
          items:
            type: string
          type: array
        handoff:
          $ref: '#/components/schemas/EntryHandoff'
        issues:
          items:
            $ref: '#/components/schemas/ControlIssue'
          type: array
        journey_id:
          pattern: ^jny_[A-Za-z0-9][A-Za-z0-9_-]*$
          type: string
        node:
          oneOf:
            - $ref: '#/components/schemas/RenderedNode'
            - type: 'null'
        progress:
          $ref: '#/components/schemas/StepProgress'
        session_id:
          type: string
        status:
          description: >-
            `active` (a node is presented), `complete` (server-authoritative
            completion), `abandoned` (patient drop-off), or `blocked` — a
            terminal, server-authoritative refusal to proceed (a required
            consent was declined or could not be affirmed). On `blocked`, `node`
            is null and `blocked` carries the honest stop-screen outcome.
          enum:
            - active
            - complete
            - abandoned
            - blocked
          type: string
      required:
        - session_id
        - journey_id
        - status
      type: object
    EdgeProblem:
      description: RFC 7807 problem+json error body.
      properties:
        detail:
          type: string
        status:
          type: integer
        title:
          type: string
        type:
          format: uri-reference
          type: string
      required:
        - type
        - title
        - status
      type: object
    AnswerValue:
      description: >-
        A patient's answer for the current question, interpreted per its control
        type. Coded controls carry `codes`; scalar controls carry `value`; a
        number pair carries `pair`; a pre-filled-value confirmation carries
        `confirmed`; the address control carries `address`; a video capture
        carries `media` (an opaque upload reference + descriptor) and `consent`.
      properties:
        address:
          $ref: '#/components/schemas/AddressAnswer'
        codes:
          items:
            type: string
          type: array
        confirmed:
          type: boolean
        consent:
          $ref: '#/components/schemas/ConsentAck'
        media:
          $ref: '#/components/schemas/MediaAnswer'
        pair:
          items:
            type: number
          maxItems: 2
          minItems: 2
          type: array
        value:
          description: >-
            scalar value (number | scale | date | text | file ref | phone |
            email)
      type: object
    BlockedOutcome:
      description: >-
        The honest stop-screen outcome. Present only when `status` is `blocked`.
        Carries the machine-readable reason, the node at which the block
        occurred, the consent scopes that were not granted (codes, never patient
        data), and a support-routing hint. The stop-screen copy is a
        presentation concern (the intake kit renders it); no patient data ever
        rides here. No order lock ever forms without a positive affirmation
        where one is required.
      properties:
        code:
          description: stable reason code (e.g. `consent_declined`)
          type: string
        node_id:
          description: the node at which the block occurred (the consent node id)
          type: string
        support_route:
          description: >-
            a stable support-routing key (edge owns portal→GHL support routing);
            never PII
          type: string
        ungranted_scopes:
          description: consent scopes not granted — codes, never patient data
          items:
            type: string
          type: array
      required:
        - code
        - node_id
      type: object
    EntryHandoff:
      additionalProperties: false
      description: >-
        Post-completion hand-off carried when a session that began from an entry
        link completes. Present only when `status` is `complete` and the session
        carried a validated entry context. `redirect` holds a brand-allowlisted
        https URL; `promo` is an opaque pass-through token validated downstream;
        `test` is the per-client test-mode flag. Never carries patient data.
      properties:
        promo:
          maxLength: 256
          type: string
        redirect:
          maxLength: 2048
          type: string
        test:
          type: boolean
      type: object
    ControlIssue:
      properties:
        code:
          type: string
        conflicting_codes:
          description: >-
            chore-refuse-contradictory-answer (additive; paired with
            `exclusive_codes`). The option code(s) the exclusive selection
            contradicted — the other half of "which options clashed". When the
            submission carried only exclusive options, they contradict each
            other and both lists name them.
          items:
            type: string
          type: array
        exclusive_codes:
          description: >-
            chore-refuse-contradictory-answer (additive; present ONLY on `code:
            renderer.exclusive_violation`, absent on every other issue). The
            exclusive ("none of these"-class) option code(s) the refused
            submission carried. A contradictory selection on a clinical-safety
            question is a SERVE defect, not a client nicety: the server refuses
            it with a 422 and never a 200-advance, and the refusal must say
            WHICH selections contradicted so the client can correct the exact
            ones. These are the caller's OWN submitted codes echoed back to the
            caller.
          items:
            type: string
          type: array
        message:
          type: string
      required:
        - code
        - message
      type: object
    RenderedNode:
      description: >-
        A node as presented to the client — PRESENTATION ONLY. The client never
        owns the sequence, controls, or option order (those come from the
        compiled bundle; a brand theme cannot alter them). Brand theme/copy,
        when applied, ride alongside as presentation overlay.
      properties:
        computed_stub:
          type: boolean
        consent_version:
          description: >-
            the consent version served on a `consent` node. The client echoes it
            on the affirmation; it rides into the recorded consent fact as
            provenance. The MD/counsel-locked affirmation copy rides `content`;
            brand can never supply it.
          type: string
        content:
          additionalProperties:
            type: string
          description: >-
            Named display-content slots for a display node — MD-locked copy
            carried in the compiled instrument bundle, versioned and reviewed
            with the instrument. Brand config themes presentation only and can
            never supply or override these slots. The `copy` field is the
            separate question-headline channel; its authoritative source is
            likewise the MD-locked instrument bundle, and the brand overlay can
            never override it.
          type: object
        control:
          type: string
        copy:
          description: >-
            The question headline, as a single string, served from the MD-locked
            question prompt in the compiled instrument bundle (versioned and
            reviewed with the instrument). For a question node this is its only
            source — the per-brand copy overlay can never supply or override it
            (theming yes, wording never). Absent is valid; the renderer falls
            back deterministically (for example a formatted fact code).
          type: string
        display:
          enum:
            - static
            - computed
          type: string
        fact:
          type: string
        grants:
          description: >-
            the consent scopes an affirmative answer grants on a `consent` node.
            Codes, never labels; nothing branches on the copy text.
          items:
            type: string
          type: array
        interstitial:
          description: >-
            Marks a display node as an interstitial moment for skin treatment
            (the tint pair is reserved for these). Presentation flag only; never
            carried on question nodes.
          type: boolean
        kind:
          description: >-
            `question` (collects a fact), `display` (MD-locked informational
            node, no answer channel), or `consent` (a consent-capture
            affirmation surface — binds no fact; its MD/counsel-locked copy
            rides `content`, its `consent_version` + `grants` ride alongside).
          enum:
            - question
            - display
            - consent
          type: string
        may_auto_advance:
          type: boolean
        media:
          $ref: '#/components/schemas/MediaConfig'
        node_id:
          type: string
        offering_refs:
          items:
            type: string
          type: array
        option_codes:
          items:
            type: string
          type: array
        options:
          description: >-
            Per-option presentation hints for this question. When present, it
            lists the same codes in the same order as `option_codes` — option
            order comes from the questionnaire and cannot be altered by theming.
            When absent, render from `option_codes` alone.
          items:
            $ref: '#/components/schemas/RenderedOption'
          type: array
        prefill:
          enum:
            - collect
            - suppress
            - confirm
          type: string
        prefilled_value: {}
        reassurance:
          description: >-
            A single hedged reassurance line for a computed display panel,
            served from the MD-locked instrument content and reviewed with it.
            The line is SELECTED server-side by where the primary computed value
            falls against the served zone (below / in_zone / above) — never
            served statically — and is PRESENT only when the matching band is
            authored and the band could be determined; otherwise it is ABSENT
            (fail to neutral). Display copy only; it carries no eligibility
            language and defers every decision to the clinician.
          type: string
        required:
          type: boolean
        section_id:
          type: string
        theme:
          additionalProperties: true
          type: object
        values:
          additionalProperties: {}
          description: >-
            Server-supplied display values keyed by fact/derivation CODE (codes,
            never label text). Derived codes are computed server-side on read
            from registered pure derivations — the client renders these values
            and never computes anything of record. A display node whose primary
            referenced values cannot all be resolved is never emitted
            (server-skipped, fail-soft). Ancillary echoed values (for example
            the height and weight behind a computed number) ride this same map
            alongside the primary value; an echoed value that cannot be resolved
            is omitted, and never blocks the node.
          type: object
        zone:
          $ref: '#/components/schemas/DisplayZone'
      required:
        - node_id
        - kind
        - section_id
      type: object
    StepProgress:
      description: >-
        Server-computed progress metadata, present on every resolve/next step:
        computed from the live effective plan — never client-guessed.
        `position_estimate` is the honest fraction of the plan completed (0..1);
        display nodes participate in the fraction. Whether and how it renders
        (thin persistent bar, step ring, interstitial pacing, none) is
        brand-theme presentation.
      properties:
        position_estimate:
          description: honest fraction of the effective plan completed, 0..1
          maximum: 1
          minimum: 0
          type: number
        scope:
          description: '`instrument`, or a subflow id once named optional sub-flows ship'
          type: string
        section_label:
          description: >-
            optional server-supplied section label; omitted while bundles carry
            none
          type: string
      required:
        - scope
        - position_estimate
      type: object
    AddressAnswer:
      description: >-
        A selected suggestion (validated) or a manually entered address
        (unverified).
      properties:
        manual:
          $ref: '#/components/schemas/NormalizedAddress'
        override_state_mismatch:
          description: proceed despite a validated ship-state ≠ asserted-state mismatch
          type: boolean
        suggestion_id:
          type: string
      type: object
    ConsentAck:
      description: A consent-to-record acknowledgment for a question that requires it.
      properties:
        acknowledged:
          type: boolean
        version:
          type: string
      required:
        - acknowledged
      type: object
    MediaAnswer:
      description: >-
        A media (video) answer for a file-capture question. `upload_ref` is an
        opaque handle — the bytes upload directly to storage through a
        short-lived upload URL and are never inlined here (an inline data URI or
        an oversized token is refused). The descriptor fields are metadata the
        server checks against the question's capture bounds.
      properties:
        byte_size:
          type: integer
        content_type:
          type: string
        duration_s:
          type: number
        upload_ref:
          description: 'opaque vault handle; never inline bytes / a data: URI'
          maxLength: 256
          type: string
      type: object
    MediaConfig:
      description: >-
        Capture configuration for a file-capture question. When absent, the
        question is a standard photo or document upload. Presentation and
        capture-bounds only — the answer is an upload reference and nothing
        branches on the media content; faces and voices are never matched,
        recognized, or graphed.
      properties:
        accept:
          description: MIME allowlist (e.g. video/mp4, video/webm)
          items:
            type: string
          type: array
        capture_mode:
          enum:
            - upload
            - record
            - record_or_upload
          type: string
        consent_copy:
          description: >-
            DELIVERY-163 — the PLAIN DISPLAY TEXT the member must be shown
            before any affirmative action, pinned to `consent_version`. The
            portal renders THIS EXACT text; it is not a key, not a template, and
            the client never substitutes its own wording — consent to copy the
            server did not serve is not consent. Optional on the contract
            because non-video captures may not gate on consent at all, but
            REQUIRED (nonempty) for a managed video capture alongside
            `consent_version` and `consent_fact`: all three, or no grant and no
            recorded consent. Authored and served through the same MediaConfig,
            so the text the member reads and the version the server records can
            never drift apart.
          type: string
        consent_fact:
          description: >-
            fact code the consent acknowledgment projects to (discrete auditable
            consent fact)
          type: string
        consent_version:
          description: >-
            the version OF RECORD for the consent copy below. The server checks
            the echoed version against this one and records it on the
            acknowledgment; there is NO versionless fallback — a managed video
            capture whose node omits this refuses rather than recording a
            consent that cannot say which copy was agreed to.
          type: string
        facing:
          description: advisory front/rear camera hint; not enforced
          enum:
            - user
            - environment
          type: string
        kind:
          enum:
            - image
            - video
          type: string
        max_bytes:
          type: integer
        max_duration_s:
          type: number
        min_duration_s:
          type: number
        require_consent:
          description: gate capture on a consent-to-record acknowledgment (video ⇒ true)
          type: boolean
        resumable:
          type: boolean
      required:
        - kind
      type: object
    RenderedOption:
      description: >-
        One selectable option with its presentation hints — presentation only,
        carried alongside `option_codes`. An `exclusive` option (a "none of
        these"-class choice) cannot be combined with any sibling — selecting it
        should clear the other selections, and the server rejects an answer that
        carries an exclusive code together with another selection (`422`, the
        session does not advance). A banded choice (for example a blood-pressure
        or frequency range) may carry a `classification_code` alongside its
        stable option code.
      properties:
        classification_code:
          description: >-
            classification code carried by a banded choice alongside its stable
            option code
          type: string
        code:
          description: the stable option code submitted as the answer value
          type: string
        exclusive:
          description: >-
            marks a "none of these"-class option that cannot be combined with
            any sibling selection
          type: boolean
        label:
          description: >-
            MD-locked display label for this option, carried in the compiled
            instrument bundle and resolved by the stable option code (never
            positional). Presentation only — the code remains the submitted
            answer value and the sole binding; consumers fall back to the code
            when no label is present.
          type: string
      required:
        - code
      type: object
    DisplayZone:
      description: >-
        A designed-panel band descriptor for a computed display node: a scale
        from `scale_min` to `scale_max` with a highlighted band from `zone_min`
        to `zone_max`, named by `zone_label`. The client places the patient's
        marker and the highlighted band for the picture. Display copy and
        presentation only — a zone carries no eligibility language and never
        asserts eligibility; the server remains authoritative on jurisdiction
        and eligibility.
      properties:
        scale_max:
          description: high end of the rendered scale axis
          type: number
        scale_min:
          description: low end of the rendered scale axis
          type: number
        zone_label:
          description: label naming the highlighted band (display copy; never an outcome)
          type: string
        zone_max:
          description: high bound of the highlighted band (within the scale)
          type: number
        zone_min:
          description: low bound of the highlighted band (within the scale)
          type: number
      required:
        - scale_min
        - scale_max
        - zone_min
        - zone_max
        - zone_label
      type: object
    NormalizedAddress:
      properties:
        city:
          type: string
        country:
          type: string
        line1:
          type: string
        line2:
          type: string
        postal_code:
          type: string
        state:
          description: >-
            ISO 3166-2-style state code, compared to the asserted jurisdiction
            state
          type: string
      required:
        - line1
        - city
        - state
        - postal_code
        - country
      type: object
  responses:
    EdgeInstrumentSessionUnknown:
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/EdgeProblem'
      description: >-
        The questionnaire session named by `session_id` is not available: it
        never existed, or it has expired. Sessions live in the serving process,
        so a deployment retires the ones in flight. Start a fresh questionnaire
        (`/instrument/resolve`) and collect the answers again — retrying the
        same `session_id` will never succeed. The answer is identical for a
        malformed, an expired, and an unknown session id, so it carries no
        signal about which.
    EdgeRateLimited:
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/EdgeProblem'
      description: >-
        Too many requests. You have exceeded the request rate allowed for this
        endpoint. Wait the number of seconds given in the Retry-After response
        header, then retry the request.
      headers:
        Retry-After:
          description: Seconds to wait before you retry.
          schema:
            type: integer
        X-Correlation-Id:
          description: The correlation id for this request, echoed back.
          schema:
            type: string
  securitySchemes:
    apiKey:
      description: 'Per-client API key (M2M). Presented as `Authorization: Bearer <key>`.'
      scheme: bearer
      type: http

````