Appearance
Webhooks
Intercom webhooks tell the backend about operator activity (replies, closes, state changes). They drive three things: push notifications (while the user is offline), read-cache invalidation (so the next poll reflects the change fast), and conversation-duration recording (for the admin panel). When the app is open, polling still handles live updates; webhooks make them snappier.
Endpoint
POST /webhooks/intercomPublic (no JWT). The backend:
- Verifies the
X-Hub-Signatureheader:sha1=HMAC-SHA1(rawBody, INTERCOM_CLIENT_SECRET)(timing-safe). Bad/missing →401. - Returns 200 immediately (well under Intercom's 5s limit).
- Processes asynchronously: idempotency claim (
processed_events) → topic routing → dispatch.
Setup (Intercom Developer Hub)
- Set the webhook URL to
https://<your-host>/webhooks/intercom(HTTPS required — Intercom won't call plain HTTP orlocalhost). - Use the app's Client Secret as
INTERCOM_CLIENT_SECRET. It must be the same app whoseINTERCOM_ACCESS_TOKENyou configured; a secret mismatch rejects every delivery with401. - Subscribe to the topics below.
Testing locally (no deploy)
Since Intercom can't reach localhost, expose the running backend with a tunnel and use that public URL as the endpoint:
bash
ngrok http 41100 # → https://xxxx.ngrok-free.app
# or: cloudflared tunnel --url http://localhost:41100
# endpoint → https://xxxx.ngrok-free.app/webhooks/intercomUse a throwaway tunnel only for testing. The tunnel URL changes each restart — update the endpoint in the Developer Hub when it does.
Topics handled
Subscribe to exactly these in the Developer Hub (the handler ignores the rest):
| Topic | Action |
|---|---|
conversation.admin.replied | Push to the user + invalidate the cached conversation. |
conversation.admin.closed | Record the conversation duration (admin panel) + post a duration note + invalidate cache. |
conversation.admin.snoozed / .unsnoozed / .opened | Lifecycle/state sync (invalidate cache so the SDK's next poll sees open/snoozed). |
ticket.admin.replied | Push to the user + invalidate the cached ticket. |
ticket.state.updated | Push; if resolved, include a CSAT trigger; invalidate cache. |
ticket.contact.replied | Auto-flip a resolved ticket back to in progress. |
ticket.created | Refresh the ticket list (invalidate cache). |
Unknown topics are accepted and ignored (return 200).
Idempotency
Each event's id is claimed atomically in processed_events; duplicate deliveries are skipped. Rows older than 7 days are pruned by an in-process daily job.
Notification routing
For a routed event the backend looks up the user's devices and notification preferences, then sends via FCM (mobile) and/or Telegram (TMA). A user with no registered devices, or with that category disabled, simply receives nothing.
Conversation durations
On conversation.admin.closed the backend computes how long the conversation was open, stores it in the conversation_durations table, and leaves a Conversation duration: <Xh Ym> note in Intercom. The admin panel surfaces it as a Длительность column on the CSAT and complaints tables.
Verify it works
After saving the webhook, close a conversation in the Intercom inbox. Within a second or two you should see the duration note appear on that conversation, and the duration show up in the admin panel. If nothing happens, the signature check is most likely failing — confirm INTERCOM_CLIENT_SECRET equals the Client Secret of the same app the webhook belongs to.