Skip to content
Docs menu

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.

HTTP
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 Accepted while 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.

Node example
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);
}
Python example
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:

HTTP
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" }
}