Intercom

@crvouga/mockingbird-service-intercom

Stateful emulator of the Intercom REST API 2.11: contacts (search, create with 409 on duplicates, update, get), conversations (create with Idempotency-Key, reply as JSON or multipart, close/open, get, cursor search), admins, and X-Hub-Signature-signed admin reply/close/open webhooks.

Parity: Contacts, conversations, and webhooksCommunication14 of 14 operationsIn-process fetchNode servermockingbird-intercom
$
npm install -D @crvouga/mockingbird-service-intercom

Playground

Requests go to the real emulator running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-intercom into this tab on first send
https://api.intercom.io

Send a request to see the emulator's response.

Operations

14 of 14 operations in the vendored contract are emulated. Trimmed from Intercom's published 2.11 API reference to the operations and fields our consumers use (intercom-messaging.adapter.ts, intercom-api.adapter.ts) and the webhook receivers (messaging.controller.ts, the EMR IntercomWebhookService).

MethodPathOperationStatus
POST/contacts/searchSearchContacts
GET/contactsListContacts
POST/contactsCreateContact
GET/contacts/{contact_id}GetContact
PUT/contacts/{contact_id}UpdateContact
GET/conversationsListConversations
POST/conversationsCreateConversationA contact-initiated conversation. An optional `Idempotency-Key` header replays.
POST/conversations/searchSearchConversations
GET/conversations/{conversation_id}GetConversation
PUT/conversations/{conversation_id}UpdateConversation
POST/conversations/{conversation_id}/replyReplyConversationReply as the contact (`type: user` + `intercom_user_id`) or as an admin (`type: admin` + `admin_id`), as JSON (attachments as base64 `att…
POST/conversations/{conversation_id}/partsManageConversationClose, open, snooze or assign; close and open fire `conversation.admin.closed` / `opened`.
GET/adminsListAdmins
GET/meGetMe

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Local emulators. Real API contracts. Part of Mockingbird.

Stateful emulator of the Intercom REST API (version 2.11) for test suites. It serves the calls our backend makes:

  • contacts: search, create (409 on duplicates, the way Intercom does), update and get;
  • conversations: create (optional Idempotency-Key), update, reply as the member or as an admin (JSON or multipart attachments), close/open/snooze/assign, get with ?display_as=plaintext, and cursor-paginated search;
  • identity: /admins and /me.

Admin replies and close/open (and, for a receiver that subscribes to them, member messages and replies) send Intercom's notification_event webhooks, signed with X-Hub-Signature: sha1=<hex HMAC-SHA1>, to both of our receivers: the backend's POST /messaging/webhook and the EMR's POST /v1/webhooks/intercom.

  • Operation coverage: SUPPORT.md
  • The contract (openapi.yaml) is trimmed from Intercom's published 2.11 reference to what our consumers use.

Install

Shell
npm install -D @crvouga/mockingbird-service-intercom

ESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with npx mockingbird-intercom serve, createServer from ./server (Node), or createRuntime with any Fetch server.

Usage

Shell
npx mockingbird-intercom serve --port 8807 \
  --webhook-url http://127.0.0.1:3000/messaging/webhook \
  --emr-webhook-url http://127.0.0.1:4000/v1/webhooks/intercom \
  --webhook-secret "$INTERCOM_WEBHOOK_SECRET"

Add --webhook-events conversation.user.created,conversation.user.replied,conversation.admin.replied,conversation.admin.closed,conversation.admin.opened to subscribe --webhook-url to the member topics too; --emr-webhook-url keeps the admin-only default.

Consumer Setting
Member messaging adapter (intercom-messaging.adapter.ts) INTERCOM_API_BASE_URL=http://127.0.0.1:8807 (already read), INTERCOM_ACCESS_TOKEN=<any>
Intercom sync adapter (intercom-api.adapter.ts) Seam G-I1: this adapter hardcodes https://api.intercom.io today, so it needs an env base URL. Its writes are also gated by FEATURE_INTERCOM_SYNC_ENABLED.
Both webhook receivers INTERCOM_WEBHOOK_SECRET = --webhook-secret
TypeScript
import { IntercomClient } from "intercom-client"
import { createServer } from "@crvouga/mockingbird-service-intercom/server"

const emulator = await createServer({
  webhooks: {
    urls: ["http://127.0.0.1:3000/messaging/webhook", "http://127.0.0.1:4000/v1/webhooks/intercom"],
    secret: "whsec-test",
  },
})

// The application's Intercom client. Only baseUrl changes.
const intercom = new IntercomClient({ token: "any-token", baseUrl: emulator.url })
const contact = await intercom.contacts.create({ role: "user", external_id: "1652", email: "ada@example.com" })
// The member opens a conversation through the app.
const message = await intercom.conversations.create({
  from: { type: "user", id: contact.id ?? "" },
  body: "Are my results in?",
})

// Test control. Support answers: appends an admin comment and fires the signed conversation.admin.replied.
await fetch(`${emulator.url}/__admin/conversations/${message.conversation_id}/admin-reply`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ adminId: "1000001", body: "Your results are in" }),
})

await emulator.close()

Routes

Every route needs Authorization: Bearer <token>. Any non-empty token is accepted unless tokens is set. Intercom-Version defaults to 2.11, and a malformed value answers 400 intercom_version_invalid. Every error uses Intercom's envelope: {type: "error.list", request_id, errors: [{code, message}]}.

Route Behaviour
GET /contacts Contact collection; per_page (default 10, max 150), starting_after cursor and page. Current state from the existing contact API.
GET /conversations Conversation collection; per_page (default 20, max 150) and starting_after cursor. Lists omit conversation parts; fetch a single conversation for parts.
POST /contacts/search {query, pagination?} → {type: "list", data, total_count, pages}. See "Search" below.
POST /contacts {role, external_id?, email?, name?, phone?, signed_up_at?, custom_attributes?} → the contact, whose id is 24 hex characters. A user needs an email or external_id. A second user with the same external_id, or the same email (case-insensitive), answers 409 conflict: "A contact matching those details already exists with id=…". The sync adapter then searches and PUTs. Leads never conflict.
PUT /contacts/{id} Partial update. custom_attributes are merged. 404 User Not Found; 409 if the new identifiers collide.
GET /contacts/{id} The contact (name, email, external_id, …), or 404.
POST /conversations {from: {type: "user", id}, body} → {type: "user_message", id, created_at, body, message_type: "inapp", conversation_id}. The contact must exist (404 otherwise). An Idempotency-Key header replays the stored response for the same body. A different body with the same key is a 409.
PUT /conversations/{id} {read?, title?, custom_attributes?} (for example {custom_attributes: {chatbot_escalation: true}} or {read: true}) → the conversation.
POST /conversations/{id}/reply JSON {message_type: "comment", type: "user", intercom_user_id | user_id | email, body, attachment_files?: [{content_type, name, data (base64)}], attachment_urls?}, or {type: "admin", admin_id, message_type: "comment" | "note" | "quick_reply", body}. Or multipart, with the same fields plus attachment_files[] file parts. Returns the conversation.
POST /conversations/{id}/parts {message_type: "close" | "open" | "snoozed" | "assignment", type: "admin", admin_id, snoozed_until?, assignee_id?} → the conversation.
GET /conversations/{id}?display_as=plaintext id, state, open, read, created_at, source{id, body, author{type, id, name, email}, attachments, delivered_as}, contacts.contacts[0].external_id, conversation_parts.conversation_parts[] (every part type, including close/open/note; our adapter keeps comment), teammates, custom_attributes.
POST /conversations/search {query, pagination: {per_page ≤ 150, starting_after}, sort_field, sort_order} → {type: "conversation.list", conversations, total_count, pages: {page, per_page, total_pages, next?: {page, starting_after}}}. Like the real API, results carry no conversation_parts.
GET /admins {type: "admin.list", admins: [{id, name, email, …}]}. The seeded admins are 1000001 (support@mock.intercom.local) and 1000002 (clinician@mock.intercom.local).
GET /me The token's admin and app.

Conversation state follows Intercom:

  • Admin comments set read: false, meaning unread for the member.
  • Member replies set read: true, set waiting_since, and reopen a closed or snoozed conversation.
  • PUT {read: true} marks the conversation read.
  • Notes change nothing the member sees.
  • Bodies are stored as HTML. Plain text is wrapped in <p>, one paragraph per line, and display_as=plaintext turns it back into text.

Attachments keep their metadata only: name, content type, size, and a downloads.intercomcdn.com URL that is not served. The uploaded bytes are never stored.

A query is either a filter {field, operator, value} or a compound {operator: "AND" | "OR", value: [query, …]}, nested up to two levels.

  • Operators: =, !=, IN, NIN (these two take an array), <, >, ~ (contains), !~, ^ (starts with) and $ (ends with). String comparisons are case-insensitive.
  • Contact fields: id, external_id, email, name, phone, role, the timestamps, and custom_attributes.<name>.
  • Conversation fields: id, contact_ids, teammate_ids, admin_assignee_id, team_assignee_id, state, open, read, priority, title, the timestamps, and source.{id,type,delivered_as,subject,body,author.id,author.type,author.name,author.email}.
  • Unknown fields or operators answer 400 parameter_invalid.
  • Sorting: updated_at descending by default, with the id as tiebreaker.
  • Cursors: opaque base64, 20 per page by default.

Webhooks

The emulator sends webhooks for these events:

Event Topic
Admin comment or quick reply (API or POST /__admin/conversations/:id/admin-reply) conversation.admin.replied
Close (/parts or POST /__admin/conversations/:id/close) conversation.admin.closed
Open (/parts or POST /__admin/conversations/:id/open) conversation.admin.opened
Admin-initiated conversation (POST /__admin/conversations) conversation.admin.single.created
Member starts a conversation (POST /conversations) conversation.user.created (opt-in)
Member reply (/reply with type: "user") conversation.user.replied (opt-in)

An endpoint receives the four admin topics above unless it lists its own events. The member topics, conversation.admin.snoozed and .assigned are published but opt-in: subscribe with --webhook-events, webhooks.urls: [{url, events}] or PUT /__admin/webhook-endpoints. The EMR treats every notification_event it receives as an admin event, so its subscription must not include member topics. Notes send nothing.

Each delivery is a JSON body posted with X-Hub-Signature: sha1=<hex HMAC-SHA1(secret, rawBody)>:

JSON
{ "type": "notification_event", "app_id": "mockapp", "topic": "conversation.admin.replied",
  "id": "notif_…", "created_at": 1760000000, "delivery_status": "pending", "delivery_attempts": 1,
  "data": { "type": "notification_event_data", "item": { "type": "conversation", "id": "…",
    "source": { "delivered_as": "customer_initiated", "author": { "email": "…" }, "body": "…" },
    "contacts": { "contacts": [{ "id": "…", "external_id": "1652" }] },
    "conversation_parts": { "conversation_parts": [ { "id": "…", "part_type": "comment", "body": "<p>…</p>", "created_at": 1760000000 } ] } } } }
  • Only the new part. item.conversation_parts holds just the part that caused the event, as in Intercom's own payloads.
  • Wall-clock timestamps. The part's timestamps and the envelope's created_at use real time, never the emulator clock, because the EMR rejects parts more than 5 minutes old against its own clock.
  • Stable id. The notification id stays the same on retries and on webhook_duplicate, so the backend's delivery_id ?? id dedupe and the EMR's processed-marker dedupe both hold.
  • Retries. A non-2xx answer is retried after 5 s, 5 min, 30 min and 2 h.
  • Admin plane. GET /__admin/webhooks, …/events, …/flush, …/:id/replay and PUT /__admin/webhook-endpoints are the standard webhook admin routes.

Admin (beyond the standard contract)

Route Effect
POST /__admin/conversations/:id/admin-reply {adminId? | admin_id? (default: first admin), body, messageType?: "comment" | "note"} appends an admin part ("the care team replied") and fires the webhook.
GET /__admin/outbox?since=&to=&limit=, GET /__admin/outbox/:id What the client sent through POST /conversations and /reply, oldest first: {id, operation, conversationId, contactId, partId, partType ("source" for the opening message), authorType, authorId, hasBody, bodyLength, attachmentCount, at, createdAt}. Metadata only: the body is never stored. to= matches the conversation or contact id; since= takes epoch ms or ISO-8601 on the emulator clock. The admin plane's own replies are not listed.
POST /__admin/conversations/:id/close, …/open {adminId?} closes or reopens as an admin and fires the webhook.
POST /__admin/conversations {contactId | externalId, adminId?, body} creates an admin-initiated conversation. The EMR then looks the member up by contacts[0].external_id.
GET /__admin/conversations, GET /__admin/contacts The namespace's records.
PUT /__admin/admins Replace the admins: [{id, name, email}].
GET/PUT /__admin/settings {tokens?: string[], customAttributes?: string[] | null}. With customAttributes set, writing an undefined attribute is a 400, as in a real workspace.

Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}):

Preset Effect
rate_limited Every call answers 429 rate_limit_exceeded. The adapter maps it to 429.
unauthorized Every call answers 401.
server_error Every call answers 500. The adapter throws.
service_unavailable Every call answers 503. The adapter throws.
contact_stale_404 The next conversation search answers 404. The adapter drops its cached contact id, re-resolves it and retries once.
search_unavailable Search answers conversations: null. The admin inbox answers 503.
repeated_cursor Search keeps answering the same next.starting_after. The admin inbox detects the loop and answers 503.
webhook_duplicate Deliver the next webhook twice.
webhook_reorder Swap the next two webhooks.
webhook_drop Never deliver the next webhook.

Namespaces

  • the x-mockingbird-namespace header;
  • a /__admin/ns/<name> prefix on INTERCOM_API_BASE_URL;
  • the access token: PUT /__admin/credentials {"credentials": {"<INTERCOM_ACCESS_TOKEN>": "<namespace>"}}.

The request journal records operation ids, statuses and contact, conversation and part ids. It never records message bodies or contact details.

Deliberately not modelled

  • The member-app Messenger SDK and web widget (@intercom/intercom-react-native, @intercom/messenger-js-sdk). They talk to Intercom's own hosts over their own protocol. The web widget has no test-stage gate, so QA should block it.
  • Attachment downloads: URLs are recorded but not served.
  • Tags, notes, companies, segments, tickets, articles, data-attribute management, teams and SLAs. These appear only as empty lists where a payload has them.
  • Rate-limit headers on normal responses. Only rate_limited sends them.
  • Intercom's exact wording for the idempotency conflict, which is unverified: the emulator answers 409 conflict.

API

Export Kind Description
IntercomAPI class The in-process emulator: fetch(request), reset(), appendPart(…), manage(…), startAdminConversation(…), conversations(), contacts(), conversationBody(…), contactBody(…), error(…), state. Options: sqlite, now, wallClock, namespace, admins, settings, onWebhook.
IntercomError class A vendor error (status, code, message) raised by the admin-facing methods.
createRuntime function The emulator with the full service contract (health, admin, namespaces, credentials, presets, webhooks). Options: webhooks: {urls, secret?, events?, retryDelaysMs?, fetch?} (a urls entry may be {url, events} to override events for that URL), admins, settings, clock, wallClock, seed, adminKey, onLog, sqlite.
INTERCOM_PRESETS object Every named fault preset.
INTERCOM_NAMESPACE string The service name, "intercom".
INTERCOM_TOPICS array Every webhook topic the emulator can send, including the opt-in member topics.
HUB_SIGNATURE_HEADER, signHub values X-Hub-Signature and the sha1=<hex> signer, for your own receivers.
DEFAULT_ADMINS, DEFAULT_SETTINGS values The seeded admins and default settings.
toHtml, toPlaintext, encodeCursor, decodeCursor functions Body rendering and the search cursor format.
document, operationIds, supportedOperationIds values The vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server) Node Serve over node:http; the serve CLI target (--webhook-url, --webhook-events, --emr-webhook-url, --webhook-secret, --access-token); port 8807.

Part of mockingbird.

↑↓ to navigate↵ to open