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):
| Source | Inbound | Outbound | Total |
|---|---|---|---|
| Intercom — conversations | 909 | 627 | 1536 |
| Intercom — tickets | 909 | 515 | 1424 |
| Rulebase — conversations | 1049 | 591 | 1640 |
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.createdconversation.admin.single.createdconversation.user.repliedconversation.admin.repliedconversation.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.closedetc.) 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 (
7250653and7483657) 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-metabaseand a Rails console query on prod against theconversationstable fororganization.slug = "kuda-pilot", scoped onstarted_atfor 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=515i.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:
- t₀: admin creates the conversation (
source.type = admin_initiated), self-assigns, then assigns it to Outbound and closes. - t₀ + ~minutes: a bot performs
assign_and_reopenand reassigns to Inbound. (Rulebase appears to capture group=Inbound here.) - 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:
Conversation::Import::Intercom#import: dropped theallowed_intercom_conversation?short-circuit completely. The import now runs for both first-time and re-sync cases regardless ofsource.type. The sync flow’s per-feature filters (e.g.sync_conversation_sourceonly creating aconversation_partfor chat/email source bodies) still apply, so we don’t try to materialize bodies that don’t exist forphone_call/api/etc.ImportHistoricalConversationsTask::Import::Intercom: dropped thesource.type IN ALLOWED_SOURCE_TYPESclause 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_idupdates. - 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_sourcestill filters onALLOWED_SOURCE_TYPES. This is correct: it only governs whether we create aconversation_partto represent the source body, andphone_calletc. don’t have a body to attach. Importing the conversation record itself is now decoupled from this.Conversation::Sync::Intercom::ALLOWED_SOURCE_TYPESitself 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.
| Metric | Value |
|---|---|
| Multi-step team changes (≥2) | 20/20 |
source.type = admin_initiated (filtered pre-fix) | 16/20 |
| Last team change topic NOT subscribed pre-fix | 20/20 |
| Last team change topic subscribed post-fix | 20/20 |
| Currently mismatched in Rulebase | 5/20 (25%) |
| Of mismatches, both bugs apply | 5/5 (100%) |
The 5 still-mismatched conversations (group_id = 264/Inbound in Rulebase, but Outbound in Intercom):
215474178683005215474178695024215474178702823215474178678184215474179125899
For all 5:
source.type = admin_initiated→ was being short-circuited byallowed_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.