> ## 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 an access token

> **Client credentials** (brand servers and prescriber systems): authenticate with HTTP Basic
(`client_id:client_secret`) and send `grant_type=client_credentials`. The token lasts 1 hour and carries the
client's scopes, or the subset you ask for in `scope`.

**Token exchange** (patients): send the brand-signed assertion as `subject_token`. No client secret is needed,
so the brand's app can make this call itself with an assertion its server just signed. The result is a
15-minute patient token bound to the one enrollment the assertion names. There is no refresh token; sign a
new assertion when it expires. Patient tokens are issued for enrollments that are `pending_screening`,
`active` or `paused`.

An `integration` client cannot get tokens from the production API (`unauthorized_client`).

**Platform partners** that act for several brands name the brand with `tenant_id` on the client-credentials
request, and in a `tenant_id` claim in patient assertions. Every token is for one brand. See "Platform
partners" in the Tenants tag.

Error codes: `invalid_client`, `client_revoked`, `unauthorized_client`, `unsupported_grant_type`, `invalid_scope`,
`tenant_required`, `tenant_not_permitted`, `token_exchange_not_allowed`, `assertion_signature_invalid`,
`assertion_expired`, `assertion_lifetime_too_long`, `assertion_replayed`, `assertion_audience_invalid`,
`assertion_issuer_unknown`, `assertion_invalid`, `patient_not_found`, `enrollment_not_active`.




## OpenAPI

````yaml /openapi.json post /v1/oauth/token
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/oauth/token:
    post:
      tags:
        - Auth
      summary: Get an access token
      description: >
        **Client credentials** (brand servers and prescriber systems):
        authenticate with HTTP Basic

        (`client_id:client_secret`) and send `grant_type=client_credentials`.
        The token lasts 1 hour and carries the

        client's scopes, or the subset you ask for in `scope`.


        **Token exchange** (patients): send the brand-signed assertion as
        `subject_token`. No client secret is needed,

        so the brand's app can make this call itself with an assertion its
        server just signed. The result is a

        15-minute patient token bound to the one enrollment the assertion names.
        There is no refresh token; sign a

        new assertion when it expires. Patient tokens are issued for enrollments
        that are `pending_screening`,

        `active` or `paused`.


        An `integration` client cannot get tokens from the production API
        (`unauthorized_client`).


        **Platform partners** that act for several brands name the brand with
        `tenant_id` on the client-credentials

        request, and in a `tenant_id` claim in patient assertions. Every token
        is for one brand. See "Platform

        partners" in the Tenants tag.


        Error codes: `invalid_client`, `client_revoked`, `unauthorized_client`,
        `unsupported_grant_type`, `invalid_scope`,

        `tenant_required`, `tenant_not_permitted`, `token_exchange_not_allowed`,
        `assertion_signature_invalid`,

        `assertion_expired`, `assertion_lifetime_too_long`,
        `assertion_replayed`, `assertion_audience_invalid`,

        `assertion_issuer_unknown`, `assertion_invalid`, `patient_not_found`,
        `enrollment_not_active`.
      operationId: createToken
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/TokenRequest'
            examples:
              client_credentials:
                summary: Brand server
                value:
                  grant_type: client_credentials
                  scope: enrollments:read enrollments:write logs:write
              partner_client_credentials:
                summary: Platform partner, for one of its brands
                value:
                  grant_type: client_credentials
                  tenant_id: a0000000-0000-4000-8000-000000000002
                  scope: enrollments:read enrollments:write
              token_exchange:
                summary: Patient token from a brand assertion
                value:
                  grant_type: urn:ietf:params:oauth:grant-type:token-exchange
                  subject_token: >-
                    eyJhbGciOiJFUzI1NiIsImtpZCI6ImRlbW8tMjAyNi0xMCJ9.<claims>.<signature>
                  subject_token_type: urn:ietf:params:oauth:token-type:jwt
      responses:
        '200':
          description: Token issued.
          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
            Cache-Control:
              description: Always `no-store` (RFC 6749 section 5.1).
              schema:
                type: string
              example: no-store
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenResponse'
              examples:
                client_credentials:
                  summary: Brand server token
                  value:
                    access_token: trx_at_example_not_a_real_token
                    token_type: Bearer
                    expires_in: 3600
                    scope: enrollments:read enrollments:write logs:write
                token_exchange:
                  summary: Patient token
                  value:
                    access_token: trx_pt_example_not_a_real_token
                    token_type: Bearer
                    expires_in: 900
                    issued_token_type: urn:ietf:params:oauth:token-type:access_token
        '400':
          description: >-
            The grant is invalid. `error` follows RFC 6749; `code` says exactly
            why.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/OAuthProblem'
              examples:
                assertion_expired:
                  summary: The assertion's exp has passed
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: assertion_expired
                    detail: The assertion has expired. Sign a new one.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_grant
                    error_description: The assertion has expired.
                assertion_signature_invalid:
                  summary: No registered key verifies the signature
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: assertion_signature_invalid
                    detail: >-
                      The assertion signature does not match any key in the
                      brand's JWKS.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_grant
                    error_description: Invalid assertion signature.
                assertion_replayed:
                  summary: The jti was already used
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: assertion_replayed
                    detail: >-
                      This assertion's jti has already been used. Assertions are
                      single use.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_grant
                    error_description: Assertion already used.
                patient_not_found:
                  summary: No enrollment for this sub
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: patient_not_found
                    detail: >-
                      No open enrollment matches the assertion's subject for
                      this brand.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_grant
                    error_description: Unknown patient.
                tenant_required:
                  summary: A partner client did not name the brand
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: tenant_required
                    detail: >-
                      This client acts for several brands. Send tenant_id for
                      the brand this token is for.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_request
                    error_description: tenant_id is required.
                tenant_not_permitted:
                  summary: A brand this client may not act for
                  value:
                    type: about:blank
                    title: Bad Request
                    status: 400
                    code: tenant_not_permitted
                    detail: >-
                      This client cannot get tokens for that tenant_id. GET
                      /v1/tenants lists the brands it may act for.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_target
                    error_description: Tenant not permitted.
          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':
          description: Client authentication failed or the client is revoked.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/OAuthProblem'
              examples:
                client_revoked:
                  summary: Revoked client
                  value:
                    type: about:blank
                    title: Unauthorized
                    status: 401
                    code: client_revoked
                    detail: >-
                      This client has been revoked. Contact TeraRx for new
                      credentials.
                    request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
                    error: invalid_client
                    error_description: Client 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
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      security:
        - oauthClientBasic: []
        - {}
components:
  schemas:
    TokenRequest:
      description: One endpoint, two grants. `grant_type` picks the shape.
      oneOf:
        - $ref: '#/components/schemas/ClientCredentialsGrant'
        - $ref: '#/components/schemas/TokenExchangeGrant'
      discriminator:
        propertyName: grant_type
        mapping:
          client_credentials: '#/components/schemas/ClientCredentialsGrant'
          urn:ietf:params:oauth:grant-type:token-exchange: '#/components/schemas/TokenExchangeGrant'
    TokenResponse:
      type: object
      required:
        - access_token
        - token_type
        - expires_in
      properties:
        access_token:
          type: string
          description: Opaque to callers. Do not parse it.
          example: trx_at_example_not_a_real_token
        token_type:
          type: string
          const: Bearer
        expires_in:
          type: integer
          description: Seconds. 3600 for client tokens, 900 for patient tokens.
          example: 3600
        scope:
          type: string
          description: Space-separated scopes granted. Absent on patient tokens.
          example: enrollments:read enrollments:write logs:write
        issued_token_type:
          type: string
          const: urn:ietf:params:oauth:token-type:access_token
          description: Token exchange only (RFC 8693).
    OAuthProblem:
      description: >-
        A problem document that also carries the RFC 6749 `error` fields, so
        standard OAuth libraries can read it.
      allOf:
        - $ref: '#/components/schemas/Problem'
        - type: object
          required:
            - error
          properties:
            error:
              type: string
              enum:
                - invalid_request
                - invalid_client
                - invalid_grant
                - unauthorized_client
                - unsupported_grant_type
                - invalid_scope
                - invalid_target
              example: invalid_grant
            error_description:
              type: string
              example: The assertion has expired.
    ClientCredentialsGrant:
      type: object
      description: >-
        OAuth 2.0 client credentials (RFC 6749 section 4.4), for brand servers
        and prescriber systems.
      required:
        - grant_type
      properties:
        grant_type:
          type: string
          const: client_credentials
        scope:
          type: string
          description: >-
            Space-separated scopes. Omit to get every scope the client holds.
            Asking for one it does not hold fails with `invalid_scope`.
          example: enrollments:read enrollments:write logs:write
        tenant_id:
          type: string
          format: uuid
          description: >
            The brand this token is for. Required for a platform partner's
            client, which acts for several brands and

            gets one token per brand (`tenant_required` without it,
            `tenant_not_permitted` for a brand it may not act for).

            A brand's own client may omit it or send its own tenant id.
          example: a0000000-0000-4000-8000-000000000002
        client_id:
          type: string
          description: Only when not using HTTP Basic.
          example: demo_brand_integration
        client_secret:
          type: string
          description: Only when not using HTTP Basic.
          example: example-client-secret-not-real
    TokenExchangeGrant:
      type: object
      description: >
        OAuth 2.0 token exchange (RFC 8693). `subject_token` is a JWT the
        brand's server signed with its own private key

        (RFC 7523 profile). The API checks it against the brand's registered
        JWKS and returns a patient token.

        TeraRx never holds a key that can mint patient sessions. A platform
        partner's assertion names the brand in a

        `tenant_id` claim.
      required:
        - grant_type
        - subject_token
        - subject_token_type
      properties:
        grant_type:
          type: string
          const: urn:ietf:params:oauth:grant-type:token-exchange
        subject_token:
          type: string
          description: >-
            The signed patient assertion. See "Patient assertion" in this tag's
            description for the claims.
          example: >-
            eyJhbGciOiJFUzI1NiIsImtpZCI6ImRlbW8tMjAyNi0xMCJ9.<claims>.<signature>
        subject_token_type:
          type: string
          const: urn:ietf:params:oauth:token-type:jwt
        requested_token_type:
          type: string
          const: urn:ietf:params:oauth:token-type:access_token
          description: Optional. The only supported value.
    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'
    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:
    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
    ServiceUnavailable:
      description: >-
        A dependency (for example the AI model) is temporarily unavailable.
        Retry after `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: Service Unavailable
            status: 503
            detail: The AI Coach is temporarily unavailable. Try again shortly.
            code: ai_unavailable
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
      example: 30
  securitySchemes:
    oauthClientBasic:
      type: http
      scheme: basic
      description: >
        HTTP Basic with your `client_id` and `client_secret`, only on `POST
        /v1/oauth/token` for the

        `client_credentials` grant. You may send them as `client_id` and
        `client_secret` form fields instead.

````

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