Spaces:
Running
Running
File size: 8,531 Bytes
b17baae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | # 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/<provider>` 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://<your-api-host>/integrations/webhooks/<provider>` 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 <surveyId>"`
- `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.
|