Skip to content

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/intercom

Public (no JWT). The backend:

  1. Verifies the X-Hub-Signature header: sha1=HMAC-SHA1(rawBody, INTERCOM_CLIENT_SECRET) (timing-safe). Bad/missing → 401.
  2. Returns 200 immediately (well under Intercom's 5s limit).
  3. Processes asynchronously: idempotency claim (processed_events) → topic routing → dispatch.

Setup (Intercom Developer Hub)

  1. Set the webhook URL to https://<your-host>/webhooks/intercom (HTTPS required — Intercom won't call plain HTTP or localhost).
  2. Use the app's Client Secret as INTERCOM_CLIENT_SECRET. It must be the same app whose INTERCOM_ACCESS_TOKEN you configured; a secret mismatch rejects every delivery with 401.
  3. 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/intercom

Use 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):

TopicAction
conversation.admin.repliedPush to the user + invalidate the cached conversation.
conversation.admin.closedRecord the conversation duration (admin panel) + post a duration note + invalidate cache.
conversation.admin.snoozed / .unsnoozed / .openedLifecycle/state sync (invalidate cache so the SDK's next poll sees open/snoozed).
ticket.admin.repliedPush to the user + invalidate the cached ticket.
ticket.state.updatedPush; if resolved, include a CSAT trigger; invalidate cache.
ticket.contact.repliedAuto-flip a resolved ticket back to in progress.
ticket.createdRefresh 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.

AW Chat SDK — internal integration docs.