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

# Create a data request

> A patient's request, made through you, to export, delete or amend their coaching records. Requests are
processed in the background; `data_request.completed` fires when done.
Error codes: `data_request_in_progress` (409) for a second open request of the same kind, `validation_failed` (422).


**Scope:** `data_requests:write`.



## OpenAPI

````yaml /openapi.json post /v1/data-requests
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/data-requests:
    post:
      tags:
        - Data requests
      summary: Create a data request
      description: >-
        A patient's request, made through you, to export, delete or amend their
        coaching records. Requests are

        processed in the background; `data_request.completed` fires when done.

        Error codes: `data_request_in_progress` (409) for a second open request
        of the same kind, `validation_failed` (422).



        **Scope:** `data_requests:write`.
      operationId: createDataRequest
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DataRequestCreate'
            examples:
              export:
                summary: Export
                value:
                  enrollment_id: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
                  kind: export
              amend:
                summary: Amendment
                value:
                  enrollment_id: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
                  kind: amend
                  details:
                    record_type: symptom_log
                    record_id: 0190f5c9-7f80-7192-8cb4-3d4e5f607182
                    description: The severity was logged as 5 by mistake.
                    requested_change: Severity should be 2.
      responses:
        '201':
          description: Received.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataRequest'
              example:
                id: 7a2e4c61-0b3d-4f58-9a17-2c4e6f8a0b13
                enrollment_id: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
                external_patient_id: brand-pt-48213
                kind: export
                status: received
                requested_at: '2026-10-26T14:05:00Z'
                completed_at: null
                export: null
                amendment: null
          headers:
            X-Request-Id:
              description: >-
                Unique id of this request. Echoes a valid `X-Request-Id` you
                sent, otherwise generated. Also in every problem body.
              schema:
                type: string
              example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
            RateLimit-Limit:
              description: >-
                Requests allowed per minute for this client (600 by default, set
                per contract).
              schema:
                type: integer
              example: 600
            RateLimit-Remaining:
              description: Requests left in the current window.
              schema:
                type: integer
              example: 587
            RateLimit-Reset:
              description: Seconds until the window resets.
              schema:
                type: integer
              example: 42
            Idempotent-Replayed:
              description: >-
                `true` when this response is a replay of an earlier request with
                the same `Idempotency-Key`.
              schema:
                type: boolean
              example: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
      security:
        - brandServer:
            - data_requests:write
components:
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: >
        A unique key per logical request, for example a UUID. Retrying with the
        same key and the same body

        within 24 hours returns the first response (with `Idempotent-Replayed:
        true`) instead of creating a duplicate.

        The same key with a different body is refused with `422
        idempotency_key_reused`.
      schema:
        type: string
        minLength: 8
        maxLength: 255
        x-default: 7d0c7f4e-1a8b-4c55-9d3e-2f6a1b0c9e84
      example: 7d0c7f4e-1a8b-4c55-9d3e-2f6a1b0c9e84
  schemas:
    DataRequestCreate:
      oneOf:
        - $ref: '#/components/schemas/ExportOrDeleteRequest'
        - $ref: '#/components/schemas/AmendRequest'
      discriminator:
        propertyName: kind
        mapping:
          export: '#/components/schemas/ExportOrDeleteRequest'
          delete: '#/components/schemas/ExportOrDeleteRequest'
          amend: '#/components/schemas/AmendRequest'
    DataRequest:
      type: object
      required:
        - id
        - enrollment_id
        - external_patient_id
        - kind
        - status
        - requested_at
      properties:
        id:
          $ref: '#/components/schemas/Uuid'
          example: 7a2e4c61-0b3d-4f58-9a17-2c4e6f8a0b13
        enrollment_id:
          $ref: '#/components/schemas/Uuid'
        external_patient_id:
          $ref: '#/components/schemas/ExternalId'
        kind:
          $ref: '#/components/schemas/DataRequestKind'
        status:
          $ref: '#/components/schemas/DataRequestStatus'
        requested_at:
          $ref: '#/components/schemas/Timestamp'
        completed_at:
          type:
            - string
            - 'null'
          format: date-time
          example: null
        export:
          description: Export requests, once completed.
          oneOf:
            - type: object
              required:
                - available
                - expires_at
              properties:
                available:
                  type: boolean
                  example: true
                expires_at:
                  $ref: '#/components/schemas/Timestamp'
            - type: 'null'
        amendment:
          description: Amend requests only.
          oneOf:
            - type: object
              required:
                - details
                - respond_by
              properties:
                details:
                  $ref: '#/components/schemas/AmendmentDetails'
                respond_by:
                  $ref: '#/components/schemas/Timestamp'
                  description: 60 days after `requested_at`.
                decision:
                  type:
                    - string
                    - 'null'
                  enum:
                    - accepted
                    - denied
                    - null
                  example: null
                decision_note:
                  type:
                    - string
                    - 'null'
                  example: null
                decided_at:
                  type:
                    - string
                    - 'null'
                  format: date-time
                  example: null
            - type: 'null'
    ExportOrDeleteRequest:
      type: object
      required:
        - enrollment_id
        - kind
      properties:
        enrollment_id:
          $ref: '#/components/schemas/Uuid'
        kind:
          type: string
          enum:
            - export
            - delete
          example: export
    AmendRequest:
      type: object
      required:
        - enrollment_id
        - kind
        - details
      properties:
        enrollment_id:
          $ref: '#/components/schemas/Uuid'
        kind:
          type: string
          const: amend
        details:
          $ref: '#/components/schemas/AmendmentDetails'
    Uuid:
      type: string
      format: uuid
      example: 0190f5c2-3a1b-7c4d-8e9f-a0b1c2d3e4f5
    ExternalId:
      type: string
      minLength: 1
      maxLength: 128
      pattern: ^[\x21-\x7E]+$
      description: Your own identifier, printable ASCII without spaces.
      example: brand-pt-48213
    DataRequestKind:
      type: string
      enum:
        - export
        - delete
        - amend
      description: >
        * `export`: a copy of the patient's coaching records for your brand, as
        a download.

        * `delete`: removes the patient's coaching records for your brand,
        except what the law requires TeraRx to keep (the audit log).

        * `amend`: the HIPAA right to amend. You decide within 60 days of
        `requested_at`.
      example: export
    DataRequestStatus:
      type: string
      enum:
        - received
        - processing
        - completed
        - rejected
      example: processing
    Timestamp:
      type: string
      format: date-time
      description: ISO 8601 in UTC.
      example: '2026-10-26T14:05:00Z'
    AmendmentDetails:
      type: object
      required:
        - record_type
        - description
        - requested_change
      properties:
        record_type:
          type: string
          enum:
            - profile
            - meal_log
            - workout_log
            - symptom_log
            - injection_log
            - daily_log
            - protocol
            - message
          example: symptom_log
        record_id:
          type: string
          description: >-
            The record, when the patient can point to one. A date for
            `daily_log`.
          example: 0190f5c9-7f80-7192-8cb4-3d4e5f607182
        description:
          type: string
          maxLength: 2000
          example: The severity was logged as 5 by mistake.
        requested_change:
          type: string
          maxLength: 2000
          example: Severity should be 2.
    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:
    BadRequest:
      description: >-
        The request is malformed (unparseable JSON, wrong content type, unknown
        query parameter).
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Bad Request
            status: 400
            detail: The request body is not valid JSON.
            code: malformed_request
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    Unauthorized:
      description: The access token is missing, expired or revoked.
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
        WWW-Authenticate:
          description: The scheme to authenticate with.
          schema:
            type: string
          example: Bearer error="invalid_token"
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Unauthorized
            status: 401
            detail: The access token has expired. Request a new one.
            code: token_expired
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
    Forbidden:
      description: >-
        The token is valid but lacks the scope, or the resource is not available
        on the patient's plan.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Forbidden
            status: 403
            detail: This operation needs the enrollments:write scope.
            code: insufficient_scope
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    Conflict:
      description: >-
        The request conflicts with the resource's current state, or a request
        with the same `Idempotency-Key` is still running.
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/Problem'
          example:
            type: about:blank
            title: Conflict
            status: 409
            detail: The enrollment is cancelled and cannot be paused.
            code: invalid_status_transition
            request_id: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
      headers:
        X-Request-Id:
          description: >-
            Unique id of this request. Echoes a valid `X-Request-Id` you sent,
            otherwise generated. Also in every problem body.
          schema:
            type: string
          example: req_019a3b2c-4d5e-7f60-8a1b-2c3d4e5f6a7b
        RateLimit-Limit:
          description: >-
            Requests allowed per minute for this client (600 by default, set per
            contract).
          schema:
            type: integer
          example: 600
        RateLimit-Remaining:
          description: Requests left in the current window.
          schema:
            type: integer
          example: 587
        RateLimit-Reset:
          description: Seconds until the window resets.
          schema:
            type: integer
          example: 42
    UnprocessableEntity:
      description: >
        A path, query or header value, or the body, fails validation (for
        example an id that isn't a UUID, or a

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

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

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

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

````

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