MicProtocol / docs /webhooks.md
Benaah's picture
Upload folder using huggingface_hub
b17baae verified
|
Raw History Blame Contribute Delete
8.53 kB
# 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.