# Webhooks MicProtocol receives webhooks from six self-hosted integrations and one realtime SFU. Every webhook is verified, idempotent (via `WebhookIdempotencyService`), and side-effect-bearing. This doc is the entire HTTP webhook reference. For the high-level integration architecture, see [Third-Party & Self-Hosted Integrations](./third-party-integrations.md). For the service-client code that calls these integrations outbound, see [`apps/api/src/integrations/clients/self-hosted-clients.service.ts`](../apps/api/src/integrations/clients/self-hosted-clients.service.ts). ## Security Model Every webhook handler enforces three guarantees: 1. **HMAC timing-safe signature verification** — uses `crypto.timingSafeEqual()` so attackers cannot infer the secret via response timing. 2. **Idempotent processing** — `WebhookIdempotencyService.generateEventKey()` derives a deterministic key from the payload; duplicate deliveries within the TTL window return `{ received: true, duplicate: true }` and short-circuit side effects. 3. **Workspace-scoped side effects** — every state change writes to a record that ultimately chains back to a `Workspace` via `podcastId`, preserving tenant isolation. The verification helper lives in [`apps/api/src/integrations/integrations-webhook.controller.ts`](../apps/api/src/integrations/integrations-webhook.controller.ts): ```ts function timingSafeCompare(provided?: string, expected?: string): boolean { if (!provided || !expected) return false; const bufProvided = Buffer.from(provided); const bufExpected = Buffer.from(expected); if (bufProvided.length !== bufExpected.length) return false; return crypto.timingSafeEqual(bufProvided, bufExpected); } ``` If the matching `*_WEBHOOK_SECRET` env var is unset (empty string), verification is **skipped** with a logged warning — useful for first-boot testing but you must set every secret before production. ## Endpoint Catalog All webhooks are mounted under `POST /integrations/webhooks/` on the API (`:4000` by default). | Provider | Endpoint | Auth header | Secret env var | Source | |---|---|---|---|---| | Cal.com | `/integrations/webhooks/calcom` | `x-cal-secret-key` | `CALCOM_WEBHOOK_SECRET` | Cal.com Admin → Webhooks | | Chatwoot | `/integrations/webhooks/chatwoot/:accountId` | `x-chatwoot-signature` | `CHATWOOT_WEBHOOK_SECRET` | Chatwoot Settings → Webhooks | | Documenso | `/integrations/webhooks/documenso` | `x-documenso-signature` | `DOCUMENSO_WEBHOOK_SECRET` | Documenso Admin → Webhooks | | Formbricks | `/integrations/webhooks/formbricks` | `x-formbricks-signature` | `FORMBRICKS_WEBHOOK_SECRET` | Formbricks Admin → Webhooks | | Listmonk | `/integrations/webhooks/listmonk` | `x-listmonk-secret` | `LISTMONK_WEBHOOK_SECRET` | Listmonk Settings → Webhooks | | LiveKit | `/integrations/webhooks/livekit` | `Authorization` (JWT) | `LIVEKIT_API_SECRET` | LiveKit `webhook.urls` config | Configure each upstream service to `POST` to `https:///integrations/webhooks/` with the matching header. ## Cal.com ### `POST /integrations/webhooks/calcom` Auth: `x-cal-secret-key` header, verified against `CALCOM_WEBHOOK_SECRET`. Event types handled (event type is read from `body.triggerEvent || body.event`): | Event type | Side effect | |---|---| | `BOOKING_CREATED`, `BOOKING_CONFIRMED` | Sets matching `Booking.isConfirmed = true` | | `BOOKING_CANCELLED`, `BOOKING_REJECTED` | Sets matching `Booking.isConfirmed = false` | | `BOOKING_RESCHEDULED` | Updates `Booking.startTime` and `Booking.endTime` | | `BOOKING_NO_SHOW` | Logged; no schema change (Booking has no no-show field) | | `BOOKING_ATTENDEE_ADDED`, `BOOKING_ATTENDEE_REMOVED` | Logged; future: sync to episode guests | | Other | Debug-logged | Booking is resolved by matching the Cal.com UID against `Booking.calendarEventId` or a substring of `Booking.meetLink`. ## Chatwoot ### `POST /integrations/webhooks/chatwoot/:accountId` Auth: `x-chatwoot-signature` header, verified against `CHATWOOT_WEBHOOK_SECRET`. `:accountId` is the Chatwoot account ID (also configurable via `CHATWOOT_ACCOUNT_ID` env). Multiple accounts supported by routing through the path param. Event types handled (read from `body.event`): | Event | Side effect | |---|---| | `message_created` (incoming) | Logs conversation + contact IDs; calls `syncChatwootContact(contactId, accountId)` | | `conversation_created` | Logs new conversation ID | | `conversation_status_changed` | Logs status transition | | `contact_created`, `contact_updated` | Calls `syncChatwootContact(contactId, accountId)` | | Other | Debug-logged | ## Documenso ### `POST /integrations/webhooks/documenso` Auth: `x-documenso-signature` header, verified against `DOCUMENSO_WEBHOOK_SECRET`. Patient flow: Documenso fires `document_completed` or `document_declined` when all recipients finish. MicProtocol logs the transition keyed by `documentId`; future work will store signed-doc URLs on the relevant `Booking` or custom contract model. ## Formbricks ### `POST /integrations/webhooks/formbricks` Auth: `x-formbricks-signature` header, verified against `FORMBRICKS_WEBHOOK_SECRET`. Event type is read from `body.event || body.type`. If the response payload carries `data.userId` matching a `Guest.id`, MicProtocol writes a `GuestJourneyEvent`: - `eventType`: `follow_up` - `title`: `"Completed Survey"` - `details`: `"Completed survey "` - `metadata`: `body.data` This is what powers the "guest completed the post-interview NPS" timeline entry. ## Listmonk ### `POST /integrations/webhooks/listmonk` Auth: `x-listmonk-secret` header, verified against `LISTMONK_WEBHOOK_SECRET`. Confirmed in Listmonk Admin → Settings → Webhooks with the matching secret header (`x-listmonk-secret`). Event types handled (read from `body.event`): | Event | Side effect | |---|---| | `subscriber.created`, `subscriber.updated` | Calls `syncListmonkSubscriber(subscriber)` — upserts `Guest.customFields` with `{ listmonkSubscriberId, listmonkStatus, listmonkLists }` | | `subscriber.deleted` | Calls `unsubscribeListmonkSubscriber(email)` — stamps `Guest.customFields.listmonkUnsubscribedAt` | | `subscriber.subscribed`, `subscriber.unsubscribed` | Calls `handleListmonkSubscriptionChange(email, lists, event)` — logs list membership transitions | | Other | Debug-logged | ## LiveKit ### `POST /integrations/webhooks/livekit` Auth: `Authorization` JWT signed with `LIVEKIT_API_SECRET`. The handler checks for either a valid `Authorization` header or a `body.event` payload (defensive — some LiveKit versions send the event in the body without a JWT in dev). Event types handled (read from `body.event`): | Event | Side effect | |---|---| | `room_finished` | Sets matching `RecordingSession.status = "completed"` and `endedAt = now()`. Session is resolved by matching `body.room.name` against `RecordingSession.episodeId`. | | `participant_joined`, `participant_left` | Logs participant identity + room name | | Other | Debug-logged | This is the trigger that flips an episode from "recording" to "recorded" and unlocks the Content Studio domain (see [Domain Workflows](./workflows.md)). ## Securing Webhooks in Production 1. **TLS only** — terminate TLS at the API or a reverse proxy; never expose webhooks over plain HTTP. 2. **Set every `*_WEBHOOK_SECRET`** — empty values silently disable verification. Always generate with `openssl rand -hex 32` (see [`scripts/generate-secrets.sh`](../scripts/generate-secrets.sh)). 3. **Sync secrets on both sides** — when you rotate `FORMBRICKS_WEBHOOK_SECRET`, update both `.env` and the Formbricks Admin UI in the same change window. 4. **Restrict by source IP** — if your upstream integration publishes source IPs, add an IP allowlist at the network edge (nginx, Cloudflare, security group). 5. **Idempotency TTL** — `WebhookIdempotencyService` keys live in Redis; confirm your Redis eviction policy isn't aggressively evicting webhook keys during spike traffic (default `allkeys-lru` is safe for typo retries but not for long-delayed redeliveries). 6. **Monitor 401 rates** — a spike in `UnauthorizedException` from the webhook controller means a secret mismatch (rotated on one side only) or an attacker probing. See [Operations Runbook → Monitoring](./operations-runbook.md#monitoring). 7. **Log all events** — the controller logs every received event at `info` level. Aggregate these into your APM for a complete "what happened in my integrations" timeline.