> ## 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.

# Get today

> What the home screen needs for the patient's today, in their time zone: targets against what's logged so far,
the plan's meals and workouts for the day, habits, the next reminder, recent milestones, and counts of open
forms, unread care messages and legal documents to accept.


**Plans:** AI + health and fitness coaches (`ai_health_fitness`), AI only (`ai_only`).



## OpenAPI

````yaml /openapi.json get /v1/patient/today
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/patient/today:
    get:
      tags:
        - Progress
      summary: Get today
      description: >-
        What the home screen needs for the patient's today, in their time zone:
        targets against what's logged so far,

        the plan's meals and workouts for the day, habits, the next reminder,
        recent milestones, and counts of open

        forms, unread care messages and legal documents to accept.



        **Plans:** AI + health and fitness coaches (`ai_health_fitness`), AI
        only (`ai_only`).
      operationId: getMyToday
      responses:
        '200':
          description: Today.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Today'
              example:
                date: '2026-10-27'
                treatment_week: 6
                injection:
                  due_today: true
                  logged: false
                targets:
                  - metric: protein_g
                    label: Protein
                    unit: g
                    target: 110
                    progress: 72
                  - metric: water_ml
                    label: Water
                    unit: ml
                    target: 2500
                    progress: 1250
                meals:
                  - meal_plan_item_id: 3c1e8f20-5b7a-4d9c-8e61-0a2b4c6d8e01
                    meal_type: breakfast
                    title: Greek yogurt protein bowl
                    recipe_id: 2d4f6a8c-0e1b-4c3d-9e5f-7a9b1c3d5e7f
                    protein_g: 31
                    calories_kcal: 320
                    logged: true
                  - meal_plan_item_id: 5e3a0b42-7d9c-4f1e-8a83-2c4e6f8a0b13
                    meal_type: lunch
                    title: Chicken and quinoa bowl
                    recipe_id: null
                    protein_g: 38
                    calories_kcal: 450
                    logged: false
                workouts:
                  - workout_plan_item_id: 4d2f9a31-6c8b-4e0d-9f72-1b3c5d7e9f02
                    title: Full-body strength A
                    duration_min: 30
                    logged: false
                habits:
                  - protocol_id: 0190f5c6-8b9c-7d01-8e23-4f5a6b7c8d90
                    key: protein_each_meal
                    title: Protein at every meal
                    done: false
                next_reminder:
                  id: 0190f5c5-4a5b-7c61-8d78-9e0f1a2b3c45
                  kind: injection
                  ref_key: null
                  due_at: '2026-10-27T13:00:00Z'
                  status: scheduled
                  recurring: true
                open_forms: 1
                unread_care_messages: 2
                legal_documents_pending: 0
                new_milestones: []
          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
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      security:
        - patientToken: []
components:
  schemas:
    Today:
      type: object
      required:
        - date
        - treatment_week
        - injection
        - targets
        - meals
        - workouts
        - habits
        - next_reminder
        - open_forms
        - unread_care_messages
        - legal_documents_pending
        - new_milestones
      properties:
        date:
          $ref: '#/components/schemas/LocalDate'
        treatment_week:
          type:
            - integer
            - 'null'
          description: >-
            The same number as `GET /v1/patient/treatment`. Null before a
            treatment is set.
          example: 6
        injection:
          description: Null when today is not an injection day.
          oneOf:
            - type: object
              required:
                - due_today
                - logged
              properties:
                due_today:
                  type: boolean
                  example: true
                logged:
                  type: boolean
                  example: false
            - type: 'null'
        targets:
          type: array
          items:
            $ref: '#/components/schemas/MetricTarget'
        meals:
          type: array
          description: >-
            Today's meals from the meal plan, in plan order. Empty without a
            meal plan.
          items:
            $ref: '#/components/schemas/TodayMeal'
        workouts:
          type: array
          description: >-
            Today's workouts from the workout plan. Empty on a rest day or
            without a plan.
          items:
            $ref: '#/components/schemas/TodayWorkout'
        habits:
          type: array
          items:
            $ref: '#/components/schemas/TodayHabit'
        next_reminder:
          oneOf:
            - $ref: '#/components/schemas/Reminder'
            - type: 'null'
        open_forms:
          type: integer
          description: Forms waiting for the patient (`sent` or `in_progress`).
          example: 1
        unread_care_messages:
          type: integer
          description: Across care threads. Always 0 on `ai_only`.
          example: 2
        legal_documents_pending:
          type: integer
          example: 0
        new_milestones:
          type: array
          description: Milestones reached in the last 7 days, newest first.
          items:
            $ref: '#/components/schemas/Milestone'
    LocalDate:
      type: string
      format: date
      description: A calendar date in the patient's time zone.
      example: '2026-10-26'
    MetricTarget:
      type: object
      required:
        - metric
        - label
        - unit
        - target
        - progress
      properties:
        metric:
          type: string
          description: >-
            The protocol target's metric code, such as `protein_g`, `water_ml`,
            `fiber_g`, `steps` or `workout_minutes`.
          example: protein_g
        label:
          type: string
          example: Protein
        unit:
          type: string
          example: g
        target:
          type: number
          example: 110
        progress:
          type: number
          description: What the day's logs add up to so far. `0` when nothing is logged.
          example: 72
    TodayMeal:
      type: object
      required:
        - meal_plan_item_id
        - meal_type
        - title
        - logged
      properties:
        meal_plan_item_id:
          $ref: '#/components/schemas/Uuid'
          example: 3c1e8f20-5b7a-4d9c-8e61-0a2b4c6d8e01
        meal_type:
          $ref: '#/components/schemas/MealType'
        title:
          type: string
          example: Greek yogurt protein bowl
        recipe_id:
          type:
            - string
            - 'null'
          format: uuid
          example: 2d4f6a8c-0e1b-4c3d-9e5f-7a9b1c3d5e7f
        protein_g:
          type:
            - number
            - 'null'
          example: 31
        calories_kcal:
          type:
            - integer
            - 'null'
          example: 320
        logged:
          type: boolean
          description: True when a meal log links this plan meal today.
          example: true
    TodayWorkout:
      type: object
      required:
        - workout_plan_item_id
        - title
        - logged
      properties:
        workout_plan_item_id:
          $ref: '#/components/schemas/Uuid'
          example: 4d2f9a31-6c8b-4e0d-9f72-1b3c5d7e9f02
        title:
          type: string
          example: Full-body strength A
        duration_min:
          type:
            - integer
            - 'null'
          example: 30
        logged:
          type: boolean
          example: false
    TodayHabit:
      type: object
      required:
        - protocol_id
        - key
        - title
        - done
      properties:
        protocol_id:
          $ref: '#/components/schemas/Uuid'
          example: 0190f5c6-8b9c-7d01-8e23-4f5a6b7c8d90
        key:
          type: string
          description: >-
            Check it off with `PATCH /v1/patient/daily-logs/{log_date}` as
            `habits_done["<protocol_id>:<key>"]`.
          example: protein_each_meal
        title:
          type: string
          example: Protein at every meal
        done:
          type: boolean
          example: false
    Reminder:
      type: object
      required:
        - id
        - kind
        - due_at
        - status
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
          example: 0190f5c5-4a5b-7c61-8d78-9e0f1a2b3c45
        kind:
          type: string
          enum:
            - injection
            - check_in
            - log_nudge
            - habit
          example: injection
        ref_key:
          type:
            - string
            - 'null'
          description: The habit key or form assignment id the reminder is about.
          example: null
        due_at:
          $ref: '#/components/schemas/Timestamp'
        status:
          type: string
          enum:
            - scheduled
            - sent
            - cancelled
          example: scheduled
        recurring:
          type: boolean
          description: True when the next occurrence is scheduled after this one is sent.
          example: true
    Milestone:
      type: object
      required:
        - id
        - code
        - category
        - title
        - message
        - achieved_at
        - stats
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
          example: 0190f5e1-2a3b-7c4d-9e5f-6a7b8c9d0e1f
        code:
          $ref: '#/components/schemas/MilestoneCode'
        category:
          type: string
          enum:
            - logging
            - nutrition
            - activity
            - treatment
            - program
            - weight
          example: nutrition
        title:
          type: string
          description: A short headline in the patient's language.
          example: 7 days at your protein target
        message:
          type: string
          description: One personal line built from `stats`.
          example: You averaged 112 g a day, up from 74 g in your first week.
        achieved_at:
          $ref: '#/components/schemas/Timestamp'
        stats:
          type: object
          description: The patient's numbers behind the milestone. Keys depend on the code.
          additionalProperties:
            type: number
          example:
            days: 7
            avg_protein_g: 112
            first_week_avg_protein_g: 74
    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'
    Uuid:
      type: string
      format: uuid
      example: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
    MealType:
      type: string
      enum:
        - breakfast
        - lunch
        - dinner
        - snack
      example: lunch
    Timestamp:
      type: string
      format: date-time
      description: ISO 8601 in UTC.
      example: '2026-10-26T14:05:00Z'
    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
    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.
  responses:
    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
    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:
    patientToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >
        A 15-minute token bound to one patient and one enrollment, issued by
        `POST /v1/oauth/token` with the

        token-exchange grant. Treat it as opaque. When it expires, exchange a
        fresh assertion; there is no refresh token.
      x-default: Bearer sandbox_any_token_works

````

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