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.instanceId(conversation),
// Recorded once when this event creates the instance; ignored after.
initialData: {
workspaceId: conversation.workspaceId,
conversationId: conversation.conversationId,
},
message: {
kind: 'signal',
type: 'intercom.conversation.user.replied',
// The conversation item is Intercom's own message payload; it has no
// single flat text field, so it travels as the body verbatim.
body: JSON.stringify(notification.data.item),
attributes: {
...(notification.id === null ? {} : { notificationId: notification.id }),
createdAt: String(notification.created_at),
deliveryAttempts: String(notification.delivery_attempts),
},
},
});
},
});
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.
Mount the channel
A channel serves HTTP routes only where app.ts mounts it. Mount the module’s named channel export:
import { channel as intercom } from './channels/intercom.ts';
app.route('/channels/intercom', intercom.route());
channel.route() is a pure router factory serving the channel’s declared routes relative to the mount path. The webhook paths in this guide assume the conventional /channels/intercom mount; a different mount path shifts them accordingly. The dispatch-target agent module carries the 'use agent' directive — the directive registers it, so a dispatch-only agent needs no HTTP mount of its own.
Configure
| Variable | Purpose |
|---|---|
INTERCOM_CLIENT_SECRET |
Required — Verifies inbound notifications. |
INTERCOM_ACCESS_TOKEN |
Required — Authenticates outbound API calls. |
INTERCOM_WORKSPACE_ID |
Required — Restricts resource identity to one Intercom workspace. |
INTERCOM_REGION |
Optional — 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.instanceId(conversation),
// Recorded once when this event creates the instance; ignored after.
initialData: {
workspaceId: conversation.workspaceId,
conversationId: conversation.conversationId,
},
message: {
kind: 'signal',
type: `intercom.${notification.topic}`,
// The conversation item is Intercom's own message payload; it has no
// single flat text field, so it travels as the body verbatim.
body: JSON.stringify(notification.data.item),
attributes: {
...(notification.id === null ? {} : { notificationId: notification.id }),
createdAt: String(notification.created_at),
deliveryAttempts: String(notification.delivery_attempts),
},
},
});
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.',
async run() {
const conversation = await client.conversations.find({
conversation_id: ref.conversationId,
display_as: 'plaintext',
});
return 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 an instance id. An app that serves multiple workspaces
filters on notification.app_id itself, or uses application-owned
installation state to select credentials.
initialData is the instance’s creation data: recorded once when the event creates
the instance and ignored afterward, so the channel passes it on every
dispatch. It carries the workspace and conversation identifiers the tool
needs — the agent reads them with useInitialData() instead of parsing the
instance id.
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
'use agent';
import { useInitialData, useModel, useTool } from '@flue/runtime';
import * as v from 'valibot';
import { retrieveConversation } from '../channels/intercom.ts';
const initialData = v.object({
workspaceId: v.string(),
conversationId: v.string(),
});
export function Assistant() {
useModel('anthropic/claude-haiku-4-5');
const data = useInitialData<v.InferOutput<typeof initialData>>();
if (!data) throw new Error('This agent is created by the Intercom channel dispatch.');
useTool(retrieveConversation(data));
return 'Help with the inbound Intercom conversation. Retrieve the current conversation when more context is needed.';
}
Assistant.initialData = initialData;
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.
The instance id is an identifier, not an authorization capability. 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:
topicand workspace-scopedapp_id;- nullable
id; created_at,delivery_attempts, andfirst_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 instance-id 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 README.