Adds Intercom webhook triggers — 51 topics spanning Conversation (14), Contact (17), Company (5), Ticket (13), plus event.created and visitor.signed_up. Narrow-audience families (admin lifecycle, articles, calls, content stats) and niche topics (async jobs, data connectors, granular subscription state) are deferred to a follow-up — see "Delta vs target catalog" in apps/intercom/WEBHOOK_TRIGGER_AUTHZ.md for what's out and why.
App-level webhooks: one URL configured per OAuth app in Intercom's Developer Hub serves every workspace install. Resource-universal at the OAuth-token layer — every selected scope grants workspace-wide reads with no per-resource ACL. Composio routes events to the matching connected_account by app_id (top-level on every notification envelope). authz_required = false everywhere — there's no per-event filter to apply because the token's view is uniform across the workspace.
Customer-facing security boundary (verbatim from apps/intercom/WEBHOOK_TRIGGER_AUTHZ.md):
Connecting Intercom to Composio grants workspace-wide read access for every selected scope, regardless of the connecting teammate's UI role. App-level webhooks fire for every workspace where the App is installed; Composio routes events by
app_idto the matching connected_account. Provider stops firing when the developer removes a required scope or a customer uninstalls — that is the implicit fail-closed signal.
Authz model verdict (resource-universal, app-level) is grounded verbatim in Intercom's developer docs:
Full Stage 1 model + UNVERIFIED items + new-infra flags live in apps/intercom/WEBHOOK_TRIGGER_AUTHZ.md (committed in this PR).
Setup-time scope verification is probe-style, not introspection. Intercom v2.12 has no token-introspection endpoint (no GET /me?scopes). setup() falls back to probing a scope-gated read endpoint per trigger family — /conversations for conversation triggers, /contacts for contact / company / visitor triggers, /ticket_types for ticket triggers. 200 means scope granted; 403 surfaces verbatim to the customer. Defined as constants in apps/intercom/triggers/_base.py.
Hook id is {app_id, event_type} and that's it. No resolved_config, no per-instance config fields. Apollo broadcasts every event to every trigger_instance whose hook_id matches. Resource-universal makes per-event filtering unnecessary.
Customer payload is the wire envelope minus constants. Stripped: type (always notification_event), topic (constant per slug), delivery_attempts, first_sent_at (provider retry counters). Kept: id, app_id, created_at, data.
data.item is dict, not per-trigger Pydantic models. This is the deliberate deviation from the team convention. Five concrete reasons:
data.item for company.created and ticket.created (verified across v1.0–v2.15 of the page). The other 49 topics say "see the Conversation resource" without specifying which fields are populated for that specific event vs nullable vs absent. Not enough to write a correct Pydantic model from docs alone.contact.user.created → full Contact resource (~55 fields); contact.lead.tag.created → small {type: "contact_tag", tag, contact, created_at} (NOT a Contact resource); conversation.deleted → partial Conversation with only id and a few markers. Per-topic typing has to be derived from real captures, not inferred from a sibling.ValidationError and the customer sees zero events. data: dict is permissive — customer always gets what Intercom sent.data.item is opaque. Customer gets typed id, app_id, created_at, data wrapper. The data field's Pydantic description points them at Intercom's REST resource docs and explicitly calls out the tag-event small-shape exception.Setup instructions reflect the real flow (INSTRUCTION_TEMPLATE in apps/intercom/triggers/_base.py): customer creates a webhook endpoint via POST /api/v3/webhook_endpoints to get the URL, pastes that URL into Intercom Developer Hub, then PATCHes their OAuth client_secret back to Composio's webhook endpoint with data.webhook_signing_secret. Intercom signs every notification with HMAC-SHA1 keyed on client_secret; Apollo verifies it on every event.
Optional new infra — uninstall POST callback receiver. Intercom posts {app_id} to a developer-configured URL on uninstall (not a webhook topic — a separate notification channel). Without consuming it, Composio CAs stay CONNECTED for revoked installs. No active leak — Intercom stops firing webhook events for revoked installs — but stale CA state. Either build a Composio admin endpoint that accepts the callback and marks the matching CA REVOKED, or ship as-is with the residual disclosed in customer docs. This PR ships without; decision deferred.
UNVERIFIED items (Intercom developer docs are silent — none operationally blocking, fully detailed in the in-PR WEBHOOK_TRIGGER_AUTHZ.md): re-auth idempotency; multi-admin token semantics; per-scope deselection at consent; re-consent semantics; scope-vs-role intersection; install permission gate (Owner-only / "Apps" permission / any teammate); whether scope removal invalidates the access token.
Full local stack running for the entire test window: mercury (make run-lambda on :8000) + apollo + thermos (pnpm dev on :9900 / :8180) + receiver Flask app behind a cloudflared tunnel + apollo behind a second cloudflared tunnel. Toolkit pushed to thermos via mercury.registry.push --push-bundles; apollo sync-toolkits ran; webhook_endpoint created with client_secret PATCHed in as the signing secret; staging connected_account cloned to local DB and verified ACTIVE.
All 51 trigger_instances created and active (every setup() probe succeeded → all 4 scope-family probe URLs verified working against the live workspace).
Every event flows: Intercom or signed-synthetic → apollo ingress → thermos (encrypts, queues) → Temporal worker → mercury (match() → _transform()) → apollo broadcast → cloudflare proxy (HMAC-signs, retries) → receiver capture.
Every captured payload validated against IntercomNotificationEnvelope Pydantic schema (4 required fields: id, app_id, created_at, data). Validation also asserts no leak of stripped fields (type, topic, delivery_attempts, first_sent_at).
Captures total: 78
Slugs captured: 51 of 51
Format-PASS: 51 of 51
Format-FAIL: 0
36 slugs driven from the live Intercom workspace via REST API (real Intercom traffic emitted by their event bus and delivered through the entire pipeline):
POST /eventsNotable provider quirks confirmed during driving (already documented in the relevant trigger docstrings): conversation auto-routing emits a combined conversation.admin.open.assigned topic instead of conversation.user.created; PUT priority returns 200 but emits no webhook (UI-only); ticket_state_id required as of v2.12 (legacy state field rejected); tag operations on leads/users require users: body shape (not contacts:); ticket.closed requires {"open": false} not state transition.
15 slugs driven via signed-synthetic injection through apollo's webhook_ingress endpoint for topics that require Intercom UI / messenger / JS SDK / Fin AI to fire from real provider traffic:
CONVERSATION_DELETED (REST endpoint deprecated in v2.12), CONVERSATION_PRIORITY_UPDATED (UI-only), CONVERSATION_UNSNOOZED (UI-only), CONVERSATION_RATING_ADDED (messenger-only end-user CSAT), CONVERSATION_OPERATOR_REPLIED (Fin AI required), CONTACT_SUBSCRIBED / UNSUBSCRIBED (messenger-only — REST subscription_types API now emits granular.* topics instead), TICKET_RATING_ADDED (messenger-only), VISITOR_SIGNED_UP (JS SDK only), TICKET_ASSIGNED_TO_TEAM (workspace has no teams)LEAD_ADDED_EMAIL, LEAD_CONVERTED_TO_USER (/contacts/{id}/convert 404 in v2.12), COMPANY_DELETED, TICKET_CONTACT_ATTACHED / DETACHEDSynthetic payloads are signed with the live webhook_signing_secret (HMAC-SHA1) and POSTed through the apollo cloudflared tunnel — they exercise the same code path real Intercom traffic would: signature verification → thermos queue → temporal worker → mercury match/transform → apollo broadcast → receiver. The data.item shapes match what Intercom emits, derived from sibling captures of the same resource type.
This is the only way to validate the pipeline contract for these 15 topics short of a real customer manually clicking through the UI; per-trigger *Payload Pydantic models (queued as follow-up) will be derived from real customer traffic accumulating post-merge.
Decrypted thermos trigger_messages.encrypted_trigger blobs in workerdb to confirm what topics Intercom actually emits to this workspace. Three additional topics correctly NOT subscribed by this PR were observed:
conversation.read — read-receiptconversation.admin.open.assigned — combined open+assign for auto-routing (already flagged as a quirk in NEW_CONVERSATION_USER's docstring)granular.subscribe / granular.unsubscribe — v2.12 subscription_types REST path; queued for follow-up trigger familyNo 5xx in apollo / thermos / mercury logs during the entire e2e window. Mercury required a bundle re-push (mercury.registry.push --push-bundles) for the runtime to pick up envelope changes — captures emitted before the push leaked topic/type; captures after pass cleanly.
match(), _transform(), resolve_event_identity() run programmatically against captured / synthesized payloads for every trigger. 51/51 PASS. ruff check --select I, ruff format --check, mypy on apps/intercom/triggers/ + tool.py all clean.
data.item shape inferred from siblings, not real fires. Highest-risk subset: LEAD_ADDED_EMAIL, LEAD_CONVERTED_TO_USER, COMPANY_DELETED (never fired even in extended REST testing), CONTACT_SUBSCRIBED / UNSUBSCRIBED (Intercom may have deprecated these in v2.12 in favor of granular.*). First real customer fire could surprise — recommend monitoring real captures for these slugs week 1 post-merge and diffing against the synthesized fixtures.data: dict is opaque. Per-trigger *Payload typing for the 4 high-traffic families (conversation, contact, ticket, company) is the right follow-up once real captures accumulate.CONNECTED for revoked installs (Intercom stops firing — no leak — but stale state). Recommend building this admin endpoint shortly post-merge.app_id — but untested).🤖 Generated with Claude Code