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

# Replay events

> The same events as the webhooks, oldest first, for the last 30 days. After an outage, pass the id of the last
event you processed as `since`, then follow `next_cursor` until it is `null`. Every event appears exactly once,
in order; events outside the 30-day window are gone.
Error code: `event_too_old` (410) when `since` is older than 30 days.


**Scope:** `events:read`.



## OpenAPI

````yaml /openapi.json get /v1/events
openapi: 3.1.0
info:
  title: TeraRx API
  version: 1.0.0-draft
  summary: GLP-1 coaching for telehealth brands, their patients and their prescribers.
  description: >
    TeraRx is the coaching layer for patients on GLP-1 therapy. Telehealth
    brands switch it on for their patients: an **AI Coach** on every plan; a
    health coach and a fitness coach on the coached plan; protocols, meal plans
    and workout plans built from the brand's own rule matrix; and **clinical
    flags** back to the brand's prescriber. TeraRx never prescribes: it mirrors
    the dose the brand sends and coaches against it.


    This contract is a **draft for partner review**. Every example uses
    synthetic data.


    | You are | You use | Start with |

    | --- | --- | --- |

    | A brand's server | Client credentials | [Auth](#tag/Auth), then `POST
    /v1/enrollments` |

    | A platform partner, for several brands | One client for all your brands, a
    `tenant_id` on every token | [Platform partners](#tag/Tenants) |

    | A brand's app | Patient tokens from token exchange | `GET /v1/patient` |

    | A prescriber system | A clinical client | `GET /v1/clinical-flags` |

    | Receiving events | Webhooks | [Webhooks](#tag/Webhooks) |


    ## Try it


    * **Sandbox:** every operation answers with the examples on this page at the
    sandbox server. It checks that a token is present but not its signature,
    validates request bodies and stores nothing.

    * **Postman:** import the
    [collection](postman/terarx-v1.postman_collection.json) and the [sandbox
    environment](postman/terarx-sandbox.postman_environment.json). A pre-request
    script gets each caller its token.

    * **Guides and samples:** quickstart, guides and Node and Python code for
    signing the patient assertion, verifying webhook signatures and enrolling a
    patient, on the docs site at [docs.tera.health](https://docs.tera.health).


    # Conventions


    ## Base URL and versioning


    All paths start with the major version, `/v1`. Within a version, changes are
    additive only: new endpoints, new optional request fields, new response
    fields, new enum values and new event types. Build clients that ignore
    fields and values they do not know.


    A breaking change means a new version (`/v2`), announced at least **6
    months** ahead. During that time the old version's responses carry
    `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers with the dates, and
    both versions run side by side.


    ## Formats


    * JSON in and out (`application/json`; `application/merge-patch+json` for
    `PATCH`). UTF-8.

    * Ids are UUID strings. Treat them as opaque.

    * Timestamps are ISO 8601 in UTC (`2026-10-26T14:05:00Z`). Log dates
    (`log_date`) are calendar dates in the patient's time zone.

    * Measurements are always metric (kg, cm, ml, g, kcal). `units` on the
    patient is a display preference only.

    * Field names are `snake_case`. Absent and `null` both mean "no value".


    ## Request ids


    Every response has an `X-Request-Id` header, errors included. Ours are
    `req_` followed by a UUIDv7. Send your own `X-Request-Id` to have it echoed,
    which makes logs easy to join: a UUID, or `req_` followed by up to 60
    letters, digits, `_` or `-`. Any other value is replaced with a new id.
    Quote it when you contact TeraRx.


    ## Errors


    Every 4xx and 5xx response is RFC 9457 problem details
    (`application/problem+json`):


    ```json

    {
      "type": "about:blank",
      "title": "Unprocessable Content",
      "status": 422,
      "detail": "1 field is invalid.",
      "code": "validation_failed",
      "request_id": "req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b",
      "errors": [{ "pointer": "/patient/state_code", "code": "invalid_format", "detail": "Must be a two-letter USPS state code." }]
    }

    ```


    Branch on `code`, which is stable; `title` and `detail` may change. Errors
    never contain patient data: no names, no values you sent, no free text. A
    resource from another brand or another patient returns `404` exactly like
    one that does not exist.


    | Status | Common `code` values |

    | --- | --- |

    | 400 | `malformed_request`, `idempotency_key_missing` |

    | 401 | `token_missing`, `token_expired`, `token_invalid`, `client_revoked`
    |

    | 403 | `wrong_caller_type` (a token of another kind, such as a patient
    token on a brand server endpoint), `insufficient_scope`, `plan_not_eligible`
    (a coached-plan endpoint on `ai_only`), `treatment_managed_by_brand` |

    | 404 | `<resource>_not_found`, for example `enrollment_not_found` |

    | 409 | `invalid_status_transition`, `idempotency_key_in_use`,
    `enrollment_exists`, `external_order_exists`, `flag_closed`, `ai_paused` |

    | 410 | `export_expired`, `event_too_old` |

    | 422 | `validation_failed`, `idempotency_key_reused`, `plan_not_offered`,
    `unknown_medication`, `unknown_legal_document` |

    | 429 | `rate_limited`, `daily_message_cap_reached` |

    | 503 | `ai_unavailable`, `service_unavailable` |


    Each operation lists the codes specific to it.


    ## Idempotency


    Every `POST` that creates something requires an `Idempotency-Key` header (a
    UUID is a good choice); without it the request is refused with `400
    idempotency_key_missing`. Other `POST` actions accept one.


    * The same key and the same body within **24 hours** returns the first
    response, with `Idempotent-Replayed: true`, and creates nothing new.

    * The same key with a different body is refused with `422
    idempotency_key_reused`.

    * While the first request with a key is still running, a second one gets
    `409 idempotency_key_in_use`; retry shortly.


    Keys are scoped to your client. Logs your server sends also carry your own
    `external_id`, which dedupes them permanently.


    ## Pagination


    Lists that can grow are cursor-paginated: `?limit=` (1 to 100, default 25)
    and `?cursor=`. The response is `{ "data": [...], "next_cursor": "..." }`;
    pass `next_cursor` back as `cursor` until it is `null`. Cursors are opaque
    and expire after 24 hours. Short, bounded lists return `{ "data": [...] }`
    without a cursor.


    ## Rate limits


    Limits are per client (600 requests per minute by default, set per contract)
    and, for patient tokens, per patient. Every response carries:


    | Header | Meaning |

    | --- | --- |

    | `RateLimit-Limit` | Requests allowed per window |

    | `RateLimit-Remaining` | Requests left |

    | `RateLimit-Reset` | Seconds until the window resets |


    Over the limit you get `429` with `Retry-After` in seconds. The AI Coach
    also has a daily message cap per patient, set on the brand's plan (`429
    daily_message_cap_reached`).


    ## Plans


    Patient endpoints say which plans they work on (the `x-plans` extension, and
    "Plans" at the end of each description). `ai_health_fitness` is the coached
    plan; `ai_only` has the AI Coach and no coaches, and its escalations go to
    the brand's prescriber as clinical flags. Calling a coached-plan endpoint
    for an `ai_only` patient returns `403 plan_not_eligible`.


    ## Privacy


    * A patient's records belong to one brand. Nothing is shared, matched or
    merged across brands.

    * Brand servers read status and ids, never clinical content. Reports are
    aggregates with groups under 10 patients suppressed.

    * Webhooks carry ids only. Fetch content through the API.

    * Files are uploaded and downloaded through short-lived pre-signed URLs.
    Never store those URLs.

    * Every read or change of a coaching record is audit-logged.


    ## Emergencies


    Coaching is not emergency care. Wherever a patient can type (care messages,
    the AI Coach, symptom logging), show this guidance: **in an emergency call
    911; in a mental health crisis call or text 988.** When a symptom log comes
    back with `is_red_flag: true`, or an AI Coach reply with
    `show_emergency_guidance: true`, show it before anything else.


    ## Environments


    | Environment | Base URL | Data |

    | --- | --- | --- |

    | Local mock | `http://localhost:4010` | The examples in this spec |

    | Sandbox (integration) | `https://sandbox.rx.tera.health` | Synthetic only.
    Until the API is live it is this mock: the examples, no webhooks. Then flags
    and webhooks fire; nothing is billed |

    | Production | `https://api.rx.tera.health` | Real patients |


    Each environment has its own clients, keys and webhook secret.
  license:
    name: Proprietary
servers:
  - url: https://sandbox.rx.tera.health
    description: Partner sandbox, synthetic data only
  - url: http://localhost:4010
    description: >-
      Local mock (Prism on the bundled spec), answers with the examples in this
      spec
security: []
tags:
  - name: Auth
    description: >
      Three kinds of caller use this API, each with its own token. The tokens
      are not interchangeable: a brand server token cannot call patient
      endpoints, and a patient token cannot call brand server or clinical
      endpoints. Coaches use the TeraRx portal, which is not part of this
      contract.


      | Caller | Security scheme | How it gets a token | Lifetime |

      | --- | --- | --- | --- |

      | Brand server | `brandServer` | Client credentials with its own
      `client_id` and secret | 1 hour |

      | Patient (brand app, or our own app's backend as TeraRx Direct) |
      `patientToken` | Token exchange of an assertion the brand's server signed
      | 15 minutes |

      | Prescriber system (brand clinician tool or Beluga) | `clinicalClient` |
      Client credentials with a separate clinical client | 1 hour |


      Each operation lists the scheme and the scope it needs. All tokens go in
      `Authorization: Bearer <token>`.


      A platform partner that hosts several brands holds one client for all of
      them and names the brand on every token request, so each token is still
      for one brand. See [Platform partners](#tag/Tenants).


      ## Brand servers: client credentials


      TeraRx issues one OAuth client per brand and environment (integration,
      production). The secret is shown once; TeraRx keeps only its hash. Keep it
      on your server, never in an app. The integration client works against the
      sandbox and test environments only; the production API answers it with
      `unauthorized_client`.


      | Problem | `code` | HTTP |

      | --- | --- | --- |

      | Unknown client or wrong secret (the same answer for both) |
      `invalid_client` | 401 |

      | The client was revoked | `client_revoked` | 401, also for tokens it
      already holds, within a minute |

      | A `scope` the client does not hold | `invalid_scope` | 400 |

      | The integration client on the production API | `unauthorized_client` |
      400 |

      | A partner client sent no `tenant_id` | `tenant_required` | 400 |

      | A `tenant_id` this client may not act for, or a brand that is not live |
      `tenant_not_permitted` | 400 |


      ```http

      POST /v1/oauth/token

      Authorization: Basic base64(client_id:client_secret)

      Content-Type: application/x-www-form-urlencoded


      grant_type=client_credentials&scope=enrollments:read%20enrollments:write%20logs:write

      ```


      | Scope | Allows |

      | --- | --- |

      | `enrollments:read` | Read enrollment status, the treatment mirror and
      consent records |

      | `enrollments:write` | Enroll, change plan, pause, resume, cancel, update
      the treatment mirror, record consent proof |

      | `logs:write` | Send logs your app already recorded |

      | `events:read` | Replay events |

      | `reports:read` | The aggregate brand report |

      | `data_requests:read`, `data_requests:write` | Read and create data
      requests; decide amendments |

      | `patients:token_exchange` | Your signing keys may mint patient tokens
      (see below) |

      | `tenants:read` | List the brands your client may act for |


      A brand server reads status and ids only. It never reads clinical content:
      messages, form answers, protocol text and log values stay with the patient
      and the care team.


      ## Patients: token exchange


      There are no patient passwords in the API. Your server vouches for the
      patient by signing a short-lived JWT with **your own private key**; TeraRx
      checks it against the public keys you registered (a JWKS URL or a JWKS
      document) and returns a patient token. TeraRx never holds a key that can
      mint patient sessions, and you can rotate keys yourself by publishing a
      new key in your JWKS.


      1. Your app signs the patient in with your own login.

      2. Your server signs a patient assertion (below) and hands it to the app.

      3. The app (or your server) exchanges it:


      ```http

      POST /v1/oauth/token

      Content-Type: application/x-www-form-urlencoded


      grant_type=urn:ietf:params:oauth:grant-type:token-exchange

      &subject_token=<assertion>

      &subject_token_type=urn:ietf:params:oauth:token-type:jwt

      ```


      4. The response's `access_token` is valid for 15 minutes for that one
      patient and enrollment. There is no refresh token: sign a new assertion
      when it expires.


      ### Patient assertion


      A JWT (RFC 7519) signed with `ES256`, `RS256` or `EdDSA`. The header names
      the key with `kid`, which must be in your registered JWKS.


      | Claim | Required | Value |

      | --- | --- | --- |

      | `iss` | Yes | Your issuer, exactly as registered with TeraRx at
      onboarding |

      | `sub` | Yes | Your patient id: the `external_patient_id` you used on
      `POST /v1/enrollments` |

      | `aud` | Yes | The TeraRx API audience for the environment, given at
      onboarding |

      | `iat` | Yes | Issued at, in Unix seconds |

      | `exp` | Yes | Expiry, at most 5 minutes after `iat` |

      | `jti` | Yes | A unique id. Each assertion can be exchanged once |

      | `tenant_id` | Platform partners only | The brand the patient is enrolled
      with. A brand's own assertions may omit it |


      TeraRx allows 30 seconds of clock skew on `iat` and `exp`. The assertion
      carries no names or health data.


      If you registered a JWKS URL, TeraRx caches it for 10 minutes and fetches
      it again as soon as an assertion names a `kid` it has not seen (at most
      every 30 seconds). To rotate, publish the new key next to the old one,
      start signing with it, then remove the old one.


      | Problem | `code` | HTTP |

      | --- | --- | --- |

      | Signature does not match a registered key |
      `assertion_signature_invalid` | 400 |

      | `exp` has passed | `assertion_expired` | 400 |

      | `exp` is more than 5 minutes after `iat` | `assertion_lifetime_too_long`
      | 400 |

      | `jti` already used | `assertion_replayed` | 400 |

      | Wrong `aud` | `assertion_audience_invalid` | 400 |

      | `iss` not registered | `assertion_issuer_unknown` | 400 |

      | A required claim is missing or malformed, or `iat` is in the future |
      `assertion_invalid` | 400 |

      | A partner's assertion names a brand the partner may not act for, or one
      that is not live | `tenant_not_permitted` | 400 |

      | No open enrollment for `sub` | `patient_not_found` | 400 |

      | The enrollment is cancelled, completed or ineligible |
      `enrollment_not_active` | 400 |

      | The client lacks `patients:token_exchange` |
      `token_exchange_not_allowed` | 400 |

      | The client was revoked | `client_revoked` | 401, also for the patient
      tokens it already exchanged, within a minute |

      | Your client's exchanges passed its per-minute limit | `rate_limited` |
      429 |

      | TeraRx could not fetch your JWKS URL, or could not check the `jti` |
      `service_unavailable` | 503 |


      Each problem also carries the RFC 6749 `error` field (`invalid_grant`,
      `invalid_client`, `unauthorized_client` ...) for OAuth libraries.


      ## Prescriber systems: clinical clients


      Your prescriber system gets its own OAuth client, separate from the brand
      server's, with `clinical:read` (read flags) and `clinical:write`
      (acknowledge and resolve). It authenticates with client credentials like a
      brand server.


      A clinical client acts for many prescribers, so every clinical write names
      the individual in the request body:


      ```json

      {
        "on_behalf_of": { "npi": "1234567893", "prescriber_id": "dr-00417", "name": "Dr. Alex Morgan, NP" }
      }

      ```


      `name` is required, plus at least one of `npi` or `prescriber_id`. A write
      without `on_behalf_of`, or with an NPI whose check digit is wrong, is
      refused with `422 validation_failed` and the field's `pointer`. TeraRx
      stores the prescriber and the client that sent it on the flag, shows the
      name to coaches, and records it in the audit log.
  - name: Tenants
    description: >
      A platform partner hosts several telehealth brands and switches TeraRx on
      for them. The partner integrates once, with one set of credentials for all
      its brands. A brand that integrates directly keeps its own client and
      needs none of this.


      Each brand stays its own tenant. Its patients, enrollments, logs and
      reports are separate from every other brand's, including brands under the
      same partner. Nothing is matched or shared across brands: a person
      enrolled with two of your brands is two patients.


      ## What TeraRx sets up


      TeraRx creates each brand at onboarding, with its plans, prices, retention
      and rule matrix, and gives you its `tenant_id`. You get:


      | What | Count | Used for |

      | --- | --- | --- |

      | A brand-server client | One per environment, for all your brands |
      Client credentials, with the scopes your brands need, plus `tenants:read`
      |

      | Signing keys for patient assertions | One JWKS URL (or JWKS document)
      and one issuer | Token exchange for every brand's patients |

      | A clinical client | One per environment, if you run the prescriber
      tooling for your brands | Clinical flags. A brand whose prescriber system
      is separate (for example Beluga) keeps its own clinical client |

      | A webhook endpoint and secret | One, for all your brands | A brand may
      have its own endpoint instead |


      ## Tokens: one brand at a time


      Every token is for one brand. Name the brand with `tenant_id` when you ask
      for a token:


      ```http

      POST /v1/oauth/token

      Authorization: Basic base64(client_id:client_secret)

      Content-Type: application/x-www-form-urlencoded


      grant_type=client_credentials&tenant_id=a0000000-0000-4000-8000-000000000002&scope=enrollments:read%20enrollments:write

      ```


      Keep one token per brand and reuse it for its hour. A call with brand A's
      token never sees brand B's patients; it answers `404` as if they did not
      exist.


      | Problem | `code` | HTTP |

      | --- | --- | --- |

      | No `tenant_id` | `tenant_required` | 400 |

      | A brand you may not act for, or one that is not live yet |
      `tenant_not_permitted` | 400 |


      `tenant_not_permitted` is the same answer for an unknown id and for
      another partner's brand. Rate limits count per brand, so one busy brand
      does not slow the others.


      ## Patient assertions


      Sign every brand's assertions with the same key and issuer, and add a
      `tenant_id` claim naming the brand the patient is enrolled with:


      ```json

      {
        "iss": "https://partner.example.com",
        "sub": "brand-pt-48213",
        "aud": "https://sandbox.rx.tera.health",
        "tenant_id": "a0000000-0000-4000-8000-000000000002",
        "iat": 1793635200,
        "exp": 1793635500,
        "jti": "5f0c9a52-8a4e-4d4f-9a52-0f6e8b7c1d2e"
      }

      ```


      `sub` is the `external_patient_id` you sent on `POST /v1/enrollments` for
      that brand. Your ids may repeat across brands; TeraRx looks `sub` up
      within the brand named by `tenant_id`. An assertion without `tenant_id` is
      refused with `assertion_invalid`, one naming a brand you may not act for
      with `tenant_not_permitted`. Everything else is as in
      [Authentication](#tag/Auth).


      ## Listing your brands


      `GET /v1/tenants` (scope `tenants:read`) lists every brand under your
      partner, whichever brand the token is for, with its `id`, `slug`,
      `display_name` and whether it is live. Use it to check your configuration
      against ours. You need one brand's `tenant_id`, from onboarding, to get
      the first token.


      ## Webhooks


      Events for all your brands go to your partner endpoint, signed with your
      secret, unless a brand has its own endpoint. Every event carries
      `tenant_id`: route on it. The payloads and signature are as in
      [Webhooks](#tag/Webhooks).


      ## Reports and invoices


      `GET /v1/reports/brand` reports on the brand the token is for; ask once
      per brand. TeraRx sends one monthly invoice for all your brands, with a
      section per brand.
  - name: Enrollments
    description: >-
      A patient's enrollment in coaching with one brand. Brand servers create
      and manage them and read status only, never clinical content.
  - name: Treatment
    description: >-
      The GLP-1 medication, dose and schedule the brand's prescriber set,
      mirrored by TeraRx and never changed.
  - name: Consent
    description: Proof of the legal documents a patient accepted at the brand's checkout.
  - name: Brand logs
    description: >
      Logs the brand already holds, sent with the brand's own ids for safe
      retries, and corrected or deleted by those

      ids. Brand servers get receipts, never the values back.
  - name: Events
    description: Replay of the webhook events for the last 30 days, in order.
  - name: Reports
    description: The aggregate brand report. Groups under 10 patients are suppressed.
  - name: Data requests
    description: >
      Export, delete and amend requests a patient makes through the brand.
      Moving a patient's records to another brand

      is not an API request: send TeraRx support the patient's signed
      authorization and support handles the transfer.
  - name: Patient
    description: The patient's profile, settings, treatment and legal documents.
  - name: Forms
    description: >-
      Onboarding, check-in and eligibility forms, pinned to the version they
      were sent with.
  - name: Logging
    description: >
      Meals, workouts, symptoms, injections and daily values. Weight and body
      measures go on the daily log. Logs made

      in the app can be corrected or deleted; deleted logs leave lists and
      rollups.
  - name: Protocols and plans
    description: >-
      Published protocols, meal plans and workout plans, read only. Drafts are
      never visible to patients.
  - name: Meal swap
    description: >-
      The AI Coach swaps a meal for one that stays within the protocol and the
      brand's rule matrix.
  - name: Library
    description: >-
      Recipes and exercises that plans link to, that the patient can browse,
      save and rate.
  - name: Care messaging
    description: >-
      Threads with the coaches on the coached plan, photos, editing the last
      message, read state and reactions.
  - name: Feedback
    description: >-
      Popups that ask the patient about their coach. TeraRx writes the questions
      and decides when to ask.
  - name: AI Coach
    description: >-
      Chat with the AI Coach on every plan, and what it remembers. Replies
      stream or can be polled.
  - name: Reminders
    description: >
      Upcoming injection, check-in, logging and habit reminders, and the
      patient's reminder settings, including whether

      the AI Coach may start conversations.
  - name: Progress
    description: >-
      Today at a glance, targets for a day, a week's stats and the milestones
      the patient reached.
  - name: Clinical flags
    description: >
      Flags for the brand's prescriber, read and acted on with the clinical
      client. On the AI-only plan every

      escalation lands here. Each write names the individual prescriber in
      `on_behalf_of`.
  - name: Webhooks
    description: >
      TeraRx tells your server about changes with signed HTTPS `POST`s to the
      webhook URL registered for your brand. Each delivery is one event.
      Payloads are thin: ids and the minimum needed to route, never message
      bodies, form answers, protocol text or health values. Fetch details
      through the API with the right token.


      ## Envelope


      ```json

      {
        "id": "0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7",
        "type": "clinical.flag_raised",
        "created_at": "2026-10-26T14:05:00Z",
        "tenant_id": "a0000000-0000-4000-8000-000000000002",
        "data": { "enrollment_id": "…", "external_patient_id": "brand-pt-48213", "…": "…" }
      }

      ```


      `data` always has `enrollment_id` and your `external_patient_id`. The full
      list of events is below, and each event's payload is in the Webhooks
      section of the API reference. You can limit the events sent to you when
      your webhook is registered; by default you get all of them.


      | Event | When |

      | --- | --- |

      | `enrollment.created` | An enrollment was created |

      | `enrollment.stage_changed` | The enrollment moved to the next stage |

      | `enrollment.paused`, `enrollment.resumed`, `enrollment.cancelled` |
      Status changes |

      | `care_team.assigned`, `care_team.changed` | A coach was assigned,
      reassigned or removed (coached plan) |

      | `care_team.capacity_warning` | No licensed coach with capacity is
      available; the enrollment waits |

      | `care_team.feedback_due` | TeraRx would like the patient's feedback on a
      coach; show your feedback popup |

      | `form.requested`, `onboarding.completed` | A form was sent; every
      blocking onboarding form is done |

      | `protocol.published`, `protocol.updated` | A protocol went live, or a
      new version replaced it |

      | `message.created` | A new care or AI Coach message (ids only) |

      | `clinical.flag_raised` | A clinical flag for your prescriber |

      | `clinical.flag_redelivered` | A flag passed `ack_due_at` without an
      acknowledgement |

      | `clinical.flag_acknowledged`, `clinical.flag_resolved` | A prescriber
      acted on a flag |

      | `patient.disengaged` | No activity for your `disengaged_after_days` (7
      by default) |

      | `patient.milestone_reached` | The patient reached a milestone; read its
      details with the patient's token |

      | `reminder.due` | A reminder is due; send the notification in your own
      words |

      | `data_request.completed` | An export, delete or amend request finished |


      On the AI-only plan, **every** alert that raises the risk level and every
      AI Coach escalation sends `clinical.flag_raised`, even when the alert rule
      is set not to notify the brand: there is no coach to reach, so your
      prescriber is the human in the loop.


      ## Verifying the signature


      Every delivery carries three headers:


      | Header | Value |

      | --- | --- |

      | `TeraRx-Timestamp` | Unix time in seconds when this attempt was signed |

      | `TeraRx-Signature` | `v1=<hex>`; during a secret rotation
      `v1=<new>,v1=<old>` |

      | `TeraRx-Event-Id` | The event id, the same as `id` in the body |


      Step by step:


      1. Read the **raw** request body as bytes. Do not parse and re-serialize
      the JSON first; any change in whitespace breaks the signature.

      2. Read `TeraRx-Timestamp`. Reject the delivery if it is more than 5
      minutes from your clock (replay protection).

      3. Build the signed payload: the timestamp, a period, then the raw body:
      `<timestamp>.<raw body>`.

      4. Compute `HMAC-SHA256(secret, signed_payload)` as lowercase hex, using
      your webhook secret.

      5. Split `TeraRx-Signature` on commas. Accept the delivery if **any**
      `v1=` value matches, compared in constant time.

      6. Dedupe on `TeraRx-Event-Id`: a retry carries the same id. Store ids for
      at least 24 hours and answer `200` to a repeat without processing it
      again.

      7. Answer `2xx` quickly, then process. Do the work after you respond, or
      queue it.


      ### Worked example


      With the secret `example_webhook_secret_new`, timestamp `1793023500` and
      this exact body:


      ```

      {"id":"0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7","type":"clinical.flag_raised","created_at":"2026-10-26T14:05:00Z","tenant_id":"a0000000-0000-4000-8000-000000000002","data":{"enrollment_id":"0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5","external_patient_id":"brand-pt-48213","flag_id":"0190f5d8-4b2c-7d5e-9f10-b1c2d3e4f5a6","severity":"critical","raised_risk_to":"high","ack_due_at":"2026-10-26T16:05:00Z"}}

      ```


      the header is `TeraRx-Signature:
      v1=5e9f492e2cee549f0d1688a9d4a6d0505ea1687e5c293eb28a01bad16ee7aa2a`.
      During a rotation where the old secret was `example_webhook_secret_old`,
      it is
      `v1=5e9f492e2cee549f0d1688a9d4a6d0505ea1687e5c293eb28a01bad16ee7aa2a,v1=2e8d962ded17a921cbc548b968abc024e9fcae75fd8ff8a0f2044999b0898416`.


      ### Node


      ```js

      import { createHmac, timingSafeEqual } from "node:crypto";


      export function verifyTeraRx(rawBody, headers, secret, now = Date.now()) {
        const timestamp = headers["terarx-timestamp"];
        if (!timestamp || Math.abs(now / 1000 - Number(timestamp)) > 300) return false;
        const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
        return (headers["terarx-signature"] ?? "").split(",").some((part) => {
          const [version, hex] = part.trim().split("=");
          const given = Buffer.from(hex ?? "", "hex");
          return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
        });
      }

      ```


      ### Python


      ```python

      import hashlib, hmac, time


      def verify_terarx(raw_body: bytes, headers: dict, secret: str) -> bool:
          timestamp = headers.get("TeraRx-Timestamp", "")
          if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
              return False
          expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
          return any(
              part.strip().startswith("v1=") and hmac.compare_digest(part.strip()[3:], expected)
              for part in headers.get("TeraRx-Signature", "").split(",")
          )
      ```


      ## Secret rotation


      TeraRx issues a new secret on request. For the rotation window (24 hours
      by default) every delivery is signed with both secrets, new first. Deploy
      the new secret, confirm deliveries verify with it, and tell TeraRx to end
      the window; after that only the new signature is sent.


      ## Delivery and retries


      * Answer with any `2xx` within **10 seconds**. Anything else, or no answer
      in time, is a failed attempt.

      * Failed attempts are retried with exponential backoff (about 30 s, 2 min,
      10 min, 30 min, then hourly) for **24 hours**. Each attempt is signed
      again with a fresh timestamp; the event id stays the same.

      * After 24 hours the event is dead-lettered. Nothing is lost: replay it
      with `GET /v1/events?since=<last event id you processed>`, which keeps 30
      days of events in order.

      * Deliveries are not guaranteed to arrive in order. Use `created_at`, or
      fetch the current state through the API, when order matters.


      ## Clinical flag re-delivery


      A flag must be acknowledged by its `ack_due_at`, which comes from the
      acknowledgement time agreed in your contract. When it passes, the flag
      stays open and TeraRx sends `clinical.flag_redelivered` with a new event
      id (so your dedupe does not swallow it), a new `ack_due_at` and a
      `redelivery_count`. This repeats until a prescriber acknowledges the flag
      with `POST /v1/clinical-flags/{flag_id}/acknowledge`. Route re-deliveries
      to whoever covers when the first prescriber does not answer.
paths:
  /v1/events:
    get:
      tags:
        - Events
      summary: Replay events
      description: >-
        The same events as the webhooks, oldest first, for the last 30 days.
        After an outage, pass the id of the last

        event you processed as `since`, then follow `next_cursor` until it is
        `null`. Every event appears exactly once,

        in order; events outside the 30-day window are gone.

        Error code: `event_too_old` (410) when `since` is older than 30 days.



        **Scope:** `events:read`.
      operationId: listEvents
      parameters:
        - name: since
          in: query
          description: >-
            Start after this event id (exclusive). Omit to start at the oldest
            event kept.
          schema:
            type: string
            format: uuid
            x-default: 0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7
          example: 0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7
        - name: type
          in: query
          description: Only these event types, comma-separated.
          style: form
          explode: false
          schema:
            type: array
            items:
              $ref: '#/components/schemas/EventType'
            x-default:
              - clinical.flag_raised
              - clinical.flag_redelivered
          example:
            - clinical.flag_raised
            - clinical.flag_redelivered
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventList'
              example:
                data:
                  - id: 0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7
                    type: clinical.flag_raised
                    created_at: '2026-10-26T14:05:00Z'
                    tenant_id: a0000000-0000-4000-8000-000000000002
                    data:
                      enrollment_id: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
                      external_patient_id: brand-pt-48213
                      flag_id: 0190f5d8-4b2c-7d5e-9f10-b1c2d3e4f5a6
                      severity: critical
                      raised_risk_to: high
                      ack_due_at: '2026-10-26T16:05:00Z'
                  - id: 0190f5e1-0a1b-7c2d-9e3f-4a5b6c7d8e9f
                    type: clinical.flag_acknowledged
                    created_at: '2026-10-26T14:20:00Z'
                    tenant_id: a0000000-0000-4000-8000-000000000002
                    data:
                      enrollment_id: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
                      external_patient_id: brand-pt-48213
                      flag_id: 0190f5d8-4b2c-7d5e-9f10-b1c2d3e4f5a6
                      acknowledged_at: '2026-10-26T14:20:00Z'
                next_cursor: null
          headers:
            X-Request-Id:
              description: >-
                Unique id of this request. Echoes a valid `X-Request-Id` you
                sent, otherwise generated. Also in every problem body.
              schema:
                type: string
              example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
            RateLimit-Limit:
              description: >-
                Requests allowed per minute for this client (600 by default, set
                per contract).
              schema:
                type: integer
              example: 600
            RateLimit-Remaining:
              description: Requests left in the current window.
              schema:
                type: integer
              example: 587
            RateLimit-Reset:
              description: Seconds until the window resets.
              schema:
                type: integer
              example: 42
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '410':
          $ref: '#/components/responses/Gone'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      security:
        - brandServer:
            - events:read
components:
  schemas:
    EventType:
      type: string
      enum:
        - enrollment.created
        - enrollment.stage_changed
        - enrollment.paused
        - enrollment.resumed
        - enrollment.cancelled
        - care_team.assigned
        - care_team.changed
        - care_team.capacity_warning
        - care_team.feedback_due
        - form.requested
        - onboarding.completed
        - protocol.published
        - protocol.updated
        - message.created
        - clinical.flag_raised
        - clinical.flag_redelivered
        - clinical.flag_acknowledged
        - clinical.flag_resolved
        - patient.disengaged
        - patient.milestone_reached
        - reminder.due
        - data_request.completed
      example: enrollment.created
    EventList:
      type: object
      required:
        - data
        - next_cursor
      properties:
        data:
          type: array
          description: Oldest first, in the order they were written.
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          $ref: '#/components/schemas/NextCursor'
    Event:
      oneOf:
        - $ref: '#/components/schemas/EnrollmentCreatedEvent'
        - $ref: '#/components/schemas/EnrollmentStageChangedEvent'
        - $ref: '#/components/schemas/EnrollmentPausedEvent'
        - $ref: '#/components/schemas/EnrollmentResumedEvent'
        - $ref: '#/components/schemas/EnrollmentCancelledEvent'
        - $ref: '#/components/schemas/CareTeamAssignedEvent'
        - $ref: '#/components/schemas/CareTeamChangedEvent'
        - $ref: '#/components/schemas/CareTeamCapacityWarningEvent'
        - $ref: '#/components/schemas/CareTeamFeedbackDueEvent'
        - $ref: '#/components/schemas/FormRequestedEvent'
        - $ref: '#/components/schemas/OnboardingCompletedEvent'
        - $ref: '#/components/schemas/ProtocolPublishedEvent'
        - $ref: '#/components/schemas/ProtocolUpdatedEvent'
        - $ref: '#/components/schemas/MessageCreatedEvent'
        - $ref: '#/components/schemas/ClinicalFlagRaisedEvent'
        - $ref: '#/components/schemas/ClinicalFlagRedeliveredEvent'
        - $ref: '#/components/schemas/ClinicalFlagAcknowledgedEvent'
        - $ref: '#/components/schemas/ClinicalFlagResolvedEvent'
        - $ref: '#/components/schemas/PatientDisengagedEvent'
        - $ref: '#/components/schemas/PatientMilestoneReachedEvent'
        - $ref: '#/components/schemas/ReminderDueEvent'
        - $ref: '#/components/schemas/DataRequestCompletedEvent'
      discriminator:
        propertyName: type
        mapping:
          enrollment.created: '#/components/schemas/EnrollmentCreatedEvent'
          enrollment.stage_changed: '#/components/schemas/EnrollmentStageChangedEvent'
          enrollment.paused: '#/components/schemas/EnrollmentPausedEvent'
          enrollment.resumed: '#/components/schemas/EnrollmentResumedEvent'
          enrollment.cancelled: '#/components/schemas/EnrollmentCancelledEvent'
          care_team.assigned: '#/components/schemas/CareTeamAssignedEvent'
          care_team.changed: '#/components/schemas/CareTeamChangedEvent'
          care_team.capacity_warning: '#/components/schemas/CareTeamCapacityWarningEvent'
          care_team.feedback_due: '#/components/schemas/CareTeamFeedbackDueEvent'
          form.requested: '#/components/schemas/FormRequestedEvent'
          onboarding.completed: '#/components/schemas/OnboardingCompletedEvent'
          protocol.published: '#/components/schemas/ProtocolPublishedEvent'
          protocol.updated: '#/components/schemas/ProtocolUpdatedEvent'
          message.created: '#/components/schemas/MessageCreatedEvent'
          clinical.flag_raised: '#/components/schemas/ClinicalFlagRaisedEvent'
          clinical.flag_redelivered: '#/components/schemas/ClinicalFlagRedeliveredEvent'
          clinical.flag_acknowledged: '#/components/schemas/ClinicalFlagAcknowledgedEvent'
          clinical.flag_resolved: '#/components/schemas/ClinicalFlagResolvedEvent'
          patient.disengaged: '#/components/schemas/PatientDisengagedEvent'
          patient.milestone_reached: '#/components/schemas/PatientMilestoneReachedEvent'
          reminder.due: '#/components/schemas/ReminderDueEvent'
          data_request.completed: '#/components/schemas/DataRequestCompletedEvent'
    NextCursor:
      type:
        - string
        - 'null'
      description: Pass it back as `cursor` for the next page. `null` on the last page.
      example: eyJpZCI6IjAxOTBmNWMyIn0
    Problem:
      type: object
      description: >
        RFC 9457 problem details, sent as `application/problem+json` with every
        4xx and 5xx.

        A problem never contains patient data: no names, no values from the
        request, no free text the caller sent.
      required:
        - type
        - title
        - status
        - code
        - request_id
      properties:
        type:
          type: string
          format: uri-reference
          description: >-
            `about:blank` until the docs have a public home. Branch on `code`,
            not on `type` or `title`.
          example: about:blank
        title:
          type: string
          description: The HTTP status phrase.
          example: Not Found
        status:
          type: integer
          minimum: 400
          maximum: 599
          example: 404
        detail:
          type: string
          description: What went wrong, in words safe to log.
          example: No enrollment with this id is visible to this client.
        instance:
          type: string
          format: uri-reference
          description: The path of the request that failed.
          example: /v1/enrollments/0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
        code:
          type: string
          pattern: ^[a-z][a-z0-9_]*$
          description: >-
            Stable machine-readable code. New codes may be added; existing ones
            never change meaning.
          example: enrollment_not_found
        request_id:
          type: string
          description: >-
            Same value as the `X-Request-Id` response header. Quote it when you
            contact TeraRx.
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        errors:
          type: array
          description: One entry per invalid field, for `validation_failed`.
          items:
            $ref: '#/components/schemas/ValidationIssue'
    EnrollmentCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: enrollment.created
            data:
              $ref: '#/components/schemas/EnrollmentCreatedData'
    EnrollmentStageChangedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: enrollment.stage_changed
            data:
              $ref: '#/components/schemas/EnrollmentStageChangedData'
    EnrollmentPausedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: enrollment.paused
            data:
              $ref: '#/components/schemas/EnrollmentRef'
    EnrollmentResumedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: enrollment.resumed
            data:
              $ref: '#/components/schemas/EnrollmentRef'
    EnrollmentCancelledEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: enrollment.cancelled
            data:
              $ref: '#/components/schemas/EnrollmentCancelledData'
    CareTeamAssignedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: care_team.assigned
            data:
              $ref: '#/components/schemas/CareTeamAssignedData'
    CareTeamChangedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: care_team.changed
            data:
              $ref: '#/components/schemas/CareTeamChangedData'
    CareTeamCapacityWarningEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: care_team.capacity_warning
            data:
              $ref: '#/components/schemas/CareTeamCapacityWarningData'
    CareTeamFeedbackDueEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: care_team.feedback_due
            data:
              $ref: '#/components/schemas/CareTeamFeedbackDueData'
    FormRequestedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: form.requested
            data:
              $ref: '#/components/schemas/FormRequestedData'
    OnboardingCompletedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: onboarding.completed
            data:
              $ref: '#/components/schemas/OnboardingCompletedData'
    ProtocolPublishedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: protocol.published
            data:
              $ref: '#/components/schemas/ProtocolPublishedData'
    ProtocolUpdatedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: protocol.updated
            data:
              $ref: '#/components/schemas/ProtocolPublishedData'
    MessageCreatedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: message.created
            data:
              $ref: '#/components/schemas/MessageCreatedData'
    ClinicalFlagRaisedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: clinical.flag_raised
            data:
              $ref: '#/components/schemas/ClinicalFlagRaisedData'
    ClinicalFlagRedeliveredEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: clinical.flag_redelivered
            data:
              $ref: '#/components/schemas/ClinicalFlagRedeliveredData'
    ClinicalFlagAcknowledgedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: clinical.flag_acknowledged
            data:
              $ref: '#/components/schemas/ClinicalFlagAcknowledgedData'
    ClinicalFlagResolvedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: clinical.flag_resolved
            data:
              $ref: '#/components/schemas/ClinicalFlagResolvedData'
    PatientDisengagedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: patient.disengaged
            data:
              $ref: '#/components/schemas/PatientDisengagedData'
    PatientMilestoneReachedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: patient.milestone_reached
            data:
              $ref: '#/components/schemas/PatientMilestoneReachedData'
    ReminderDueEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: reminder.due
            data:
              $ref: '#/components/schemas/ReminderDueData'
    DataRequestCompletedEvent:
      allOf:
        - $ref: '#/components/schemas/EventEnvelope'
        - properties:
            type:
              const: data_request.completed
            data:
              $ref: '#/components/schemas/DataRequestCompletedData'
    ValidationIssue:
      type: object
      required:
        - pointer
        - code
      properties:
        pointer:
          type: string
          description: >-
            JSON Pointer (RFC 6901) to the field in the request body, or
            `path:<name>`, `query:<name>` or `header:<name>` for a parameter.
          example: /patient/state_code
        code:
          type: string
          enum:
            - required
            - invalid_type
            - invalid_format
            - out_of_range
            - too_long
            - unknown_value
            - not_allowed
          example: invalid_format
        detail:
          type: string
          description: >-
            What the field must look like. Never repeats the value that was
            sent.
          example: Must be a two-letter USPS state code.
    EventEnvelope:
      type: object
      required:
        - id
        - type
        - created_at
        - tenant_id
        - data
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
          description: Unique per event; the same on every retry. Dedupe on it.
          example: 0190f5d8-4b2d-7e6f-8a21-c2d3e4f5a6b7
        type:
          $ref: '#/components/schemas/EventType'
        created_at:
          $ref: '#/components/schemas/Timestamp'
        tenant_id:
          $ref: '#/components/schemas/Uuid'
          description: Your tenant.
          example: a0000000-0000-4000-8000-000000000002
        data:
          type: object
          additionalProperties: true
    EnrollmentCreatedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - plan_code
            - status
            - stage
          properties:
            external_order_id:
              type:
                - string
                - 'null'
              example: ord-2026-10-000123
            plan_code:
              $ref: '#/components/schemas/PlanCode'
            status:
              $ref: '#/components/schemas/EnrollmentStatus'
            stage:
              $ref: '#/components/schemas/EnrollmentStage'
    EnrollmentStageChangedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - from
            - to
          properties:
            from:
              $ref: '#/components/schemas/EnrollmentStage'
            to:
              $ref: '#/components/schemas/EnrollmentStage'
    EnrollmentRef:
      type: object
      required:
        - enrollment_id
        - external_patient_id
      properties:
        enrollment_id:
          $ref: '#/components/schemas/Uuid'
        external_patient_id:
          $ref: '#/components/schemas/ExternalId'
    EnrollmentCancelledData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - ended_at
            - cancel_reason
          properties:
            ended_at:
              $ref: '#/components/schemas/Timestamp'
            cancel_reason:
              type: string
              enum:
                - patient_request
                - subscription_ended
                - payment_failed
                - medically_ineligible
                - other
              example: subscription_ended
    CareTeamAssignedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - care_role
            - assigned_at
          properties:
            care_role:
              $ref: '#/components/schemas/CareRole'
            assigned_at:
              $ref: '#/components/schemas/Timestamp'
    CareTeamChangedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - care_role
            - change
            - changed_at
          properties:
            care_role:
              $ref: '#/components/schemas/CareRole'
            change:
              type: string
              enum:
                - reassigned
                - ended
              description: >-
                `reassigned` to another coach, or `ended` (for example on a move
                to `ai_only`).
              example: reassigned
            reason_code:
              type: string
              enum:
                - state_change
                - plan_change
                - coach_unavailable
                - other
              description: >
                Why: the patient moved to a state the coach isn't licensed in,
                the plan changed, the coach left or is

                unavailable, or another reason. Check for unknown values: new
                reasons may be added.
              example: state_change
            changed_at:
              $ref: '#/components/schemas/Timestamp'
    CareTeamCapacityWarningData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - care_role
            - state_code
            - waiting_since
          properties:
            care_role:
              $ref: '#/components/schemas/CareRole'
            state_code:
              $ref: '#/components/schemas/StateCode'
            waiting_since:
              $ref: '#/components/schemas/Timestamp'
    CareTeamFeedbackDueData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - feedback_request_id
            - care_role
          properties:
            feedback_request_id:
              $ref: '#/components/schemas/Uuid'
            care_role:
              $ref: '#/components/schemas/CareRole'
            expires_at:
              type:
                - string
                - 'null'
              format: date-time
              example: '2026-11-23T00:00:00Z'
    FormRequestedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - form_assignment_id
            - form_code
            - purpose
          properties:
            form_assignment_id:
              $ref: '#/components/schemas/Uuid'
            form_code:
              type: string
              example: glp1_onboarding
            purpose:
              $ref: '#/components/schemas/FormPurpose'
            due_at:
              type:
                - string
                - 'null'
              format: date-time
              example: '2026-10-24T15:00:00Z'
    OnboardingCompletedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - completed_at
          properties:
            completed_at:
              $ref: '#/components/schemas/Timestamp'
    ProtocolPublishedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - protocol_id
            - protocol_type
            - version
            - published_at
          properties:
            protocol_id:
              $ref: '#/components/schemas/Uuid'
            protocol_type:
              $ref: '#/components/schemas/ProtocolType'
            version:
              type: integer
              example: 1
            published_at:
              $ref: '#/components/schemas/Timestamp'
    MessageCreatedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - conversation_id
            - message_id
            - conversation_kind
            - sender_type
          properties:
            conversation_id:
              $ref: '#/components/schemas/Uuid'
            message_id:
              $ref: '#/components/schemas/Uuid'
            conversation_kind:
              type: string
              enum:
                - care
                - ai_coach
              example: care
            sender_type:
              type: string
              enum:
                - patient
                - coach
                - ai
                - system
              example: coach
    ClinicalFlagRaisedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - flag_id
            - severity
            - ack_due_at
          properties:
            flag_id:
              $ref: '#/components/schemas/Uuid'
            severity:
              $ref: '#/components/schemas/Severity'
            raised_risk_to:
              type:
                - string
                - 'null'
              enum:
                - normal
                - elevated
                - high
                - null
              example: high
            ack_due_at:
              type:
                - string
                - 'null'
              format: date-time
              example: '2026-10-26T16:05:00Z'
    ClinicalFlagRedeliveredData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - flag_id
            - severity
            - ack_due_at
            - redelivery_count
            - first_raised_at
          properties:
            flag_id:
              $ref: '#/components/schemas/Uuid'
            severity:
              $ref: '#/components/schemas/Severity'
            ack_due_at:
              $ref: '#/components/schemas/Timestamp'
              description: The new deadline.
            redelivery_count:
              type: integer
              minimum: 1
              example: 1
            first_raised_at:
              $ref: '#/components/schemas/Timestamp'
    ClinicalFlagAcknowledgedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - flag_id
            - acknowledged_at
          properties:
            flag_id:
              $ref: '#/components/schemas/Uuid'
            acknowledged_at:
              $ref: '#/components/schemas/Timestamp'
    ClinicalFlagResolvedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - flag_id
            - resolved_at
            - new_risk_level
          properties:
            flag_id:
              $ref: '#/components/schemas/Uuid'
            resolved_at:
              $ref: '#/components/schemas/Timestamp'
            new_risk_level:
              $ref: '#/components/schemas/RiskLevel'
    PatientDisengagedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - last_activity_at
            - inactive_days
          properties:
            last_activity_at:
              $ref: '#/components/schemas/Timestamp'
            inactive_days:
              type: integer
              description: At least the brand's `disengaged_after_days` (7 by default).
              example: 7
    PatientMilestoneReachedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - milestone_id
            - code
            - achieved_at
          properties:
            milestone_id:
              $ref: '#/components/schemas/Uuid'
            code:
              $ref: '#/components/schemas/MilestoneCode'
            achieved_at:
              $ref: '#/components/schemas/Timestamp'
    ReminderDueData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - reminder_id
            - kind
            - due_at
          properties:
            reminder_id:
              $ref: '#/components/schemas/Uuid'
            kind:
              type: string
              enum:
                - injection
                - check_in
                - log_nudge
                - habit
              example: injection
            due_at:
              $ref: '#/components/schemas/Timestamp'
            form_assignment_id:
              type:
                - string
                - 'null'
              format: uuid
              description: For `check_in` reminders.
              example: null
    DataRequestCompletedData:
      allOf:
        - $ref: '#/components/schemas/EnrollmentRef'
        - type: object
          required:
            - data_request_id
            - kind
            - status
          properties:
            data_request_id:
              $ref: '#/components/schemas/Uuid'
            kind:
              type: string
              enum:
                - export
                - delete
                - amend
              example: export
            status:
              type: string
              enum:
                - completed
                - rejected
              example: completed
    Uuid:
      type: string
      format: uuid
      example: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
    Timestamp:
      type: string
      format: date-time
      description: ISO 8601 in UTC.
      example: '2026-10-26T14:05:00Z'
    PlanCode:
      type: string
      enum:
        - ai_only
        - ai_health_fitness
      description: >
        * `ai_only`: the AI Coach only. Escalations go to the brand's prescriber
        as clinical flags.

        * `ai_health_fitness`: the AI Coach plus a health coach (nutrition) and
        a fitness coach.
      example: ai_health_fitness
    EnrollmentStatus:
      type: string
      enum:
        - pending_screening
        - ineligible
        - active
        - paused
        - cancelled
        - completed
      example: active
    EnrollmentStage:
      type: string
      enum:
        - created
        - awaiting_assignment
        - assigned
        - onboarding_pending
        - onboarding_completed
        - protocols_active
      example: onboarding_pending
    ExternalId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: ^[\x21-\x7E]+$
      description: Your own identifier, printable ASCII without spaces.
      example: brand-pt-48213
    CareRole:
      type: string
      pattern: ^[a-z][a-z0-9_]*$
      description: >-
        A care role on the coached plan. Today `health_coach` or
        `fitness_coach`; more may be added.
      example: health_coach
    StateCode:
      type: string
      pattern: ^[A-Z]{2}$
      description: >-
        US state of residence (USPS code). Decides which licensed coaches can be
        assigned.
      example: NY
    FormPurpose:
      type: string
      enum:
        - onboarding
        - check_in
        - eligibility
      example: onboarding
    ProtocolType:
      type: string
      enum:
        - nutrition
        - fitness
        - sleep
        - stress
        - lifestyle
        - supplements
      example: nutrition
    Severity:
      type: string
      enum:
        - info
        - warning
        - critical
      example: critical
    RiskLevel:
      type: string
      enum:
        - normal
        - elevated
        - high
      description: Raised by clinical flags. Only a prescriber's resolve lowers it.
      example: normal
    MilestoneCode:
      type: string
      description: >
        What the patient reached. New codes may be added; show a generic
        celebration for a code you don't know.


        | Code | Reached when |

        | --- | --- |

        | `first_meal_logged` | The first meal is logged |

        | `logging_streak_7`, `logging_streak_30` | Something is logged 7 or 30
        days in a row |

        | `protein_target_days_7`, `protein_target_days_30` | 7 or 30 days at
        80% or more of the protein target |

        | `workouts_10`, `workouts_50` | 10 or 50 workouts logged |

        | `injections_on_time_4`, `injections_on_time_12` | 4 or 12 doses in a
        row logged on their day |

        | `week_4_completed`, `week_12_completed` | 4 or 12 weeks since
        enrollment |

        | `weight_loss_5_pct`, `weight_loss_10_pct` | 5% or 10% below the first
        weight logged |

        | `goal_weight_reached` | At or below the health profile's
        `goal_weight_kg` |
      enum:
        - first_meal_logged
        - logging_streak_7
        - logging_streak_30
        - protein_target_days_7
        - protein_target_days_30
        - workouts_10
        - workouts_50
        - injections_on_time_4
        - injections_on_time_12
        - week_4_completed
        - week_12_completed
        - weight_loss_5_pct
        - weight_loss_10_pct
        - goal_weight_reached
      example: protein_target_days_7
  parameters:
    Limit:
      name: limit
      in: query
      description: Page size.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
        x-default: 25
      example: 25
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` from the previous page. Omit it for the first page.
      schema:
        type: string
        maxLength: 512
        x-default: eyJpZCI6IjAxOTBmNWMyIn0
      example: eyJpZCI6IjAxOTBmNWMyIn0
  responses:
    BadRequest:
      description: >-
        The request is malformed (unparseable JSON, wrong content type, unknown
        query parameter).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Bad Request
            status: 400
            detail: The request body is not valid JSON.
            code: malformed_request
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    Unauthorized:
      description: The access token is missing, expired or revoked.
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
        WWW-Authenticate:
          description: The scheme to authenticate with.
          schema:
            type: string
          example: Bearer error="invalid_token"
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Unauthorized
            status: 401
            detail: The access token has expired. Request a new one.
            code: token_expired
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
    Forbidden:
      description: >-
        The token is valid but lacks the scope, or the resource is not available
        on the patient's plan.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Forbidden
            status: 403
            detail: This operation needs the enrollments:write scope.
            code: insufficient_scope
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    Gone:
      description: The resource existed but has expired.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Gone
            status: 410
            detail: The export has expired. Create a new export request.
            code: export_expired
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    UnprocessableEntity:
      description: >
        A path, query or header value, or the body, fails validation (for
        example an id that isn't a UUID, or a

        field out of range), or an `Idempotency-Key` was reused with a different
        body. `errors` lists each problem.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          examples:
            validation_failed:
              summary: Field validation
              value:
                type: about:blank
                title: Unprocessable Content
                status: 422
                detail: 1 field is invalid.
                code: validation_failed
                request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                errors:
                  - pointer: /patient/state_code
                    code: invalid_format
                    detail: Must be a two-letter USPS state code.
            invalid_path_parameter:
              summary: A malformed id in the path
              value:
                type: about:blank
                title: Unprocessable Content
                status: 422
                detail: 1 parameter is invalid.
                code: validation_failed
                request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                errors:
                  - pointer: path:enrollment_id
                    code: invalid_format
                    detail: Must be a UUID.
            idempotency_key_reused:
              summary: Key reused with a different body
              value:
                type: about:blank
                title: Unprocessable Content
                status: 422
                detail: >-
                  This Idempotency-Key was used with a different request body in
                  the last 24 hours.
                code: idempotency_key_reused
                request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    TooManyRequests:
      description: Rate limit or daily cap reached. Wait `Retry-After` seconds.
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Too Many Requests
            status: 429
            detail: Rate limit of 600 requests per minute reached.
            code: rate_limited
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
      example: 30
  securitySchemes:
    brandServer:
      type: http
      scheme: bearer
      description: >
        A brand's server, one OAuth client per brand and environment. Get a
        token with the

        `client_credentials` grant and send it as `Authorization: Bearer
        <token>`. Tokens last 1 hour.

        A brand server reads status and ids only; it never reads clinical
        content. A platform partner holds one

        client for all its brands and gets one token per brand, naming it with
        `tenant_id`.
      x-default: Bearer sandbox_any_token_works

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.