Skip to Content
Internal docs are powered by Nextra Docs Theme.
Incidents2026Intercom team-assignment sync drift

Intercom team-assignment sync drift (May 2026)

Summary

Kuda reported that conversations assigned to the Outbound team in Intercom were showing up under Inbound in Rulebase, and that the daily Inbound/Outbound counts didn’t line up between the two systems.

For May 5, 2026 (Kuda local time):

SourceInboundOutboundTotal
Intercom — conversations9096271536
Intercom — tickets9095151424
Rulebase — conversations10495911640

Rulebase had more Inbound and fewer Outbound conversations than Intercom — i.e. some Outbound conversations in Intercom were sitting in Rulebase as Inbound.

The asymmetry was caused by two stacked bugs in the Intercom sync pipeline. Both are fixed in #6254 .

Root cause

Two issues in rulebase-api compounded:

1. Missing webhook subscriptions for team assignment

IntercomWebhookEvent::CONVERSATION_EVENTS only subscribed to:

  • conversation.user.created
  • conversation.admin.single.created
  • conversation.user.replied
  • conversation.admin.replied
  • conversation.admin.closed

It did not subscribe to conversation.admin.assigned (the explicit “team assigned” topic) or to its open-conversation variant. The ticket subscription was similarly missing ticket.team.assigned and ticket.admin.assigned.

So when an admin in Intercom manually moved a conversation from Inbound to Outbound, no webhook reached Rulebase. The next sync that happened to fire (e.g. via conversation.admin.closed) would refresh group_id, but only if a closed event was published — for many conversations it never was.

2. Source-type filter dropped re-syncs of admin_initiated conversations

Conversation::Import::Intercom#import short-circuited via allowed_intercom_conversation? whenever the conversation’s source.type wasn’t in the chat/email allow list (%w[conversation facebook instagram sms twitter whatsapp email]).

Most of Kuda’s outbound conversations have source.type = "phone_call" or other non-allow-listed types. They got into Rulebase via the ticket import path (Conversation::Import::IntercomTicket), which doesn’t apply that filter. But every time a conversation webhook fired for one of them, the import was dropped before Conversation::Sync::Intercom#run ran — so sync_team_assignee never got a chance to update group_id.

The two bugs together:

  • The webhook that would have refreshed the team (conversation.admin.assigned) wasn’t subscribed to.
  • The webhooks that were subscribed to (conversation.admin.closed etc.) were dropped by the source-type filter for exactly the conversations that needed them.

The group_id ended up frozen at whatever value was set the first time a sync happened to slip through — usually the bot’s assign_and_reopen to Inbound, captured before the admin reassigned to Outbound.

Methodology

We narrowed this down in three passes.

1. Confirm the discrepancy from raw Intercom + Rulebase data

  • Pulled the Inbound/Outbound team IDs (7250653 and 7483657) directly from the Intercom API via a small Python script.
  • Counted tickets and conversations created in the last day for each team using the Intercom search API, against multiple timezone interpretations to rule out boundary issues.
  • Cross-checked Rulebase counts via rulebase-metabase and a Rails console query on prod against the conversations table for organization.slug = "kuda-pilot", scoped on started_at for that day.

The two systems disagreed on totals and the per-team split was asymmetric, which ruled out a “Rulebase is just running behind” explanation.

2. Break down by source.type

Wrote a Python script (source_types.py) that paged through Intercom’s conversation search for the day and grouped results by source.type. We saw:

Inbound : all=908 allowed=0 excluded=908 Outbound: all=627 allowed=112 excluded=515

i.e. essentially all of Inbound’s conversations and most of Outbound’s were admin_initiated. We then sampled 40 of those external_ids and queried Rulebase via Metabase — every one of them was present in our DB.

That ruled out “we aren’t importing these conversations” and pointed at “we are importing them, but their group_id is stale”.

3. Walk individual conversation histories

For 4 conversations that Intercom showed as Outbound but Rulebase showed as Inbound, we pulled the full Intercom conversation history via /conversations/:id and printed the conversation_parts timeline. Every one followed the same pattern:

  1. t₀: admin creates the conversation (source.type = admin_initiated), self-assigns, then assigns it to Outbound and closes.
  2. t₀ + ~minutes: a bot performs assign_and_reopen and reassigns to Inbound. (Rulebase appears to capture group=Inbound here.)
  3. t₀ + minutes-to-hours: an admin reassigns back to Outbound and re-closes.

Step 3 is the one Rulebase never saw, because the only relevant webhook (conversation.admin.closed for the second close, or conversation.admin.assigned for the reassignment) was either dropped by the source-type filter or never subscribed to.

This pattern, plus a code read of Conversation::Import::Intercom, Conversation::Import::IntercomTicket, Conversation::Sync::Intercom, and IntercomWebhookEvent, gave us the two-bug root cause.

Fix

PR: #6254 

Code changes

  • IntercomWebhookEvent: subscribe to the previously-missing topics so manual reassignments and other admin actions actually reach us.
    • Conversation: conversation.admin.assigned, conversation.admin.open.assigned, conversation.admin.opened, conversation.admin.snoozed, conversation.admin.unsnoozed.
    • Ticket: ticket.admin.assigned, ticket.team.assigned, ticket.note.created, ticket.attribute.updated, ticket.contact.attached, ticket.contact.detached, ticket.closed.
  • Conversation::Import::Intercom#import: dropped the allowed_intercom_conversation? short-circuit completely. The import now runs for both first-time and re-sync cases regardless of source.type. The sync flow’s per-feature filters (e.g. sync_conversation_source only creating a conversation_part for chat/email source bodies) still apply, so we don’t try to materialize bodies that don’t exist for phone_call/api/etc.
  • ImportHistoricalConversationsTask::Import::Intercom: dropped the source.type IN ALLOWED_SOURCE_TYPES clause from the search query so backfills also pull all conversations, matching the live path.

Operational follow-ups

  • After the next Intercom sync cycle, spot-check a few of the previously-mismatched Kuda conversations and confirm group_id updates.
  • Watch the Inbound/Outbound counts in the Rulebase dashboard against Intercom for the next few days to confirm the daily totals converge.
  • The new webhook subscriptions need to be enabled on the Intercom app config in the Intercom dashboard (in addition to the code change). Confirm the topic list there matches IntercomWebhookEvent::EVENTS.

What we deliberately didn’t change

  • Conversation::Sync::Intercom#sync_conversation_source still filters on ALLOWED_SOURCE_TYPES. This is correct: it only governs whether we create a conversation_part to represent the source body, and phone_call etc. don’t have a body to attach. Importing the conversation record itself is now decoupled from this.
  • Conversation::Sync::Intercom::ALLOWED_SOURCE_TYPES itself is kept; only the import-time gating is removed.

Verification

After the PR was prepared, we re-sampled to confirm the fix actually addresses real mismatches. Two passes:

Pass 1: 20 most recent Outbound conversations

Pulled the 20 most recently-created conversations Intercom currently shows as Outbound and looked them up in Rulebase.

  • 17/20 had a single team-assignment event (assigned at creation, never changed).
  • All 20 matched in Rulebase (group_id = 266/Outbound).

This is expected: the bug only triggers on the multi-step admin → bot → admin reassignment pattern. Single-assignment conversations are already captured correctly on initial import.

Pass 2: 20 multi-step Outbound conversations from May 4–5

Re-sampled, this time targeting the bug’s actual population: conversations with ≥2 team-assignment events in the May 4–5, 2026 UTC window.

MetricValue
Multi-step team changes (≥2)20/20
source.type = admin_initiated (filtered pre-fix)16/20
Last team change topic NOT subscribed pre-fix20/20
Last team change topic subscribed post-fix20/20
Currently mismatched in Rulebase5/20 (25%)
Of mismatches, both bugs apply5/5 (100%)

The 5 still-mismatched conversations (group_id = 264/Inbound in Rulebase, but Outbound in Intercom):

  • 215474178683005
  • 215474178695024
  • 215474178702823
  • 215474178678184
  • 215474179125899

For all 5:

  • source.type = admin_initiated → was being short-circuited by allowed_intercom_conversation? on every re-sync.
  • The last team change in Intercom came from conversation.admin.assigned → was not in our pre-fix subscription list.

So both fixes are required and both are exercised by every mismatched case in the sample. After the PR ships and the matching subscriptions are enabled on the Intercom app config, the next webhook-driven sync (or any admin reply/close) on these conversations should flip group_id to Outbound.

Why “matched” isn’t the same as “fix isn’t needed”

The 15 multi-step conversations that already match in Rulebase aren’t evidence against the bug — they just happened to be imported when team_assignee_id was already Outbound, or the relevant team change aligned with an event we were already subscribed to. They’re correct by luck, not by design. The 5 mismatched cases tell us where the design was wrong.

Last updated on