openapi: 3.1.0
info:
  title: Tendral Health API
  version: 1.0.0
  description: |
    Public API for Tendral Health.

    **Base URL:** `https://api.tendralhealth.com/v1/`. Authentication is bearer-token (`Authorization: Bearer tk_live_xxxxxxxxxxxx`); partner endpoints rate-limit at 100 req/min; idempotency is supported via `Idempotency-Key`; outbound webhooks are signed Stripe-style. Resources carry an `object` discriminator and a `livemode` boolean; lists carry `has_more` and `next_cursor`.

    Discrete guides on the developer portal:

    - [Quickstart](https://developer.tendralhealth.com/docs/quickstart) — first call in 5 minutes
    - [Authentication](https://developer.tendralhealth.com/docs/authentication) — bearer tokens, scopes, rotation
    - [Webhooks](https://developer.tendralhealth.com/docs/webhooks) — signature format, event catalog, HMAC verification
    - [Errors](https://developer.tendralhealth.com/docs/errors) — every error code with retry guidance
    - [Rate limits](https://developer.tendralhealth.com/docs/rate-limits) · [Idempotency](https://developer.tendralhealth.com/docs/idempotency) · [Pagination](https://developer.tendralhealth.com/docs/pagination)
    - [Changelog](https://developer.tendralhealth.com/docs/changelog)

    Stitched Health is the first partner consumer — see [the protocol contract](https://github.com/epaynter/tendral-stitched-integration). Legacy non-Partner endpoints (`Surveys`, `Uploads`) use the simpler `{ code, message, errors }` shape via the `ErrorResponse` schema; Partners use the Stripe-style `PartnerErrorResponse` envelope (`error.type`, `error.code`, `error.doc_url`, `error.request_id`).
servers:
  - url: 'https://api.staging.tendralhealth.com'
    description: Staging
  - url: 'https://api.tendralhealth.com'
    description: Production
security:
  - BearerAuth: []
paths:
  /v1/health:
    get:
      summary: Health check
      description: Returns 200 with status `ok` when the API is reachable. Unauthenticated. Useful as a smoke test before partner onboarding.
      operationId: getHealth
      tags:
        - System
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
                  version:
                    type: string
                    example: 1.0.0
                  timestamp:
                    type: string
                    format: date-time
                    example: '2026-02-13T00:00:00.000Z'
                required:
                  - status
                  - version
                  - timestamp
  /v1/surveys:
    post:
      summary: '[Coming soon] Create a survey'
      description: Import a complete survey from JSON. **Not yet shipped** — schema is published for partner planning. Calling this endpoint returns 501.
      operationId: createSurvey
      tags:
        - Surveys
      deprecated: true
      x-internal: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SurveyInput'
      responses:
        '201':
          description: Survey created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SurveyCreated'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/surveys/validate:
    post:
      summary: '[Coming soon] Validate a survey (dry run)'
      description: Validate survey JSON without creating it. **Not yet shipped** — schema is published for partner planning.
      operationId: validateSurvey
      tags:
        - Surveys
      deprecated: true
      x-internal: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SurveyInput'
      responses:
        '200':
          description: Validation passed
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid:
                    type: boolean
                    example: true
                  questionCount:
                    type: integer
                    example: 12
                  caseCount:
                    type: integer
                    example: 0
                required:
                  - valid
                  - questionCount
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/uploads/presign:
    get:
      summary: '[Coming soon] Get a pre-signed upload URL'
      description: Get a pre-signed URL for uploading a PDF (e.g. case publication). **Not yet shipped** — schema is published for partner planning.
      operationId: getPresignedUrl
      tags:
        - Uploads
      deprecated: true
      x-internal: true
      parameters:
        - name: filename
          in: query
          required: true
          schema:
            type: string
          description: Original filename (used for content-type detection)
        - name: contentType
          in: query
          required: false
          schema:
            type: string
            default: application/pdf
          description: MIME type of the file
      responses:
        '200':
          description: Pre-signed URL generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    format: uri
                    description: Pre-signed upload URL (PUT)
                  publicUrl:
                    type: string
                    format: uri
                    description: Public URL to reference after upload completes
                  expiresIn:
                    type: integer
                    description: Seconds until the pre-signed URL expires
                    example: 3600
                required:
                  - url
                  - publicUrl
                  - expiresIn
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  '/v1/partners/stitched/tokens/{token}':
    get:
      summary: Resolve a Tendral short-link token
      description: |-
        Single-token lookup. Returns the recipient record bound to a Tendral-generated 6-character base62 token. Used for ad-hoc / debugging only — Stitched's redirect handler MUST NOT call this on the click path (see integration spec §3.2).

        Emits a strong `ETag` derived from the row's `updated_at`. Re-requests with `If-None-Match: <etag>` return `304` with no body and rate-limit headers retained.
      operationId: getPartnerStitchedToken
      tags:
        - Partners
      parameters:
        - name: token
          in: path
          required: true
          schema:
            type: string
            pattern: '^[0-9A-Za-z]{6}$'
          description: 6-character base62 short-link token.
        - name: 'expand[]'
          in: query
          schema:
            type: array
            items:
              type: string
              enum:
                - campaign
                - program
          style: form
          explode: true
          description: 'Inline related objects in the response. Allowed: `campaign`, `program`.'
        - name: If-None-Match
          in: header
          schema:
            type: string
          description: ETag from a prior response. Matching → 304 with no body.
      responses:
        '200':
          description: Recipient record
          headers:
            X-RateLimit-Limit: &ref_0
              $ref: '#/components/headers/X-RateLimit-Limit'
            X-RateLimit-Remaining: &ref_1
              $ref: '#/components/headers/X-RateLimit-Remaining'
            X-RateLimit-Reset: &ref_2
              $ref: '#/components/headers/X-RateLimit-Reset'
            X-Request-Id: &ref_3
              $ref: '#/components/headers/X-Request-Id'
            ETag:
              schema:
                type: string
              description: Strong ETag for `If-None-Match` revalidation.
            Cache-Control:
              schema:
                type: string
              description: '`private, max-age=10, must-revalidate` — only the partner caches; revalidate within 10s.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecipientRecord'
        '304':
          description: Not modified — ETag matched If-None-Match. No body.
          headers: &ref_4
            X-RateLimit-Limit: *ref_0
            X-RateLimit-Remaining: *ref_1
            X-RateLimit-Reset: *ref_2
            X-Request-Id: *ref_3
        '401':
          description: Missing or invalid bearer token
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '403':
          description: 'Token lacks tokens:read scope'
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '404':
          description: Token not found
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '429':
          description: Rate limit exceeded
          headers: &ref_5
            X-RateLimit-Limit: *ref_0
            X-RateLimit-Remaining: *ref_1
            X-RateLimit-Reset: *ref_2
            X-Request-Id: *ref_3
            Retry-After:
              $ref: '#/components/headers/Retry-After'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
  /v1/partners/stitched/tokens:
    get:
      summary: List recipient records since a timestamp
      description: 'Paginated list of Tendral recipient records updated since `since`. Composite cursor pagination on `(updated_at, id)` for stable ordering across rows that share a timestamp.'
      operationId: listPartnerStitchedTokens
      tags:
        - Partners
      parameters:
        - name: since
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque cursor returned by the previous page.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: 'expand[]'
          in: query
          schema:
            type: array
            items:
              type: string
              enum:
                - campaign
                - program
          style: form
          explode: true
      responses:
        '200':
          description: Page of recipient records
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListResponseRecipient'
        '400':
          description: Validation error
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '401':
          description: Missing or invalid bearer token
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '403':
          description: 'Token lacks tokens:read scope'
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '429':
          description: Rate limit exceeded
          headers: *ref_5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
  /v1/partners/stitched/events:
    get:
      summary: List Tendral-emitted outreach events since a timestamp
      description: |-
        Paginated list of `outreach.email_sent` and `outreach.email_bounced` events for traffic campaigns under Stitched-bound programs. Each row is wrapped in the spec §4 envelope so consumers can dedupe by `event_id` exactly as with webhooks.

        Filter by event type with `?type=outreach.email_sent` (single) or repeated `?types[]=outreach.email_sent&types[]=outreach.email_bounced` (multi).
      operationId: listPartnerStitchedEvents
      tags:
        - Partners
      parameters:
        - name: since
          in: query
          required: true
          schema:
            type: string
            format: date-time
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: type
          in: query
          schema:
            type: string
            enum:
              - outreach.email_sent
              - outreach.email_bounced
          description: Single event type filter.
        - name: 'types[]'
          in: query
          schema:
            type: array
            items:
              type: string
              enum:
                - outreach.email_sent
                - outreach.email_bounced
          style: form
          explode: true
          description: Multi event type filter.
        - name: 'expand[]'
          in: query
          schema:
            type: array
            items:
              type: string
              enum:
                - campaign
                - program
                - recipient
          style: form
          explode: true
      responses:
        '200':
          description: Page of envelopes
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListResponseEvent'
        '400':
          description: Validation error
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '401':
          description: Missing or invalid bearer token
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '403':
          description: 'Token lacks events:read scope'
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '429':
          description: Rate limit exceeded
          headers: *ref_5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
  '/v1/partners/stitched/campaigns/{id}/recipients':
    get:
      summary: List recipients for a Tendral campaign
      description: Paginated list of recipient records for the given campaign. `id` must be the prefixed `cmp_<hex>` form.
      operationId: listPartnerStitchedCampaignRecipients
      tags:
        - Partners
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Tendral campaign id (`cmp_<hex>`).
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: 'expand[]'
          in: query
          schema:
            type: array
            items:
              type: string
              enum:
                - campaign
                - program
          style: form
          explode: true
      responses:
        '200':
          description: Page of recipient records
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListResponseRecipient'
        '400':
          description: Invalid campaign id
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '401':
          description: Missing or invalid bearer token
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '403':
          description: 'Token lacks recipients:read scope'
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '429':
          description: Rate limit exceeded
          headers: *ref_5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
  /v1/partners/stitched/digest:
    get:
      summary: Aggregated digest of Stitched-bound program activity for a UTC date
      description: 'Returns one record per Stitched-bound program under the caller''s organization for the requested UTC date: qualification funnel (today + cumulative), outreach throughput (today), and quota status. Backs the daily Slack digest delivered to Stitched Health.'
      operationId: getPartnerStitchedDigest
      tags:
        - Partners
      parameters:
        - name: date
          in: query
          required: true
          schema:
            type: string
            format: date
          description: UTC calendar date in YYYY-MM-DD form.
      responses:
        '200':
          description: Aggregated digest
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StitchedDigest'
        '400':
          description: Validation error
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '401':
          description: Missing or invalid bearer token
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '403':
          description: 'Token lacks digest:read scope'
          headers: *ref_4
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
        '429':
          description: Rate limit exceeded
          headers: *ref_5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerErrorResponse'
webhooks:
  outreach.recipients_published:
    post:
      summary: Pre-arrival batch of recipients (Tendral → consumer)
      description: 'Fired before a campaign goes live. Carries the full set of recipients (or a chunk of them) that Tendral plans to email, so the consumer can seat token → NPI mappings ahead of time. **No bearer auth — verify the `X-Signature` header.** See [/docs/webhooks](https://developer.tendralhealth.com/docs/webhooks) for HMAC verification samples.'
      operationId: webhookOutreachRecipientsPublished
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed00dfeed
              event_type: outreach.recipients_published
              event_version: 1
              occurred_at: '2026-04-26T18:00:00Z'
              emitted_at: '2026-04-26T18:00:01Z'
              source: tendral
              data:
                tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
                tendral_program_id: pgm_018f5a2e7c127c8da123cafed00dffff
                stitched_program_slug: alz-tx-decisions
                scheduled_send_window_start: '2026-04-27T13:00:00Z'
                scheduled_send_window_end: '2026-04-28T13:00:00Z'
                recipients:
                  - token: Ab3kQ9
                    npi: '1234567890'
                    email: jdoe@example.com
                    tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
      responses:
        '200':
          description: Consumer acknowledges receipt. Body ignored.
        4XX:
          description: Permanent failure — Tendral will not retry.
        5XX:
          description: Transient failure — Tendral retries on the schedule documented at /docs/webhooks.
  outreach.email_sent:
    post:
      summary: Per-recipient email-sent event (Tendral → consumer)
      description: Fired when an outreach email is delivered to the recipient mailbox. **No bearer auth — verify `X-Signature`.**
      operationId: webhookOutreachEmailSent
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed00dfeed
              event_type: outreach.email_sent
              event_version: 1
              occurred_at: '2026-04-27T13:02:00Z'
              emitted_at: '2026-04-27T13:02:01Z'
              source: tendral
              data:
                tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
                tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
                token: Ab3kQ9
      responses:
        '200':
          description: Consumer acknowledges receipt.
        4XX:
          description: Permanent failure — no retry.
        5XX:
          description: Transient failure — Tendral retries.
  outreach.email_bounced:
    post:
      summary: Per-recipient email-bounced event (Tendral → consumer)
      description: Fired when an outreach email hard-bounces. **No bearer auth — verify `X-Signature`.**
      operationId: webhookOutreachEmailBounced
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed00dfeed
              event_type: outreach.email_bounced
              event_version: 1
              occurred_at: '2026-04-27T13:02:30Z'
              emitted_at: '2026-04-27T13:02:31Z'
              source: tendral
              data:
                tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
                tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
                token: Ab3kQ9
                bounce_reason: mailbox_full
      responses:
        '200':
          description: Consumer acknowledges receipt.
        4XX:
          description: Permanent failure — no retry.
        5XX:
          description: Transient failure — Tendral retries.
  clinician.account_created:
    post:
      summary: 'Action event: clinician created an account on the partner platform'
      description: Fired by Stitched (or future partners) when a clinician creates an account. Tendral consumes this to grow the suppression set and (when `attribution = tendral_token`) update the recipient record. **No bearer auth — verify `X-Signature`.** Documented from Tendral's perspective as the inbound contract Stitched delivers.
      operationId: webhookClinicianAccountCreated
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed01dfeed
              event_type: clinician.account_created
              event_version: 1
              occurred_at: '2026-04-27T15:30:00Z'
              emitted_at: '2026-04-27T15:30:01Z'
              source: stitched
              data:
                npi: '1234567890'
                email: jdoe@example.com
                stitched_program_slug: alz-tx-decisions
                attribution: tendral_token
                token: Ab3kQ9
      responses:
        '200':
          description: Tendral acknowledges receipt.
        4XX:
          description: Permanent failure — Stitched will not retry.
        5XX:
          description: Transient — Stitched retries.
  clinician.learner_qualified:
    post:
      summary: 'Action event: clinician crossed the learner threshold on a program'
      description: Fired by Stitched when a clinician hits the learner-quota threshold. Tendral consumes this to increment local quota counters and withdraw matching active leads. **No bearer auth — verify `X-Signature`.**
      operationId: webhookClinicianLearnerQualified
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed02dfeed
              event_type: clinician.learner_qualified
              event_version: 1
              occurred_at: '2026-04-28T10:00:00Z'
              emitted_at: '2026-04-28T10:00:01Z'
              source: stitched
              data:
                npi: '1234567890'
                stitched_program_slug: alz-tx-decisions
                token: Ab3kQ9
      responses:
        '200':
          description: Tendral acknowledges receipt.
        4XX:
          description: Permanent failure — no retry.
        5XX:
          description: Transient — Stitched retries.
  clinician.completer_qualified:
    post:
      summary: 'Action event: clinician completed a program'
      description: Fired by Stitched when a clinician completes a program. Same handler shape as `clinician.learner_qualified`. **No bearer auth — verify `X-Signature`.**
      operationId: webhookClinicianCompleterQualified
      tags:
        - Partners
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PartnerEventEnvelope'
            example:
              object: event
              livemode: true
              event_id: evt_018f5a2e7c127c8da123cafed03dfeed
              event_type: clinician.completer_qualified
              event_version: 1
              occurred_at: '2026-04-29T12:00:00Z'
              emitted_at: '2026-04-29T12:00:01Z'
              source: stitched
              data:
                npi: '1234567890'
                stitched_program_slug: alz-tx-decisions
                token: Ab3kQ9
      responses:
        '200':
          description: Tendral acknowledges receipt.
        4XX:
          description: Permanent failure — no retry.
        5XX:
          description: Transient — Stitched retries.
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: API key in the format `tk_live_xxxxxxxxxxxx`
  headers:
    X-RateLimit-Limit:
      description: Requests allowed per window for this API key (default 100/min for partner endpoints).
      schema:
        type: integer
        example: 100
    X-RateLimit-Remaining:
      description: Requests remaining in the current window.
      schema:
        type: integer
        example: 99
    X-RateLimit-Reset:
      description: Unix-seconds when the current window resets.
      schema:
        type: integer
        example: 1715040000
    X-Request-Id:
      description: Correlation id (`req_<hex>`); also surfaced in `error.request_id` on failures.
      schema:
        type: string
        example: req_018f5a2e7c127c8da123cafed00dfeed
    Retry-After:
      description: Seconds to wait before retrying. Present only on 429 responses.
      schema:
        type: integer
        example: 60
    Idempotent-Replayed:
      description: '`true` when the response is a replayed cached idempotency response. Absent on first execution.'
      schema:
        type: string
        enum:
          - 'true'
  schemas:
    SurveyInput:
      type: object
      required:
        - title
        - estimatedTime
        - incentive
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 200
          description: Survey title
        description:
          type: string
          description: Rich text introduction (shown after sign-in)
        briefDescription:
          type: string
          maxLength: 150
          description: Welcome screen subtitle
        sponsor:
          type: string
          description: Welcome screen sponsor
        background:
          type: string
          description: Study context shown to respondents
        directions:
          type: string
          description: '"What you''ll do" text'
        outcomes:
          type: string
          description: Expected outcomes text
        format:
          type: string
          enum:
            - standard
            - case-based
          default: standard
          description: 'Survey format. Standard uses flat questions[], case-based nests questions inside cases.'
        estimatedTime:
          $ref: '#/components/schemas/EstimatedTime'
        incentive:
          type: number
          minimum: 0
          description: Compensation in dollars
        questions:
          type: array
          items:
            $ref: '#/components/schemas/Question'
          description: Questions array (required for standard format)
        introQuestions:
          type: array
          items:
            $ref: '#/components/schemas/Question'
          description: 'Questions before cases (case-based only, optional)'
        cases:
          type: array
          items:
            $ref: '#/components/schemas/Case'
          description: Cases array (required for case-based format)
        closingQuestions:
          type: array
          items:
            $ref: '#/components/schemas/Question'
          description: 'Questions after cases (case-based only, optional)'
        targeting:
          $ref: '#/components/schemas/Targeting'
      example:
        title: Treatment decision-making in early Alzheimer's disease
        description: A 12-minute survey on how neurologists assess disease-modifying therapy candidates.
        briefDescription: 12 minutes · Sponsored by Stitched Health
        format: standard
        estimatedTime:
          min: 10
          max: 14
        incentive: 75
        questions:
          - type: single-choice
            text: How often do you order amyloid PET scans in your practice?
            options:
              - Never
              - Rarely
              - Sometimes
              - Often
              - Always
            required: true
          - type: scale
            text: Confidence in selecting disease-modifying therapy for an MCI patient.
            points: 5
            labels:
              start: Not confident
              end: Very confident
            required: true
        targeting:
          audiences:
            - name: US neurologists
              specialtyCodes:
                - 2084N0400X
              states:
                - CA
                - NY
                - TX
              quota: 50
    EstimatedTime:
      type: object
      required:
        - min
        - max
      properties:
        min:
          type: integer
          minimum: 1
          description: Minimum estimated minutes
        max:
          type: integer
          minimum: 1
          description: Maximum estimated minutes (must be >= min)
    Question:
      type: object
      required:
        - type
        - text
      description: A survey question. Additional fields depend on the type.
      properties:
        type:
          type: string
          enum:
            - text
            - single-choice
            - multiple-choice
            - scale
            - yes-no
            - number
            - maxdiff
            - compound
        text:
          type: string
          minLength: 1
          description: Question text
        description:
          type: string
          description: Help text shown below question
        required:
          type: boolean
          default: true
          description: Whether response is required
        placeholder:
          type: string
        voiceEnabled:
          type: boolean
          default: false
        validation:
          type: object
          properties:
            minLength:
              type: integer
            maxLength:
              type: integer
            min:
              type: number
            max:
              type: number
        options:
          type: array
          items:
            oneOf:
              - type: string
              - $ref: '#/components/schemas/OptionObject'
          description: Options for single-choice and multiple-choice (min 2)
        allowOther:
          type: boolean
          default: false
        maxSelections:
          type: integer
        randomizeOptions:
          type: boolean
          default: false
        points:
          type: integer
          minimum: 2
          maximum: 10
          default: 5
          description: Number of scale points
        labels:
          $ref: '#/components/schemas/ScaleLabels'
        visualization:
          type: string
          enum:
            - slider
            - likert
            - semantic
            - descriptive
            - frequency
            - comparative
            - numerical
            - graphic
            - multi-dimension
          default: likert
        showLabels:
          type: boolean
          default: true
        showValue:
          type: boolean
          default: true
        flipped:
          type: boolean
          default: false
        yesText:
          type: string
          default: 'Yes'
        noText:
          type: string
          default: 'No'
        items:
          type: array
          items:
            type: string
          minItems: 4
          maxItems: 20
          description: Items to compare (MaxDiff)
        config:
          type: object
          properties:
            itemsPerTask:
              type: integer
            totalTasks:
              type: integer
        contextHeader:
          type: string
        subQuestions:
          type: array
          items:
            $ref: '#/components/schemas/SubQuestion'
    OptionObject:
      type: object
      required:
        - text
      properties:
        text:
          type: string
          minLength: 1
        value:
          type: string
    ScaleLabels:
      type: object
      properties:
        start:
          type: string
        end:
          type: string
        points:
          type: array
          items:
            type: string
    SubQuestion:
      type: object
      required:
        - type
        - label
      properties:
        type:
          type: string
          enum:
            - scale
            - single-choice
            - multiple-choice
            - yes-no
        label:
          type: string
          minLength: 1
        required:
          type: boolean
          default: true
        options:
          type: array
          items:
            oneOf:
              - type: string
              - $ref: '#/components/schemas/OptionObject'
        points:
          type: integer
          minimum: 2
          maximum: 10
          default: 5
        labels:
          type: object
          properties:
            start:
              type: string
            end:
              type: string
    Case:
      type: object
      required:
        - title
        - content
        - questions
      properties:
        ref:
          type: string
          description: Unique reference for this case
        title:
          type: string
          minLength: 1
        content:
          type: string
          minLength: 1
          description: HTML case content
        prompt:
          type: string
          description: Directions shown above the case
        publication:
          $ref: '#/components/schemas/Publication'
        keyPoints:
          type: array
          items:
            type: string
        summary:
          type: string
        headerLabel:
          type: string
        reflectionLabel:
          type: string
        introLabel:
          type: string
        questions:
          type: array
          items:
            $ref: '#/components/schemas/Question'
          minItems: 1
    Publication:
      type: object
      required:
        - type
      properties:
        type:
          type: string
          enum:
            - pdf
            - link
            - embedded
            - text
        url:
          type: string
          format: uri
        metadata:
          type: object
          properties:
            journal:
              type: string
            authors:
              type: string
            year:
              type: integer
            title:
              type: string
            doi:
              type: string
    Targeting:
      type: object
      required:
        - audiences
      properties:
        audiences:
          type: array
          items:
            $ref: '#/components/schemas/AudienceInput'
          minItems: 1
    AudienceInput:
      type: object
      properties:
        name:
          type: string
        roles:
          type: array
          items:
            type: string
          description: NUCC groupings
        specialtyCodes:
          type: array
          items:
            type: string
          description: NUCC specialty codes
        states:
          type: array
          items:
            type: string
          description: US state codes
        zipCodes:
          type: array
          items:
            type: string
        compensation:
          type: number
          minimum: 0
          description: Override compensation in dollars
        quota:
          type: integer
          minimum: 1
          description: Target response count
    SurveyCreated:
      type: object
      required:
        - id
        - title
        - status
        - questionCount
        - createdAt
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        status:
          type: string
          example: draft
        questionCount:
          type: integer
        createdAt:
          type: string
          format: date-time
      example:
        id: 018f5a2e-7c12-7c8d-a123-cafed00dfeed
        title: Treatment decision-making in early Alzheimer's disease
        status: draft
        questionCount: 12
        createdAt: '2026-05-06T13:02:30Z'
    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: Machine-readable error code
              example: VALIDATION_ERROR
            message:
              type: string
              description: Human-readable error description
              example: Survey validation failed
            errors:
              type: array
              items:
                $ref: '#/components/schemas/ImportError'
              description: Field-level validation errors (if applicable)
    ImportError:
      type: object
      required:
        - code
        - path
        - message
      properties:
        code:
          type: string
          example: MISSING_OPTIONS
        path:
          type: string
          example: 'questions[2].options'
        message:
          type: string
          example: single-choice questions require an "options" array
    ErrorType:
      type: string
      description: |
        Coarse machine-routable category (Stripe-style). Branch retry/UX logic on `type` rather than `code`.

        | Type | Typical HTTP | Retryable? | What it means |
        | --- | --- | --- | --- |
        | `authentication_error` | 401 | No | The request is not authenticated (missing/expired/revoked bearer). |
        | `invalid_request_error` | 400, 404 | No | The request is malformed or refers to something that doesn't exist. Fix the request. |
        | `permission_error` | 403 | No | Authentication succeeded but the token lacks the required scope. Re-issue with appropriate scopes. |
        | `rate_limit_error` | 429 | Yes — after `Retry-After` | API key exceeded its rate limit. Back off and retry. |
        | `idempotency_error` | 409 | No | The provided `Idempotency-Key` was reused with a different request body. Use a fresh key. |
        | `api_error` | 5xx | Yes — exponential backoff | Unexpected server error. |
      enum:
        - authentication_error
        - invalid_request_error
        - permission_error
        - rate_limit_error
        - idempotency_error
        - api_error
    ErrorCode:
      type: string
      description: |
        Specific reason code. Stable enum; new codes are additive. Full reference at [/docs/errors](https://developer.tendralhealth.com/docs/errors).

        | Code | Type | HTTP | Retryable? | What it means |
        | --- | --- | --- | --- | --- |
        | `UNAUTHORIZED` | `authentication_error` | 401 | No | Bearer token missing, malformed, expired, or revoked. |
        | `INSUFFICIENT_SCOPE` | `permission_error` | 403 | No | Token does not include the required scope (e.g. `tokens:read`). |
        | `INVALID_TOKEN` | `invalid_request_error` | 400 | No | The 6-character URL token is malformed (must match `[0-9A-Za-z]{6}`). |
        | `INVALID_CAMPAIGN_ID` | `invalid_request_error` | 400 | No | Campaign id must be the prefixed `cmp_<hex>` form. |
        | `TOKEN_NOT_FOUND` | `invalid_request_error` | 404 | No | No record exists for the provided token. |
        | `MISSING_PARAMETER` | `invalid_request_error` | 400 | No | A required query/body parameter is missing. See `error.details.parameter`. |
        | `INVALID_PARAMETER` | `invalid_request_error` | 400 | No | A parameter is present but malformed (wrong type, format, or value). |
        | `INVALID_EXPAND` | `invalid_request_error` | 400 | No | A value in `expand[]` is not allowed for this endpoint. |
        | `INVALID_TYPE_FILTER` | `invalid_request_error` | 400 | No | A value in `type`/`types[]` is not in the allowed enum. |
        | `IDEMPOTENCY_KEY_REUSED` | `idempotency_error` | 409 | No | Same `Idempotency-Key` + different request body. Retry with a fresh key. |
        | `RATE_LIMITED` | `rate_limit_error` | 429 | Yes (after `Retry-After`) | API key has exceeded its rate limit. |
        | `INTERNAL_ERROR` | `api_error` | 500 | Yes (exponential backoff) | Unexpected server error. |
      enum:
        - UNAUTHORIZED
        - INSUFFICIENT_SCOPE
        - INVALID_TOKEN
        - INVALID_CAMPAIGN_ID
        - TOKEN_NOT_FOUND
        - MISSING_PARAMETER
        - INVALID_PARAMETER
        - INVALID_EXPAND
        - INVALID_TYPE_FILTER
        - IDEMPOTENCY_KEY_REUSED
        - RATE_LIMITED
        - INTERNAL_ERROR
    PartnerErrorResponse:
      type: object
      required:
        - error
      description: Error envelope for Partners-tagged endpoints. Spec §5.4.
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
            - request_id
            - doc_url
          properties:
            type:
              $ref: '#/components/schemas/ErrorType'
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              example: No record exists for the provided token.
            details:
              type: object
              additionalProperties: true
              nullable: true
            doc_url:
              type: string
              format: uri
              example: 'https://developer.tendralhealth.com/docs/errors#TOKEN_NOT_FOUND'
            request_id:
              type: string
              example: req_018f5a2e7c127c8da123cafed00dfeed
          example:
            type: invalid_request_error
            code: TOKEN_NOT_FOUND
            message: No record exists for the provided token.
            details:
              token: Ab3kQ9
            doc_url: 'https://developer.tendralhealth.com/docs/errors#TOKEN_NOT_FOUND'
            request_id: req_018f5a2e7c127c8da123cafed00dfeed
    RecipientRecord:
      type: object
      required:
        - object
        - livemode
        - token
        - npi
        - email
        - tendral_recipient_id
        - tendral_campaign_id
        - tendral_program_id
        - created_at
      properties:
        object:
          type: string
          enum:
            - recipient_record
        livemode:
          type: boolean
          description: True only on the production deployment.
        token:
          type: string
          pattern: '^[0-9A-Za-z]{6}$'
          example: Ab3kQ9
        npi:
          type: string
          pattern: '^\d{10}$'
          example: '1234567890'
        email:
          type: string
          format: email
        tendral_recipient_id:
          type: string
          pattern: '^rcp_[0-9a-f]+$'
        tendral_campaign_id:
          type: string
          pattern: '^cmp_[0-9a-f]+$'
        tendral_program_id:
          type: string
          pattern: '^pgm_[0-9a-f]+$'
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
          nullable: true
        sent_at:
          type: string
          format: date-time
          nullable: true
        bounced_at:
          type: string
          format: date-time
          nullable: true
        last_event_at:
          type: string
          format: date-time
          nullable: true
        campaign:
          $ref: '#/components/schemas/CampaignSummary'
          nullable: true
          description: 'Inlined when expand[]=campaign.'
        program:
          $ref: '#/components/schemas/ProgramSummary'
          nullable: true
          description: 'Inlined when expand[]=program.'
      example:
        object: recipient_record
        livemode: false
        token: Ab3kQ9
        npi: '1234567890'
        email: jdoe@example.com
        tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
        tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
        tendral_program_id: pgm_018f5a2e7c127c8da123cafed00dffff
        created_at: '2026-04-26T18:00:00.000Z'
        updated_at: '2026-04-27T15:30:00.000Z'
        sent_at: '2026-04-27T13:02:00.000Z'
        bounced_at: null
        last_event_at: '2026-04-27T15:30:00.000Z'
    PartnerEventEnvelope:
      type: object
      required:
        - object
        - livemode
        - event_id
        - event_type
        - event_version
        - occurred_at
        - emitted_at
        - source
        - data
      description: Spec §4 envelope wrapping `outreach.*` rows.
      properties:
        object:
          type: string
          enum:
            - event
        livemode:
          type: boolean
        event_id:
          type: string
          pattern: '^evt_[0-9a-f]+$'
        event_type:
          type: string
          example: outreach.email_sent
        event_version:
          type: integer
          minimum: 1
          example: 1
        occurred_at:
          type: string
          format: date-time
        emitted_at:
          type: string
          format: date-time
        source:
          type: string
          enum:
            - tendral
            - stitched
        data:
          type: object
          additionalProperties: true
        metadata:
          type: object
          additionalProperties: true
          nullable: true
        campaign:
          $ref: '#/components/schemas/CampaignSummary'
          nullable: true
        program:
          $ref: '#/components/schemas/ProgramSummary'
          nullable: true
        recipient:
          $ref: '#/components/schemas/RecipientRecord'
          nullable: true
      example:
        object: event
        livemode: false
        event_id: evt_018f5a2e7c127c8da123cafed00dfeed
        event_type: outreach.email_sent
        event_version: 1
        occurred_at: '2026-04-27T13:02:00Z'
        emitted_at: '2026-04-27T13:02:01Z'
        source: tendral
        data:
          tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
          tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
          token: Ab3kQ9
    CampaignSummary:
      type: object
      required:
        - object
        - livemode
        - id
        - name
        - program_id
        - status
      properties:
        object:
          type: string
          enum:
            - campaign
        livemode:
          type: boolean
        id:
          type: string
          pattern: '^cmp_[0-9a-f]+$'
        name:
          type: string
        program_id:
          type: string
          pattern: '^pgm_[0-9a-f]+$'
        status:
          type: string
          example: active
    ProgramSummary:
      type: object
      required:
        - object
        - livemode
        - id
        - slug
        - name
        - status
      properties:
        object:
          type: string
          enum:
            - program
        livemode:
          type: boolean
        id:
          type: string
          pattern: '^pgm_[0-9a-f]+$'
        slug:
          type: string
          example: alz-tx-decisions
        name:
          type: string
        status:
          type: string
    ListResponseRecipient:
      type: object
      required:
        - object
        - livemode
        - data
        - has_more
        - next_cursor
        - url
      properties:
        object:
          type: string
          enum:
            - list
        livemode:
          type: boolean
        data:
          type: array
          items:
            $ref: '#/components/schemas/RecipientRecord'
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor; null when no more pages.
        url:
          type: string
          description: Echoed request path + query for client-side correlation.
      example:
        object: list
        livemode: false
        data:
          - object: recipient_record
            livemode: false
            token: Ab3kQ9
            npi: '1234567890'
            email: jdoe@example.com
            tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
            tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
            tendral_program_id: pgm_018f5a2e7c127c8da123cafed00dffff
            created_at: '2026-04-26T18:00:00.000Z'
            updated_at: '2026-04-27T15:30:00.000Z'
            sent_at: '2026-04-27T13:02:00.000Z'
            bounced_at: null
            last_event_at: '2026-04-27T15:30:00.000Z'
        has_more: false
        next_cursor: null
        url: /v1/partners/stitched/tokens?since=2026-04-01T00%3A00%3A00Z&limit=100
    ListResponseEvent:
      type: object
      required:
        - object
        - livemode
        - data
        - has_more
        - next_cursor
        - url
      properties:
        object:
          type: string
          enum:
            - list
        livemode:
          type: boolean
        data:
          type: array
          items:
            $ref: '#/components/schemas/PartnerEventEnvelope'
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
        url:
          type: string
      example:
        object: list
        livemode: false
        data:
          - object: event
            livemode: false
            event_id: evt_018f5a2e7c127c8da123cafed00dfeed
            event_type: outreach.email_sent
            event_version: 1
            occurred_at: '2026-04-27T13:02:00Z'
            emitted_at: '2026-04-27T13:02:01Z'
            source: tendral
            data:
              tendral_recipient_id: rcp_018f5a2e7c127c8da123cafed00dfeed
              tendral_campaign_id: cmp_018f5a2e7c127c8da123cafed00dfeef
              token: Ab3kQ9
        has_more: false
        next_cursor: null
        url: /v1/partners/stitched/events?since=2026-04-01T00%3A00%3A00Z&limit=100
    StitchedDigestFunnelCounts:
      type: object
      required:
        - account_created
        - learner_qualified
        - completer_qualified
      properties:
        account_created:
          type: integer
          minimum: 0
        learner_qualified:
          type: integer
          minimum: 0
        completer_qualified:
          type: integer
          minimum: 0
    StitchedDigestProgram:
      type: object
      required:
        - tendral_program_id
        - program_name
        - stitched_program_slug
        - program_status
        - funnel
        - outreach
        - quota
      properties:
        tendral_program_id:
          type: string
          description: Tendral program id (`pgm_<hex>`).
        program_name:
          type: string
        stitched_program_slug:
          type: string
        program_status:
          type: string
          enum:
            - draft
            - active
            - completed
            - archived
        funnel:
          type: object
          required:
            - today
            - all_time
          properties:
            today:
              $ref: '#/components/schemas/StitchedDigestFunnelCounts'
            all_time:
              $ref: '#/components/schemas/StitchedDigestFunnelCounts'
        outreach:
          type: object
          required:
            - today
          properties:
            today:
              type: object
              required:
                - email_sent
                - email_opened
                - email_clicked
                - email_bounced
              properties:
                email_sent:
                  type: integer
                  minimum: 0
                email_opened:
                  type: integer
                  minimum: 0
                  description: Bot-suspected opens excluded.
                email_clicked:
                  type: integer
                  minimum: 0
                email_bounced:
                  type: integer
                  minimum: 0
        quota:
          type: object
          required:
            - learner
            - completer
            - paused_at
          properties:
            learner:
              type: object
              required:
                - count
                - quota
              properties:
                count:
                  type: integer
                  minimum: 0
                quota:
                  type: integer
                  minimum: 0
                  nullable: true
                  description: Null when no cap is set.
            completer:
              type: object
              required:
                - count
                - quota
              properties:
                count:
                  type: integer
                  minimum: 0
                quota:
                  type: integer
                  minimum: 0
                  nullable: true
                  description: Null when no cap is set.
            paused_at:
              type: string
              format: date-time
              nullable: true
    StitchedDigest:
      type: object
      required:
        - object
        - livemode
        - date
        - generated_at
        - programs
      properties:
        object:
          type: string
          enum:
            - digest
        livemode:
          type: boolean
        date:
          type: string
          format: date
        generated_at:
          type: string
          format: date-time
        programs:
          type: array
          items:
            $ref: '#/components/schemas/StitchedDigestProgram'
      example:
        object: digest
        livemode: false
        date: '2026-05-12'
        generated_at: '2026-05-12T13:00:01.234Z'
        programs:
          - tendral_program_id: pgm_018f5a2e7c127c8da123cafed00dfeed
            program_name: Alzheimer's CME Traffic
            stitched_program_slug: stitched-alz-traffic
            program_status: active
            funnel:
              today:
                account_created: 12
                learner_qualified: 8
                completer_qualified: 3
              all_time:
                account_created: 145
                learner_qualified: 92
                completer_qualified: 41
            outreach:
              today:
                email_sent: 230
                email_opened: 87
                email_clicked: 0
                email_bounced: 4
            quota:
              learner:
                count: 92
                quota: 200
              completer:
                count: 41
                quota: 100
              paused_at: null
tags:
  - name: System
    description: Health and status endpoints.
    externalDocs:
      url: 'https://developer.tendralhealth.com/docs/quickstart'
      description: Quickstart
  - name: Surveys
    description: Survey creation and validation. Endpoints in this group are not yet shipped — schema is published for partner planning.
    externalDocs:
      url: 'https://developer.tendralhealth.com/docs/quickstart'
      description: Quickstart
  - name: Uploads
    description: File upload utilities. Endpoints in this group are not yet shipped — schema is published for partner planning.
    externalDocs:
      url: 'https://developer.tendralhealth.com/docs/quickstart'
      description: Quickstart
  - name: Partners
    description: 'Partner-organization integration endpoints. Bearer-token authenticated, rate-limited per key (default 100/min), Idempotency-Key on POSTs, Stripe-style type-prefixed IDs. Stitched Health is the first consumer — see [the protocol contract](https://github.com/epaynter/tendral-stitched-integration). Webhook events fired by Tendral are documented at [/docs/webhooks](https://developer.tendralhealth.com/docs/webhooks); error codes at [/docs/errors](https://developer.tendralhealth.com/docs/errors).'
    externalDocs:
      url: 'https://developer.tendralhealth.com/docs/quickstart'
      description: Quickstart for partners
