openapi: 3.1.0
info:
  title: IWE Partner API
  version: 0.1.1
  summary: Embed the Intelligent Wellbeing Engine in your product.
  description: |
    Headless API for licensees embedding the IWE in their own applications.
    Multi-adopter: every licensee gets an isolated deployment (own project,
    keys, region).

    v0.1.1 reconciles the spec to the built reference engine (schema `iwe`):
      - metric BENEFITS_REACH -> PARTICIPATION (placeholder; not computed yet).
      - band examples use the vendored OWHS codelists (age-band, tenure-band,
        work-pattern), not free-text.
      - construct codes may be core (construct-domain@0.1.0) OR profile
        extension codes (e.g. `x-iwe:work-ability`), emitted verbatim.
      - documents the OAuth2 token + JWKS endpoints, the embed-token shape, the
        embed check-in API, and the webhook signature as built.

    ## Invariants the API enforces (not merely promises)
    - No endpoint returns an individual's answers to anyone but that
      respondent's own embed-token-gated check-in session.
    - Aggregates below n=5 are suppressed objects with a reason and NO value —
      a DB CHECK, not a filter. Severe-distress aggregates are floored at n=10.
    - Reminder eligibility encodes the one-reminder-per-cycle rule.
    - Safeguarding/crisis signposting renders in the IWE-hosted embed surface
      and is not configurable via this API.
servers:
  - url: https://{licensee}.api.iwe.alltoogether.com/v1
    variables: { licensee: { default: sandbox } }
security:
  - oauth2ClientCredentials: []

tags:
  - name: auth
  - name: respondents
  - name: checkin
  - name: embed
  - name: cycles
  - name: aggregates
  - name: webhooks
  - name: usage

paths:
  /oauth/token:
    post:
      tags: [auth]
      operationId: issueToken
      security: []
      summary: OAuth2 client-credentials token
      description: Exchange client_id + client_secret for a 1h RS256 access token carrying the client's scopes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type: { type: string, enum: [client_credentials] }
                client_id: { type: string }
                client_secret: { type: string }
                scope: { type: string, description: Space-delimited subset of the client's granted scopes. }
      responses:
        '200':
          description: Token.
          content:
            application/json:
              schema:
                type: object
                properties:
                  access_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, example: 3600 }
                  scope: { type: string }
        '401': { description: invalid_client }

  /.well-known/jwks.json:
    get:
      tags: [auth]
      operationId: jwks
      security: []
      summary: Public signing keys (RS256) for offline token verification
      responses:
        '200': { description: JWK Set. }

  /respondents:sync:
    post:
      tags: [respondents]
      operationId: syncRespondents
      summary: Create/update pseudonymous respondents (bulk, <=500)
      description: Pseudonymous by design — send partner_user_id, never names. bands are validated against the OWHS codelists (invalid values are rejected per-row).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [respondents]
              properties:
                respondents:
                  type: array
                  maxItems: 500
                  items: { $ref: '#/components/schemas/RespondentUpsert' }
      responses:
        '200':
          description: Per-row results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  upserted: { type: integer }
                  errors: { type: array, items: { $ref: '#/components/schemas/RowError' } }

  /respondents/{respondentId}:
    delete:
      tags: [respondents]
      operationId: eraseRespondent
      summary: GDPR erasure (cascade + receipt)
      description: Deletes identity link, answer ledger, response rows and deep-dive rows. Closed-cycle aggregates are not recomputed (policy). Emits respondent.erased with a receipt id.
      parameters: [ { $ref: '#/components/parameters/respondentId' } ]
      responses:
        '202': { description: Erasure accepted; receipt via webhook. }
        '404': { $ref: '#/components/responses/NotFound' }

  /respondents/{respondentId}/checkin:
    get:
      tags: [checkin]
      operationId: getCheckinState
      summary: Current check-in state (server-to-server)
      parameters: [ { $ref: '#/components/parameters/respondentId' } ]
      responses:
        '200': { description: Check-in state., content: { application/json: { schema: { $ref: '#/components/schemas/CheckinState' } } } }

  /respondents/{respondentId}/answers:
    post:
      tags: [checkin]
      operationId: submitAnswer
      summary: Submit one answer (server-to-server)
      parameters: [ { $ref: '#/components/parameters/respondentId' } ]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/AnswerSubmit' } } }
      responses:
        '200': { description: Result., content: { application/json: { schema: { $ref: '#/components/schemas/AnswerResult' } } } }
        '409': { description: Cycle not open / item not in this respondent's set. }
        '422': { description: Value outside the item's native scale. }

  /respondents/{respondentId}/reminder-eligibility:
    get:
      tags: [checkin]
      operationId: getReminderEligibility
      summary: May this respondent be nudged this cycle?
      parameters: [ { $ref: '#/components/parameters/respondentId' } ]
      responses:
        '200':
          description: Eligibility.
          content:
            application/json:
              schema:
                type: object
                properties:
                  eligible: { type: boolean }
                  kind: { type: string, enum: [invite, reminder, none] }
                  reason: { type: string, example: already_reminded }

  /respondents/{respondentId}/nudges:record:
    post:
      tags: [checkin]
      operationId: recordNudge
      summary: Record a host-delivered invite/reminder
      parameters: [ { $ref: '#/components/parameters/respondentId' } ]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, enum: [invite, reminder] }
                channel: { type: string, example: host_push }
      responses:
        '200': { description: Recorded. }
        '409': { description: Already recorded / no open cycle. }

  /embed/tokens:
    post:
      tags: [embed]
      operationId: mintEmbedToken
      summary: Mint a short-lived embed token (<=10 min, single respondent)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [respondent_ref]
              properties:
                respondent_ref: { $ref: '#/components/schemas/RespondentRef' }
                locale: { type: string, default: en-GB }
      responses:
        '200':
          description: Token (as built).
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  embed_url: { type: string, format: uri }
                  expires_at: { type: string, format: date-time }

  /embed/checkin:
    get:
      tags: [embed]
      operationId: embedCheckinState
      security: [ { embedToken: [] } ]
      summary: Check-in state for the embed page (embed-token gated)
      responses:
        '200': { description: Check-in state., content: { application/json: { schema: { $ref: '#/components/schemas/CheckinState' } } } }

  /embed/answers:
    post:
      tags: [embed]
      operationId: embedSubmitAnswer
      security: [ { embedToken: [] } ]
      summary: Submit one answer from the embed page (embed-token gated)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: '#/components/schemas/AnswerSubmit' } } }
      responses:
        '200': { description: Result., content: { application/json: { schema: { $ref: '#/components/schemas/AnswerResult' } } } }

  /cycles:
    get:
      tags: [cycles]
      operationId: listCycles
      summary: List cycles
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, closed, all], default: all } }
      responses:
        '200': { description: Cycles., content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Cycle' } } } } }

  /cycles:launch:
    post:
      tags: [cycles]
      operationId: launchFirstCycle
      summary: Launch the first cycle for a unit (refuses roster < 5)
      requestBody:
        content:
          application/json:
            schema: { type: object, properties: { unit_id: { type: string } } }
      responses:
        '200': { description: '{status: launched|already_launched|roster_too_small}. Emits cycle.opened when launched.' }

  /aggregates:
    get:
      tags: [aggregates]
      operationId: listAggregates
      summary: Read aggregates (suppression-aware; no bypass)
      parameters:
        - { name: cycle_id, in: query, schema: { type: string, format: uuid } }
        - { name: unit_id, in: query, schema: { type: string } }
        - { name: metric, in: query, schema: { type: string, enum: [WELLBEING_INDEX, DOMAIN_MEAN, PARTICIPATION] } }
      responses:
        '200': { description: Aggregates., content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Aggregate' } } } } }

  /aggregates/export:
    get:
      tags: [aggregates]
      operationId: exportAggregatesOWHS
      summary: OWHS AggregateReport export (only sanctioned re-export format)
      parameters:
        - { name: cycle_id, in: query, schema: { type: string, format: uuid } }
        - { name: format, in: query, schema: { type: string, enum: [owhs+json], default: owhs+json } }
      responses:
        '200':
          description: OWHS export.
          content:
            application/vnd.owhs.aggregate-report+json:
              schema: { $ref: '#/components/schemas/OWHSAggregateReport' }

  /webhooks:
    post:
      tags: [webhooks]
      operationId: createWebhook
      summary: Subscribe to events (HMAC-signed, show-once secret)
      description: |
        Deliveries are signed: `IWE-Signature: t=<unix>,v1=<hex hmac of "t.body">`.
        Receivers MUST reject when |now - t| > 300s (5-minute replay window).
        The signing secret is returned ONCE at creation.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string, enum: [cycle.opened, cycle.closed, aggregates.ready, respondent.erased, deliverability.flag] }
      responses:
        '201':
          description: Created (returns signing_secret once).
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, signing_secret: { type: string } } }

  /usage:
    get:
      tags: [usage]
      operationId: getUsage
      summary: Metering — enrolled respondents per month
      parameters:
        - { name: month, in: query, schema: { type: string, example: '2026-07' } }
      responses:
        '200': { description: Usage., content: { application/json: { schema: { $ref: '#/components/schemas/Usage' } } } }

components:
  securitySchemes:
    oauth2ClientCredentials:
      type: oauth2
      flows:
        clientCredentials:
          tokenUrl: https://auth.iwe.alltoogether.com/oauth/token
          scopes:
            respondents:write: Roster sync + erasure
            checkin:write: Submit answers / mint embed tokens
            aggregates:read: Read aggregates + OWHS export + webhooks
            usage:read: Metering
    embedToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Short-lived (<=10 min) single-respondent embed token from /embed/tokens.
  parameters:
    respondentId:
      name: respondentId
      in: path
      required: true
      description: IWE respondent id OR `ext:{partner_user_id}`.
      schema: { type: string }
  responses:
    NotFound:
      description: Unknown respondent (never distinguishes "erased" from "never existed").
  schemas:
    RespondentRef:
      type: object
      required: [partner_user_id]
      properties: { partner_user_id: { type: string } }
    RespondentUpsert:
      allOf:
        - $ref: '#/components/schemas/RespondentRef'
        - type: object
          properties:
            unit_id: { type: string }
            email: { type: string, format: email, description: OPTIONAL — only for IWE-operated email delivery. }
            bands:
              type: object
              description: Banded demographics only; values must be in the OWHS codelists.
              properties:
                age_band: { type: string, enum: ['16-24','25-34','35-49','50-64','65+'], example: '35-49' }
                tenure_band: { type: string, enum: ['<1y','1-2y','3-5y','6-10y','10y+'], example: '3-5y' }
                work_pattern: { type: string, enum: [full-time, part-time], example: full-time }
            status: { type: string, enum: [active, inactive], default: active }
    RowError:
      type: object
      properties: { index: { type: integer }, code: { type: string }, message: { type: string } }
    CheckinState:
      type: object
      properties:
        enabled: { type: boolean }
        cycle:
          type: [object, 'null']
          properties:
            id: { type: string, format: uuid }
            cycle_number: { type: integer }
            closes_at: { type: string, format: date-time }
            answered: { type: integer }
            total: { type: integer }
            complete: { type: boolean }
            items:
              type: array
              items:
                type: object
                properties:
                  code: { type: string, example: WORK_ABILITY_1 }
                  position: { type: integer }
                  question_text: { type: string }
                  response_scale: { type: string, example: NUMERIC_0_10 }
                  response_options:
                    description: Worded options — an array of {value,label}, or {anchors:{min,max}} for 0-10 scales.
                    oneOf:
                      - type: array
                        items: { type: object, properties: { value: { type: integer }, label: { type: string } } }
                      - type: object
                        properties: { anchors: { type: object, properties: { min: { type: string }, max: { type: string } } } }
                  answered: { type: boolean }
    AnswerSubmit:
      type: object
      required: [cycle_id, question_code]
      properties:
        cycle_id: { type: string, format: uuid }
        question_code: { type: string }
        value: { type: integer }
        band: { type: string, enum: [LOW, MID, HIGH] }
        channel: { type: string, enum: [embed, host_app, web], default: embed }
      oneOf:
        - required: [value]
        - required: [band]
    AnswerResult:
      type: object
      properties:
        status: { type: string, enum: [saved, duplicate] }
        answered: { type: integer }
        total: { type: integer }
        complete: { type: boolean }
    Cycle:
      type: object
      properties:
        id: { type: string, format: uuid }
        unit_id: { type: string }
        cycle_number: { type: integer }
        opens_at: { type: string, format: date-time }
        closes_at: { type: string, format: date-time }
        status: { type: string, enum: [open, closed] }
    Aggregate:
      type: object
      required: [cycle_id, metric, n, suppressed, method]
      properties:
        cycle_id: { type: string, format: uuid }
        unit_id: { type: string }
        metric: { type: string, enum: [WELLBEING_INDEX, DOMAIN_MEAN, PARTICIPATION] }
        question_code: { type: [string, 'null'] }
        construct_domain: { type: [string, 'null'], description: 'Core construct-domain@0.1.0 code OR a profile extension code, e.g. x-iwe:work-ability.' }
        n: { type: integer }
        headcount: { type: integer }
        completion_rate: { type: [number, 'null'], example: 0.412 }
        suppressed: { type: boolean }
        suppression_reason: { type: [string, 'null'], enum: [below-floor, safeguarding, low-completion, null] }
        value: { type: [number, 'null'], description: NULL whenever suppressed — by schema, not by filter. }
        ci_low: { type: [number, 'null'] }
        ci_high: { type: [number, 'null'] }
        sd: { type: [number, 'null'] }
        method: { type: string, enum: [design, indicative, model] }
        index_version: { type: string, example: v1-equal }
        computed_at: { type: string, format: date-time }
    OWHSAggregateReport:
      type: object
      properties:
        owhs_version: { type: string, example: '0.1' }
        producer: { type: object }
        codelists: { type: object }
        reports: { type: array, items: { $ref: '#/components/schemas/Aggregate' } }
    Usage:
      type: object
      properties:
        month: { type: string }
        enrolled_respondents: { type: integer }
