@crvouga/mockingbird-service-intercom

中文文档
3.11.0发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 1.6万

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 adm

原始使用文档
文档目录9 节
  1. Install
  2. Usage
  3. Routes
  4. Search
  5. Webhooks
  6. Admin (beyond the standard contract)
  7. Namespaces
  8. Deliberately not modelled
  9. API

@crvouga/mockingbird-service-intercom

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

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

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.

ConsumerSetting
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 receiversINTERCOM_WEBHOOK_SECRET = --webhook-secret
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: "[email protected]" })
// 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}]}.

RouteBehaviour
GET /contactsContact collection; per_page (default 10, max 150), starting_after cursor and page. Current state from the existing contact API.
GET /conversationsConversation 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}/replyJSON {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=plaintextid, 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 ([email protected]) and 1000002 ([email protected]).
GET /meThe 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:

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

{ "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)

RouteEffect
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/:idWhat 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/contactsThe namespace's records.
PUT /__admin/adminsReplace 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}):

PresetEffect
rate_limitedEvery call answers 429 rate_limit_exceeded. The adapter maps it to 429.
unauthorizedEvery call answers 401.
server_errorEvery call answers 500. The adapter throws.
service_unavailableEvery call answers 503. The adapter throws.
contact_stale_404The next conversation search answers 404. The adapter drops its cached contact id, re-resolves it and retries once.
search_unavailableSearch answers conversations: null. The admin inbox answers 503.
repeated_cursorSearch keeps answering the same next.starting_after. The admin inbox detects the loop and answers 503.
webhook_duplicateDeliver the next webhook twice.
webhook_reorderSwap the next two webhooks.
webhook_dropNever 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

ExportKindDescription
IntercomAPIclassThe in-process emulator: fetch(request), reset(), appendPart(…), manage(…), startAdminConversation(…), conversations(), contacts(), conversationBody(…), contactBody(…), error(…), state. Options: sqlite, now, wallClock, namespace, admins, settings, onWebhook.
IntercomErrorclassA vendor error (status, code, message) raised by the admin-facing methods.
createRuntimefunctionThe 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_PRESETSobjectEvery named fault preset.
INTERCOM_NAMESPACEstringThe service name, "intercom".
INTERCOM_TOPICSarrayEvery webhook topic the emulator can send, including the opt-in member topics.
HUB_SIGNATURE_HEADER, signHubvaluesX-Hub-Signature and the sha1=<hex> signer, for your own receivers.
DEFAULT_ADMINS, DEFAULT_SETTINGSvaluesThe seeded admins and default settings.
toHtml, toPlaintext, encodeCursor, decodeCursorfunctionsBody rendering and the search cursor format.
document, operationIds, supportedOperationIdsvaluesThe vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server)NodeServe over node:http; the serve CLI target (--webhook-url, --webhook-events, --emr-webhook-url, --webhook-secret, --access-token); port 8807.

Part of mockingbird.

版本与发布记录

@crvouga/mockingbird-service-intercom 共发布 43 个版本,首次发布于 2026/09/23;过去 12 个月发布了 43 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
3.11.0latest2026/11/11今天
3.10.92026/11/038 天前
3.10.82026/11/038 天前
3.10.72026/11/038 天前
3.10.62026/11/038 天前
3.10.52026/11/038 天前

关于 @crvouga/mockingbird-service-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 adm

如何安装 @crvouga/mockingbird-service-intercom?

在项目目录运行 npm install @crvouga/mockingbird-service-intercom,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-intercom,可以用 npx mockingbird-intercom 直接运行。

@crvouga/mockingbird-service-intercom 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-intercom 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-intercom 可以商用吗?

@crvouga/mockingbird-service-intercom 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-intercom 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-intercom 在 2026/10/11 – 2026/11/09 这 30 天内被下载 16,264 次,日均约 542 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-intercom 还在维护吗?

最新版本 3.11.0 发布于 2026/11/11(今天);至今共发布 43 个版本,其中过去 12 个月发布了 43 个;首次发布于 2026/09/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。