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

# Webhooks

> Event envelope, signature verification, retries and secret rotation.

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 theme={null}
{
  "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 theme={null}
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 theme={null}
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.


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