Webhooks
Inbound webhooks are how external services reach Mesachat — Telegram delivers messages, Mailgun delivers email, Clerk delivers identity events.
Derived from backend/src/api/routes/webhooks.ts and backend/src/auth/clerk-webhook-handler.ts.
Webhooks are not under /api
Every webhook path is mounted at the server root, not beneath /api:
/webhooks/telegram/:botId
/webhooks/email/:botId
/webhooks/email
/webhooks/clerk
/webhooks/health
/api/webhooks/… is not a route.If you configured a webhook at https://<host>/api/webhooks/clerk, deliveries have been 404ing and
nothing has been syncing. The correct path is https://<host>/webhooks/clerk. Earlier versions of
this page — and of the internal setup guide — gave the /api prefix. They were wrong.
The origin can differ from your app origin. Deployments that keep the app on a private network
still have to accept deliveries from the public internet, so webhooks are commonly published on a
separate, public hostname. The value of WEBHOOK_BASE_URL is what Mesachat tells each platform to
call, and it is what those platforms will use.
Authentication
Webhook routes sit outside the /api authentication gate. They are not exempt from
verification — each handler verifies the sender itself, by a different mechanism per platform. The
three mechanisms do not behave alike, and the differences matter for production.
| Source | Mechanism | With no secret configured |
|---|---|---|
| Telegram | X-Telegram-Bot-Api-Secret-Token, compared in constant time | Rejected in production; accepted otherwise |
| Clerk | Svix signature over the raw body | Rejected — the handler throws |
| Mailgun (email) | HMAC over timestamp + token | ⚠️ Accepted — verification is skipped |
Rate limit
All /webhooks traffic is limited to 600 requests per client IP per minute, applied before
signature verification so forged or replayed deliveries cannot drive database lookups and bot
cold-starts. Exceeding it returns 429. The limiter is skipped when NODE_ENV=test.
Telegram
POST /webhooks/telegram/:botId
- Creating a Telegram integration registers this URL with Telegram's Bot API.
- Telegram POSTs updates to it.
- Mesachat routes the message to the bot's configured agents.
Securing it
Set webhookSecretToken under the integration's settings.telegram. Telegram then sends it in the
X-Telegram-Bot-Api-Secret-Token header on every delivery, and Mesachat compares it in constant
time.
In production, an integration with no secret token is rejected — verifyTelegramWebhook returns
false when no secret is configured and NODE_ENV=production. Outside production the unsigned path
stays open so you can test locally. So: a production bot without a secret token does not run
insecurely, it stops receiving messages.
Registration is automatic
| Event | Action |
|---|---|
| Integration created | Registers the webhook with Telegram |
| Integration updated | Re-registers if the URL changed |
| Integration deleted | Removes the webhook from Telegram |
Registration calls getWebhookInfo() before setWebhook() so an unchanged URL is not re-registered,
and retries with exponential backoff when Telegram answers 429.
Email
POST /webhooks/email/:botId # routed to a known bot
POST /webhooks/email # catch-all, bot resolved from the payload
Mailgun posts inbound mail as multipart/form-data. Uploads are capped at 25 MB per file, 10 files
per request.
Securing it — do not skip this
Set webhookSigningKey under the integration's settings.email.
accepted — in every environment, including production. The bypass is logged at SECURITY
severity (email.webhook.signature.BYPASSED), but it does not fail the request. Unlike the Telegram
path, this one does not fail closed.
An email integration deployed without settings.email.webhookSigningKey will act on forged inbound
mail. Set the key before you route real mail to it, and grep your logs for
email.webhook.signature.BYPASSED to confirm no integration is running without one.
When a key is present, Mesachat verifies Mailgun's timestamp + token + signature triple, and
does so before loading or starting the bot — so an unverified payload cannot trigger a
cold-start.
Clerk
POST /webhooks/clerk
Syncs identity from Clerk into Mesachat.
| Event | Action |
|---|---|
user.created · user.updated · user.deleted | Create, update, remove the Mesachat user |
organization.created · updated · deleted | Create, update, remove the org-to-tenant mapping |
organizationMembership.created · updated · deleted | Sync membership and roles |
Setup
- Clerk Dashboard → Webhooks → Add Endpoint.
- URL:
https://<your-webhook-host>/webhooks/clerk— no/apiprefix. - Subscribe to the nine events above.
- Copy the signing secret into
CLERK_WEBHOOK_SECRET.
Step 4 is not optional. With CLERK_WEBHOOK_SECRET unset the handler throws on every delivery, so
identity sync fails closed rather than accepting unverified events. Verification uses Svix over the
raw request body.
Health
GET /webhooks/health
Reports the webhook subsystem's status. Unauthenticated, like the rest of /webhooks.
Outbound webhooks
Mesachat does not send webhooks to your systems. Everything here is inbound.