Webhooks
Webhooks tell your system about changes as they happen: a specialist replies,
a case closes, a practice connects. Thea sends each event as a signed
POST to an HTTPS endpoint you register.
Set up an endpoint
Section titled “Set up an endpoint”-
Expose an HTTPS URL that accepts
POSTrequests with a JSON body. -
Register it with the events you want:
Terminal window curl -X POST "$THEA_API/webhook-endpoints" \-H "Authorization: Bearer $THEA_PARTNER_KEY" \-H "Content-Type: application/json" \-d '{"url": "https://hooks.acme-ehr.example/thea","events": ["case.message.created", "case.closed"],"description": "Production consult sync"}' -
Store the
secretfrom the response in your secrets manager. It’s returned only once. If you lose it, delete the endpoint and create a new one. -
Send a test event to check your signature verification:
Terminal window curl -X POST "$THEA_API/webhook-endpoints/17/test" \-H "Authorization: Bearer $THEA_PARTNER_KEY"The response reports whether your endpoint returned a 2xx status.
Webhook endpoints belong to your organization, so managing them needs only your partner key, and each endpoint receives events from every connected practice. Sandbox keys register sandbox endpoints, and live keys live endpoints. Each environment sends events only to its own endpoints.
Events
Section titled “Events”| Event | Sent when | data |
|---|---|---|
case.created |
A case is created in one of your practices, in Thea or through the API | case_id, external_ids, patient_id, specialty_ids |
case.message.created |
Anyone posts a message on a case | case_id, external_ids, message_id, sender_type |
case.closed |
A case is closed | case_id, external_ids, closed_at |
case.reopened |
A closed case is reopened | case_id, external_ids |
patient.created |
A patient is created | patient_id |
patient.updated |
A patient’s demographics change | patient_id |
provider.activated |
Someone on a practice’s roster becomes active, including an approved seat request |
provider_id, role, status |
provider.deactivated |
Someone on a practice’s roster becomes inactive |
provider_id, role, status |
provider.updated |
A roster member’s name, email, NPI, or role changes | provider_id, role, status |
practice.connected |
A practice connects to your organization | connected_at |
practice.disconnected |
A practice disconnects, and its practice key stops working | disconnected_at, disconnected_by |
You also get events for changes you made through the API, so you can drive all
updates from one place. Case events echo the external_ids you set, so you can
match them to your own records without a lookup.
Payload
Section titled “Payload”Every event has the same envelope:
{ "id": "50211", "type": "case.closed", "created_at": "2026-11-04T17:30:00Z", "practice_id": "152", "data": { "case_id": "a1f3c9e27b4d4e0f9c6b2d8e5f7a1c3b", "external_ids": { "referral_id": "acme-ref-5521" }, "closed_at": "2026-11-04T17:30:00Z" }}Payloads carry IDs and a few non-sensitive fields only, never clinical
content or demographics. Fetch the resource with the API, using the practice
key for the event’s practice_id, for full details. This
keeps patient data out of webhook logs and queues, and means you always act on
the latest state.
Each delivery has these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
Thea-Signature |
t=<unix seconds>,v1=<signature> |
Thea-Event-Id |
The event’s id |
Verify signatures
Section titled “Verify signatures”Anyone can send a request to your URL, so verify every delivery before
acting on it. The Thea-Signature header contains a timestamp t and a
signature v1:
Thea-Signature: t=1793806200,v1=5f3c1b0e9a7d4c2b8e6f0a1d3c5b7e9f2a4c6e8b0d1f3a5c7e9b2d4f6a8c0e1bv1 is the hex-encoded HMAC-SHA256 of the string <t>.<raw request body>,
keyed with your endpoint’s full secret (including the thea_whsec_ prefix).
To verify:
- Read the raw request body exactly as received. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
- Split the header on
,and taketand everyv1value. - Compute the HMAC-SHA256 of
t+.+ raw body with your secret. - Compare it with each
v1in constant time. Accept if any matches. - Reject the delivery if
tis more than 5 minutes from your current time, to block replayed requests.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
export function verifyTheaSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) { if (!header) return false
let timestamp const signatures = [] for (const part of header.split(',')) { const [key, value] = part.trim().split('=', 2) if (key === 't') timestamp = value if (key === 'v1' && value) signatures.push(value) } if (!/^\d+$/.test(timestamp ?? '') || signatures.length === 0) return false if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(rawBody) .digest()
return signatures.some((signature) => { const given = Buffer.from(signature, 'hex') return given.length === expected.length && crypto.timingSafeEqual(given, expected) })}With Express, use express.raw() on the webhook route so req.body is the raw Buffer:
import express from 'express'import { verifyTheaSignature } from './verifyTheaSignature.js'
const app = express()
app.post('/thea', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyTheaSignature(req.body, req.get('Thea-Signature'), process.env.THEA_WEBHOOK_SECRET) if (!ok) return res.sendStatus(400)
const event = JSON.parse(req.body.toString('utf8')) res.sendStatus(200) // acknowledge fast, then process queue.add(event) // your job queue})import hashlibimport hmacimport timefrom typing import Optional
TOLERANCE_SECONDS = 5 * 60
def verify_thea_signature(raw_body: bytes, header: Optional[str], secret: str, now: Optional[int] = None) -> bool: if not header: return False
timestamp, signatures = None, [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": timestamp = value elif key == "v1" and value: signatures.append(value) if not timestamp or not timestamp.isdigit() or not signatures: return False
now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)With Flask, request.get_data() returns the raw body:
import osfrom flask import Flask, abort, requestfrom verify_thea_signature import verify_thea_signature
app = Flask(__name__)
@app.post("/thea")def thea_webhook(): ok = verify_thea_signature( request.get_data(), request.headers.get("Thea-Signature"), os.environ["THEA_WEBHOOK_SECRET"] ) if not ok: abort(400)
event = request.get_json() queue.enqueue(event) # your job queue; acknowledge fast return "", 200Respond quickly
Section titled “Respond quickly”Return any 2xx status within 5 seconds. Do the real work afterwards, in a background job. Anything else (a non-2xx status, a timeout, a connection error) counts as a failure and is retried.
Retries
Section titled “Retries”Failed deliveries are retried with increasing delays: about 1 minute, 5 minutes, 30 minutes, 2 hours, then every 2 hours until 24 hours after the first attempt. After that the event is dropped.
If an endpoint fails every delivery for 24 hours, Thea marks it inactive
(active: false) and stops sending to it. Once it’s fixed, delete it and
create it again, then catch up on missed changes with an
updated_since sync.
Duplicates and ordering
Section titled “Duplicates and ordering”- Events can arrive more than once. A retry after a slow 2xx is a duplicate.
Store each event’s
idand skip ones you’ve processed. - Events can arrive out of order, especially after retries. Don’t assume
case.closedcomes after the lastcase.message.created. Because payloads are minimal, the safe pattern is to fetch the resource and use its current state.
Test events
Section titled “Test events”POST /webhook-endpoints/{id}/test sends a webhook.ping event, signed like any
other, with data: { "webhook_endpoint_id": "17" }. Test events aren’t retried.
Treat unknown event types, including webhook.ping, by returning 200 and
ignoring them.

