Skip to content

Start typing to search the documentation.

Intercom

AI-generated, awaiting review View as Markdown @flue/intercom

Quickstart

Add verified webhook ingress and application-owned API behavior to an existing Flue project with the Intercom blueprint. Run the following command in your terminal or coding agent of choice:

flue add channel intercom

Overview

The Intercom blueprint installs @flue/intercom and the official intercom-client SDK, creates a project-owned client factory at the source-root intercom-client.ts, and creates channels/intercom.ts. It also updates the selected agent to bind a conversation-retrieval tool to the verified workspace and conversation.

import { createIntercomChannel, type IntercomConversationRef } from '@flue/intercom';
import { dispatch } from '@flue/runtime';
import assistant from '../agents/assistant.ts';
import { createIntercomClient } from '../intercom-client.ts';

export const client = createIntercomClient(process.env.INTERCOM_ACCESS_TOKEN!, { region: 'us' });

export const channel = createIntercomChannel({
  clientSecret: process.env.INTERCOM_CLIENT_SECRET!,
  async webhook({ notification }) {
    if (notification.topic !== 'conversation.user.replied') return;
    const conversationId = conversationIdFromItem(notification.data.item);
    if (!conversationId) return;

    const conversation: IntercomConversationRef = {
      workspaceId: notification.app_id,
      conversationId,
    };
    await dispatch(assistant, {
      id: channel.conversationKey(conversation),
      input: {
        type: 'intercom.conversation.user.replied',
        notificationId: notification.id,
        conversation: notification.data.item,
      },
    });
  },
});

The abridged example shows one dispatched topic and omits the generated environment, region, conversation-id, and retrieval-tool helpers. The complete generated module dispatches conversation.user.created and conversation.user.replied; other verified topics reach the callback and remain subject to application policy. It pins the SDK to its typed API version, selects the configured region, and binds the retrieval tool in the agent module, so dispatched notifications reach a workspace-scoped agent instance that can retrieve the current conversation without exposing workspace ids or credentials to the model.

Configure

VariablePurpose
INTERCOM_CLIENT_SECRETRequired — Verifies inbound notifications.
INTERCOM_ACCESS_TOKENRequired — Authenticates outbound API calls.
INTERCOM_WORKSPACE_IDRequired — Restricts resource identity to one Intercom workspace.
INTERCOM_REGIONOptional — Selects us, eu, or au; defaults to us.

It installs @flue/intercom and the official intercom-client@7.0.3. The blueprint creates named channel and project-owned client exports.

Configure one URL in Intercom’s Developer Hub:

https://example.com/channels/intercom/webhook

Intercom validates that URL with HEAD and sends notifications to it with POST. The client secret and outbound access token are separate credentials.

Channel module

import {
  createIntercomChannel,
  type IntercomConversationRef,
  type JsonValue,
} from '@flue/intercom';
import { defineTool, dispatch } from '@flue/runtime';
import assistant from '../agents/assistant.ts';
import { createIntercomClient, type IntercomRegion } from '../intercom-client.ts';

const workspaceId = requiredEnv('INTERCOM_WORKSPACE_ID');

export const client = createIntercomClient(requiredEnv('INTERCOM_ACCESS_TOKEN'), {
  region: intercomRegion(),
});

export const channel = createIntercomChannel({
  clientSecret: requiredEnv('INTERCOM_CLIENT_SECRET'),

  // Path: /channels/intercom/webhook (HEAD, POST)
  async webhook({ notification }) {
    switch (notification.topic) {
      case 'conversation.user.created':
      case 'conversation.user.replied': {
        const conversationId = conversationIdFromItem(notification.data.item);
        if (!conversationId) return;

        const conversation: IntercomConversationRef = {
          workspaceId: notification.app_id,
          conversationId,
        };
        await dispatch(assistant, {
          id: channel.conversationKey(conversation),
          input: {
            type: `intercom.${notification.topic}`,
            notificationId: notification.id,
            createdAt: notification.created_at,
            deliveryAttempts: notification.delivery_attempts,
            conversation: notification.data.item,
          },
        });
        return;
      }
      default:
        return;
    }
  },
});

export function retrieveConversation(ref: IntercomConversationRef) {
  if (ref.workspaceId !== workspaceId) {
    throw new TypeError('Expected the configured Intercom workspace.');
  }
  return defineTool({
    name: 'retrieve_intercom_conversation',
    description: 'Retrieve the current Intercom conversation bound to this agent.',
    parameters: {
      type: 'object',
      properties: {},
      additionalProperties: false,
    },
    async execute() {
      const conversation = await client.conversations.find({
        conversation_id: ref.conversationId,
        display_as: 'plaintext',
      });
      return JSON.stringify(conversation);
    },
  });
}

function conversationIdFromItem(item: JsonValue): string | undefined {
  if (!item || typeof item !== 'object' || Array.isArray(item)) return undefined;
  return typeof item.id === 'string' && item.id.length > 0 ? item.id : undefined;
}

function intercomRegion(): IntercomRegion {
  const value = process.env.INTERCOM_REGION || 'us';
  if (value === 'us' || value === 'eu' || value === 'au') return value;
  throw new Error('INTERCOM_REGION must be us, eu, or au.');
}

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required.`);
  return value;
}

The example handles two conversation topics with one grouped switch branch. Intercom’s topic catalog is broad and API-versioned, so the channel keeps notification.topic open and represents notification.data.item as JSON. Validate the fields used by each selected topic. Verified ping and future topics reach the same callback.

The HMAC-verified body already carries app_id, so the channel does not re-check workspace identity. Resource ids are not globally unique across Intercom workspaces, so the example combines notification.app_id and the conversation id into a canonical key. An app that serves multiple workspaces filters on notification.app_id itself, or uses application-owned installation state to select credentials.

Official client

Keep the REST client in project code:

import { IntercomClient, IntercomEnvironment } from 'intercom-client';

export type IntercomRegion = 'us' | 'eu' | 'au';

export interface IntercomClientOptions {
  region?: IntercomRegion;
  fetch?: typeof globalThis.fetch;
  maxRetries?: number;
}

export function createIntercomClient(
  token: string,
  options: IntercomClientOptions = {},
): IntercomClient {
  if (!token) throw new TypeError('Intercom access token must be non-empty.');
  return new IntercomClient({
    token,
    version: '2.14',
    environment: environmentForRegion(options.region ?? 'us'),
    ...(options.fetch === undefined ? {} : { fetch: options.fetch }),
    ...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
  });
}

function environmentForRegion(
  region: IntercomRegion,
): (typeof IntercomEnvironment)[keyof typeof IntercomEnvironment] {
  switch (region) {
    case 'us':
      return IntercomEnvironment.UsProduction;
    case 'eu':
      return IntercomEnvironment.EuProduction;
    case 'au':
      return IntercomEnvironment.AuProduction;
  }
}

The SDK supports US, EU, and AU API environments. Select the region in trusted configuration instead of accepting an API host from a model or webhook field.

Pin version: '2.14'. intercom-client@7.0.3 generates its REST request and response types for API version 2.14. Newer webhook topic documentation does not make those generated REST types compatible with a manually forced 2.15 header. Use a narrow Fetch client for a genuinely 2.15-only operation until the official SDK supports it.

Bind the tool

import { createAgent } from '@flue/runtime';
import { channel, retrieveConversation } from '../channels/intercom.ts';

export default createAgent(({ id }) => {
  const conversation = channel.parseConversationKey(id);
  return {
    model: 'anthropic/claude-haiku-4-5',
    tools: [retrieveConversation(conversation)],
  };
});

The tool retrieves only the conversation already selected from a verified notification. It accepts no workspace, conversation id, token, or API host from the model.

channel.conversationKey() creates canonical workspace-scoped identity. Conversation keys remain identifiers, not authorization capabilities. Apply the project’s normal access control to direct agent routes, and verify the workspace again before selecting an installation token.

Endpoint validation and signatures

The discovered channel serves both:

HEAD /channels/intercom/webhook
POST /channels/intercom/webhook

The unsigned HEAD route returns an empty 200 for Intercom’s endpoint validation and never invokes application code.

Notifications require:

X-Hub-Signature: sha1=<40 hexadecimal characters>

Intercom computes HMAC-SHA1 over the exact request body using the developer app client secret. @flue/intercom retains and verifies those bytes before UTF-8 decoding or JSON parsing. A changed body, missing or malformed signature, or wrong secret is rejected before webhook runs.

The callback receives { c, notification }. The notification is Intercom’s own object, with its native field names and nesting:

  • topic and workspace-scoped app_id;
  • nullable id;
  • created_at, delivery_attempts, and first_sent_at;
  • provider-native JSON under data.item;
  • optional self;
  • any unmodeled top-level fields, forwarded unchanged.

The envelope is structurally checked, but item fields remain provider-native. Deletion, ticket, conversation-part, and future topics may have different shapes. Do not assume every conversation-related topic has data.item.id without validating that topic’s documented payload.

Intercom supplies no signed timestamp or protocol replay window. Signature verification authenticates delivery bytes but does not provide deduplication or freshness.

Responses and delivery

Returning nothing produces an empty 200. A JSON-compatible value becomes a JSON response with status 200. A normal Hono or Fetch Response passes through unchanged. A thrown callback surfaces to the framework error handler as 500.

Intercom acknowledges on any 2xx. Use 200 for ordinary acknowledgment. Return another status only when its provider behavior is intentional: 410 disables the subscription, while 429 throttles it. Ordinary failures are retried once after approximately one minute.

Intercom expects a 2xx within about five seconds and otherwise retries the notification once after one minute. The channel does not enforce this with a timer, because a promise timeout cannot cancel arbitrary JavaScript work. Admit durable work quickly — dispatch and return — and defer long-running processing beyond the acknowledgment path.

Notifications can be duplicated and arrive out of order. Use a non-null notification.id in application-owned durable storage when duplicate admission is unacceptable, and consider created_at when ordering matters. Setup or periodic pings may have a null id.

The package does not install an app, perform OAuth, select permissions, create subscriptions, store tokens, deduplicate notifications, persist conversations, or define outbound inbox policy.

Cloudflare Workers

The verifier uses Web Crypto. The official intercom-client@7.0.3 uses Fetch, has no runtime dependencies, and executes in workerd with Flue’s required nodejs_compat configuration. The example’s workerd test performs a real client.conversations.find() request through injected fake Fetch and confirms the expected EU URL, bearer token, Intercom-Version: 2.14, and workerd runtime header.

That execution proves the client operation shown here, not every SDK method. Test each additional operation used by the application against its actual Worker target. Cloudflare projects may use typed bindings instead of process.env; nodejs_compat is already part of Flue’s Worker configuration.

Create original synthetic notification bodies and local HMAC-SHA1 signatures. Exercise valid and tampered exact bytes, HEAD, ping, future topics, malformed JSON, body limits, handler results, a thrown callback surfacing as 500, and conversation-key round trips in Node and workerd.

For outbound tests, inject fail-closed Fetch into the actual official client, disable retries, assert the exact host, path, method, authorization, version, and region, and reject every unexpected destination. Do not register a webhook, perform OAuth, obtain a real token, or contact Intercom.

See the @flue/intercom API reference.