Skip to content

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.

  1. Expose an HTTPS URL that accepts POST requests with a JSON body.

  2. 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"
    }'
  3. Store the secret from the response in your secrets manager. It’s returned only once. If you lose it, delete the endpoint and create a new one.

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

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.

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

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=5f3c1b0e9a7d4c2b8e6f0a1d3c5b7e9f2a4c6e8b0d1f3a5c7e9b2d4f6a8c0e1b

v1 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:

  1. Read the raw request body exactly as received. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
  2. Split the header on , and take t and every v1 value.
  3. Compute the HMAC-SHA256 of t + . + raw body with your secret.
  4. Compare it with each v1 in constant time. Accept if any matches.
  5. Reject the delivery if t is more than 5 minutes from your current time, to block replayed requests.
verifyTheaSignature.js
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:

server.js
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
})

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.

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.

  • Events can arrive more than once. A retry after a slow 2xx is a duplicate. Store each event’s id and skip ones you’ve processed.
  • Events can arrive out of order, especially after retries. Don’t assume case.closed comes after the last case.message.created. Because payloads are minimal, the safe pattern is to fetch the resource and use its current state.

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.