Intercom Local Dev Loop
Overview
Set up a fast local development workflow for Intercom integrations with proper test isolation, mocking strategies, and webhook tunneling.
Prerequisites
- Completed
intercom-install-authsetup - Node.js 18+ with npm/pnpm
- A test/development Intercom workspace (separate from production)
Instructions
Step 1: Project Structure
my-intercom-app/
├── src/
│ ├── intercom/
│ │ ├── client.ts # Singleton client
│ │ ├── contacts.ts # Contact operations
│ │ ├── conversations.ts # Conversation operations
│ │ └── types.ts # Intercom type extensions
│ └── index.ts
├── tests/
│ ├── mocks/
│ │ └── intercom.ts # Mock client factory
│ ├── contacts.test.ts
│ └── conversations.test.ts
├── .env.development # Dev workspace token
├── .env.test # Test config (mocked)
├── .env.example # Template
└── package.json
Step 2: Environment Configuration
# .env.example (commit this)
INTERCOM_ACCESS_TOKEN=
INTERCOM_WEBHOOK_SECRET=
NODE_ENV=development
# .env.development (git-ignored, real dev workspace token)
INTERCOM_ACCESS_TOKEN=dG9rOmRldl90b2tlbl9oZXJl
INTERCOM_WEBHOOK_SECRET=your-webhook-secret
NODE_ENV=development
Step 3: Client Singleton with Environment Awareness
// src/intercom/client.ts
import { IntercomClient } from "intercom-client";
let instance: IntercomClient | null = null;
export function getClient(): IntercomClient {
if (!instance) {
const token = process.env.INTERCOM_ACCESS_TOKEN;
if (!token) {
throw new Error(
"INTERCOM_ACCESS_TOKEN not set. Copy .env.example to .env.development"
);
}
instance = new IntercomClient({ token });
}
return instance;
}
// Reset for testing
export function resetClient(): void {
instance = null;
}
Step 4: Mock Client for Tests
// tests/mocks/intercom.ts
import { vi } from "vitest";
export function createMockClient() {
return {
contacts: {
create: vi.fn().mockResolvedValue({
type: "contact",
id: "mock-contact-id",
role: "user",
email: "test@example.com",
name: "Test User",
external_id: "ext-123",
custom_attributes: {},
created_at: 1711100000,
updated_at: 1711100000,
}),
find: vi.fn().mockResolvedValue({
type: "contact",
id: "mock-contact-id",
email: "test@example.com",
}),
search: vi.fn().mockResolvedValue({
type: "list",
data: [],
total_count: 0,
pages: { type: "pages", page: 1, per_page: 50, total_pages: 0 },
}),
list: vi.fn().mockResolvedValue({
type: "list",
data: [],
total_count: 0,
pages: { next: null },
}),
update: vi.fn(),
delete: vi.fn(),
tag: vi.fn(),
untag: vi.fn(),
},
conversations: {
create: vi.fn().mockResolvedValue({
type: "conversation",
id: "mock-convo-id",
state: "open",
}),
find: vi.fn(),
list: vi.fn().mockResolvedValue({
type: "conversation.list",
conversations: [],
pages: { next: null },
}),
reply: vi.fn(),
close: vi.fn(),
assign: vi.fn(),
},
messages: {
create: vi.fn().mockResolvedValue({
type: "user_message",
id: "mock-msg-id",
}),
},
admins: {
list: vi.fn().mockResolvedValue({
type: "admin.list",
admins: [{ id: "admin-1", name: "Test Admin", email: "admin@test.com" }],
}),
},
tags: {
create: vi.fn().mockResolvedValue({ type: "tag", id: "tag-1", name: "test" }),
list: vi.fn().mockResolvedValue({ type: "list", data: [] }),
},
};
}
Step 5: Write Tests
// tests/contacts.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createMockClient } from "./mocks/intercom";
describe("Contact Operations", () => {
let mockClient: ReturnType<typeof createMockClient>;
beforeEach(() => {
mockClient = createMockClient();
});
it("should create a user contact", async () => {
const contact = await mockClient.contacts.create({
role: "user",
externalId: "user-123",
email: "test@example.com",
});
expect(contact.id).toBe("mock-contact-id");
expect(contact.role).toBe("user");
expect(mockClient.contacts.create).toHaveBeenCalledWith({
role: "user",
externalId: "user-123",
email: "test@example.com",
});
});
it("should search contacts by email", async () => {
await mockClient.contacts.search({
query: { field: "email", operator: "=", value: "test@example.com" },
});
expect(mockClient.contacts.search).toHaveBeenCalledOnce();
});
});
Step 6: Webhook Testing with ngrok
# Install ngrok
npm install -g ngrok
# Start your local server
npm run dev # Starts on port 3000
# Tunnel to expose locally
ngrok http 3000
# Use the HTTPS URL (e.g., https://abc123.ngrok.io) as your webhook URL
# in Intercom Developer Hub > Webhooks
Step 7: Package Scripts
{
"scripts": {
"dev": "tsx watch src/index.ts",
"test": "vitest",
"test:watch": "vitest --watch",
"test:integration": "INTERCOM_ACCESS_TOKEN=$INTERCOM_DEV_TOKEN vitest --config vitest.integration.config.ts",
"typecheck": "tsc --noEmit"
}
}
Integration Test Pattern
// tests/integration/contacts.integration.test.ts
import { describe, it, expect } from "vitest";
import { IntercomClient } from "intercom-client";
const client = new IntercomClient({
token: process.env.INTERCOM_ACCESS_TOKEN!,
});
describe.skipIf(!process.env.INTERCOM_ACCESS_TOKEN)("Contacts Integration", () => {
it("should create and retrieve a contact", async () => {
const created = await client.contacts.create({
role: "lead",
name: `Integration Test ${Date.now()}`,
});
expect(created.id).toBeDefined();
// Clean up
await client.contacts.delete({ contactId: created.id });
});
});
Error Handling
| Error | Cause | Solution |
|-------|-------|----------|
| INTERCOM_ACCESS_TOKEN not set | Missing .env file | Copy .env.example to .env.development |
| Port 3000 in use | Another process | lsof -i :3000 and kill, or change port |
| ngrok tunnel expired | Free tier 2h limit | Restart ngrok or use paid plan |
| Mock type mismatch | SDK updated | Regenerate mocks from SDK types |
| rate_limit_exceeded in dev | Dev workspace limits | Add delays between integration tests |
Resources
Next Steps
See intercom-sdk-patterns for production-ready code patterns.