Webhooks
ContactFollowUp receives webhooks (inbox + EHR) and is rolling out outbound delivery for lifecycle events.
Inbound webhooks
We accept HTTP POSTs from two upstream sources today: an email forwarder for the shared inbox, and EHR vendors for sync events. Both endpoints are unauthenticated at the cookie layer; auth is via a shared-secret HMAC on the raw request body.
Inbox webhook
Endpoint: POST /api/inbox/webhook. The forwarder sends a JSON payload with at minimum a from address and a subject. We accept the body, persist a message row, and return 201.
POST /api/inbox/webhook HTTP/1.1
Host: app.contactfollowup.com
Content-Type: application/json
X-ContactFollowUp-Webhook-Secret: <shared secret>
{
"from": "patient@example.com",
"subject": "Question about Tuesday's visit",
"text": "Hi — wanted to double-check my appointment time…"
}The X-ContactFollowUp-Webhook-Secret header must equal the value in your INBOX_WEBHOOK_SECRET env var. Comparison is constant-time. If the env var is unset, the endpoint will accept traffic unauthenticated — useful for local dev, not for production. Set the secret.
EHR webhooks
Endpoint: POST /api/ehr/webhooks/{ehr} — where {ehr} is the vendor key (drchrono, elationhealth,athena, etc.). The body is whatever shape the vendor sends; we verify their signature scheme (HMAC-SHA256 over the raw bytes), persist a receipt for the admin UI, and dispatch to the per-vendor handler.
- Unknown vendor →
404. - Vendor without an inbound webhook scheme (Athena today) →
501. - Bad signature →
401. A receipt is still recorded. - Valid signature →
202 Acceptedwhile we process.
Verifying signatures
Use the raw, undecoded request body for the HMAC — never the JSON-parsed object. Common gotchas: middleware that re-encodes JSON, or framework body parsers that strip whitespace.
import crypto from "node:crypto";
function verifyWebhook(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(signature, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hmac, hashlib
def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Outbound webhooks
The planned shape:
POST <your endpoint> HTTP/1.1
Content-Type: application/json
X-ContactFollowUp-Event: contact.created
X-ContactFollowUp-Delivery: 01HF2WC9Q4G2Z7X9V5K8YA8B3M
X-ContactFollowUp-Signature: t=1747570260,v1=ec3a7c…
{
"id": "01HF2WC9Q4G2Z7X9V5K8YA8B3M",
"event": "contact.created",
"createdAt": "2026-05-18T14:11:00Z",
"data": { "id": "cln…", "firstName": "Avery", "lastName": "Liu" }
}