Integrations
Intercom Integration
Architecture

Intercom Integration Architecture

The Intercom integration is designed to maintain accurate member data in Intercom while minimizing API calls and handling edge cases gracefully.

System Architecture

┌─────────────────────────────────────────────────────────────┐
│                    MyNATCA Platform                         │
│                                                             │
│  ┌──────────────┐     ┌──────────────┐   ┌──────────────┐ │
│  │   Supabase   │────▶│  Daily Sync  │──▶│   Intercom   │ │
│  │   (Members,  │     │    Script    │   │     API      │ │
│  │  Positions)  │     └──────────────┘   └──────────────┘ │
│  └──────────────┘                               ▲          │
│                                                  │          │
│  ┌──────────────┐     ┌──────────────┐         │          │
│  │    MySQL     │────▶│   Webhook    │─────────┘          │
│  │  (Fallback)  │     │   Handler    │                    │
│  └──────────────┘     └──────────────┘                    │
│                                                             │
│                       ┌──────────────┐                     │
│                       │ Audit Script │                     │
│                       │  (One-time)  │                     │
│                       └──────────────┘                     │
└─────────────────────────────────────────────────────────────┘


                        ┌──────────────┐
                        │  Intercom    │
                        │  Contacts    │
                        └──────────────┘

Core Components

1. Daily Sync (daily-sync.js)

Purpose: Automated daily synchronization of Active/Retired members

Process Flow:

  1. Fetch Active/Retired members from Supabase
  2. For each member:
    • Get positions from Supabase
    • Format phone to E.164
    • Determine member type and region/facility
    • Upsert to Intercom (create or update by email)
  3. Handle duplicates automatically
  4. Log results and errors

Data Sources:

  • Supabase: Member data, positions, regions, facilities
  • No MySQL dependency (fully migrated to Supabase)

Schedule: Daily at 2:30 AM UTC (after positions sync at 2:15 AM)

2. Audit Script (audit.js)

Purpose: One-time enrichment and cleanup of existing Intercom contacts

Process Flow:

  1. Fetch all contacts from Intercom (or filter by role)
  2. For each contact:
    • Check for duplicate contacts (same email)
    • Archive older duplicates, keep newest
    • Validate external_id (member number)
    • Lookup member in MySQL by external_id → email → legacy "Member Number"
    • Update contact with member data
    • Convert leads to users when matched
    • Archive contacts with no open conversations and no member match
  3. Log all actions to /tmp/intercom_audit.log

Data Sources:

  • Intercom: Existing contacts
  • MySQL: Member lookup (primary source for audit)
  • Supabase: Positions only

Use Cases:

  • Initial setup and data migration
  • Cleanup of invalid UUIDs from old WordPress integration
  • Lead conversion to users
  • Duplicate contact resolution

3. Webhook Handler (routes/intercom.js)

Purpose: Real-time contact enrichment when members message support

Process Flow:

  1. Receive webhook from Intercom (conversation created/replied)
  2. Extract contact ID from conversation
  3. Fetch full contact from Intercom API
  4. If contact missing member_number:
    • Lookup member by email in MySQL
    • Get positions from Supabase
    • Update Intercom contact with member data
  5. Respond 200 OK immediately (async processing)

Data Sources:

  • MySQL: Member lookup by email (primary source)
  • Supabase: Positions only

Endpoints:

  • POST /api/intercom/webhook - Webhook receiver
  • POST /api/intercom/lookup-email - Manual email lookup

4. Intercom Client (lib/client.js)

Purpose: Centralized Intercom API client with rate limiting

Features:

  • Rate Limiting: 166 requests per 10 seconds (evenly distributed from 10,000/min limit)
  • Pagination: Handles cursor-based pagination for search and list endpoints
  • Error Handling: Structured error messages with status codes
  • Methods:
    • getAllContacts() - Iterator for all contacts
    • searchContactsByRole(role) - Search by role (lead/user)
    • searchContactByEmail(email) - Find contact by email
    • searchContactsByEmail(email) - Find ALL contacts by email (for duplicates)
    • searchContactByExternalId(id) - Find contact by member number
    • updateContact(id, customAttrs, builtInFields) - Update contact
    • upsertContact(email, data) - Create or update by email
    • archiveContact(id) - Soft delete contact
    • unarchiveContact(id) - Restore archived contact
    • unblockContact(id) - NEW: Unblock a blocked contact
    • mergeLead(leadId, userId) - Merge lead into user
    • searchContactOpenConversations(id) - Count open conversations
    • searchContactOpenTickets(id) - Count open tickets

5. Helper Libraries

Phone Formatter (lib/phoneFormatter.js)

  • Converts US phone numbers to E.164 format (+17654657031)
  • Uses libphonenumber-js for accurate parsing
  • Handles various input formats: (765) 465-7031, 765-465-7031, etc.
  • Returns null for invalid numbers (Intercom rejects invalid phones)

Member Type Helper (lib/memberTypeHelper.js)

  • Maps status and membertypeid to Intercom member_type field
  • Logic:
    • status === 'Retired' → "Retired Member"
    • membertypeid === 6 → "Current Member"
    • membertypeid === 8 → "NATCA Staff"
    • Default → "NON MEMBER"

MySQL Helpers (lib/mysqlHelpers.js)

  • getMemberByEmailQuery() - Query member by email with JOINs
  • getMemberPositions(supabase, memberNumber) - Get positions from Supabase
    • Includes retry logic for network failures
    • Returns full position names (reverse-maps codes)

Data Flow Diagrams

Daily Sync Flow

┌─────────────────┐
│  Cron Trigger   │
│  2:30 AM UTC    │
└────────┬────────┘


┌─────────────────────────────────┐
│  Fetch Active/Retired Members   │
│  FROM Supabase members table    │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  For Each Member:               │
│  1. Get positions (Supabase)    │
│  2. Get region/facility codes   │
│  3. Format phone to E.164       │
│  4. Determine member_type       │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Upsert to Intercom (by email)  │
│  - Create if new                │
│  - Update if exists             │
│  - Handle 409 conflicts         │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Handle Duplicates              │
│  - Find all with same email     │
│  - Keep newest (by created_at)  │
│  - Archive older duplicates     │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Log Results & Errors           │
│  - Created count                │
│  - Updated count                │
│  - Duplicates resolved          │
│  - Failed count                 │
└─────────────────────────────────┘

Webhook Flow

┌─────────────────────────────────┐
│  Member Messages Intercom       │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Intercom Webhook Fires         │
│  POST /api/intercom/webhook     │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Extract Contact ID             │
│  from conversation data         │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Fetch Full Contact from        │
│  Intercom API                   │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Check: Has member_number?      │
└────────┬───────────┬────────────┘
         │ No        │ Yes
         ▼           ▼
┌──────────────┐  ┌──────────────┐
│ Lookup Email │  │ Skip (done)  │
│ in MySQL     │  └──────────────┘
└──────┬───────┘


┌─────────────────────────────────┐
│  Get Positions from Supabase    │
└────────┬────────────────────────┘


┌─────────────────────────────────┐
│  Update Intercom Contact        │
│  with Member Data               │
└─────────────────────────────────┘

Contact Lifecycle & States

Understanding Intercom contact states is critical for proper sync operation.

Contact States

Intercom contacts can exist in multiple states that affect API operations:

┌─────────────────────────────────────────────────────────────────┐
│                    Contact Lifecycle States                      │
└─────────────────────────────────────────────────────────────────┘

    ┌────────────┐
    │   Active   │ ◀──── Normal operational state
    └─────┬──────┘       Can be updated/deleted

          │ Archive (manual or auto)

    ┌────────────┐
    │  Archived  │ ◀──── Soft-deleted state
    └─────┬──────┘       Can be unarchived

          │ Block (spam/abuse)

    ┌────────────┐
    │  Blocked   │ ◀──── Cannot be restored until unblocked
    └────────────┘       not_restorable error

Recovery Flow:
  Blocked → Unblock → Unarchive → Active → Update

State Transitions

Current StateDesired OperationRequired Steps
ActiveUpdateDirect update
ArchivedUpdateUnarchive → Update
BlockedUpdateUnblock → Update
Blocked + ArchivedUpdateUnblock → Unarchive → Update

API Behavior by State

Active Contact:

  • updateContact() - ✅ Works
  • upsertContact() - ✅ Works (updates existing)
  • archiveContact() - ✅ Works

Archived Contact:

  • updateContact() - ❌ Fails with 404 or "archived"
  • upsertContact() - ❌ Fails with 409 "archived contact exists"
  • unarchiveContact() - ✅ Works (unless also blocked)

Blocked Contact:

  • updateContact() - ❌ Fails with 404 or "not found"
  • upsertContact() - ❌ Fails with 409 conflict
  • unarchiveContact() - ❌ Fails with 400 "not_restorable"
  • unblockContact() - ✅ Works (required first step)

Blocked + Archived Contact:

  • unarchiveContact() - ❌ Fails with "not_restorable"
  • Must unblock first, then unarchive

Error Code Reference

Error CodeError MessageContact StateResolution
409"A contact matching those details already exists"Active duplicateExtract ID, update
409"An archived contact matching those details already exists"Archived duplicateUnarchive → Update
400"not_restorable"BlockedUnblock first
400"User has been blocked and is not restorable"Blocked + ArchivedUnblock → Unarchive
404"not_found" or "contact_not_found"Archived or deletedTry unarchive

Automatic Recovery Implementation

The daily sync implements automatic recovery for all states:

// Try to upsert
try {
  await intercomClient.upsertContact(email, data);
} catch (error) {
  if (error.includes('409') && error.includes('archived contact')) {
    // Extract contact ID
    const contactId = extractIdFromError(error);
 
    // Try to unarchive
    try {
      await intercomClient.unarchiveContact(contactId);
    } catch (unarchiveError) {
      if (unarchiveError.includes('not_restorable')) {
        // Contact is blocked - unblock first
        await intercomClient.unblockContact(contactId);
        await intercomClient.unarchiveContact(contactId);
      }
    }
 
    // Now update
    await intercomClient.updateContact(contactId, data);
  }
}

This ensures the sync can recover from any contact state automatically.

Design Decisions

1. Why Supabase for Daily Sync, MySQL for Webhooks?

Daily Sync Uses Supabase:

  • All member data already synced to Supabase
  • Positions are in Supabase (synced at 2:15 AM)
  • No need for MySQL connection overhead
  • Cleaner, more modern data access

Webhooks Use MySQL:

  • Immediate fallback if Supabase is unavailable
  • MySQL is the source of truth for legacy data
  • Lower latency for real-time lookups
  • Webhook needs high reliability

2. Why Daily Sync at 2:30 AM UTC?

  • Runs after positions sync (2:15 AM UTC)
  • Ensures positions data is fresh
  • Low traffic time for Intercom API
  • Matches member sync schedule (every 4 hours, includes 2:00 AM)

3. Why Keep Newest Contact on Duplicates?

  • Newer contacts likely have more recent interaction history
  • Preserves latest conversation context
  • Matches Intercom's duplicate resolution strategy
  • Page view history from authenticated sessions retained

4. Why Archive Contacts with No Open Conversations?

  • Reduces contact count (Intercom pricing)
  • Removes stale, outdated contacts
  • Preserves contacts with active support needs
  • Can be restored if needed

5. Why Use external_id for Member Number?

  • Intercom's built-in field for external system IDs
  • Indexed for fast lookups
  • Validates uniqueness (prevents duplicates)
  • Allows conversation history to follow member across contact updates

6. Why E.164 Phone Format?

  • Intercom requires E.164 for phone numbers
  • Enables SMS and WhatsApp integrations
  • International standard format
  • Better validation and error handling

Rate Limiting Strategy

Intercom API limits: 10,000 requests per minute

Our Strategy: 166 requests per 10 seconds

  • Evenly distributes load across time windows
  • Prevents burst rate limit hits
  • Allows for retries within same minute
  • Provides headroom for webhook spikes

Implementation:

class IntercomClient {
  constructor() {
    this.maxRequestsPer10Seconds = 166;
    this.windowDuration = 10000; // 10 seconds
    this.requestsInWindow = 0;
    this.windowStart = Date.now();
  }
 
  async waitForRateLimit() {
    const now = Date.now();
    const windowElapsed = now - this.windowStart;
 
    if (windowElapsed >= this.windowDuration) {
      this.requestsInWindow = 0;
      this.windowStart = now;
      return;
    }
 
    if (this.requestsInWindow >= this.maxRequestsPer10Seconds) {
      const waitTime = this.windowDuration - windowElapsed;
      await sleep(waitTime);
      this.requestsInWindow = 0;
      this.windowStart = Date.now();
    }
  }
}

Error Handling

Duplicate Contact Error (409)

// Intercom returns 409 conflict when email/external_id already exists
try {
  await intercomClient.upsertContact(email, data);
} catch (error) {
  if (error.message.includes('409') && error.message.includes('conflict')) {
    // Handle duplicate: find all contacts, keep newest, archive older ones
    const contactId = await handleDuplicateContact(email);
    // Update the specific contact
    await intercomClient.updateContact(contactId, customAttributes, builtInFields);
  }
}

Unique Constraint Error (external_id)

// Another contact already has this member number
try {
  await intercomClient.updateContact(id, attrs, { external_id: memberNumber });
} catch (error) {
  if (error.message.includes('unique_user_constraint')) {
    // Find existing contact with this member number
    const existingContact = await intercomClient.searchContactByExternalId(memberNumber);
    // Keep newer contact, archive older one
    if (currentContact.created_at > existingContact.created_at) {
      await intercomClient.archiveContact(existingContact.id);
      // Retry update
    } else {
      await intercomClient.archiveContact(currentContact.id);
    }
  }
}

Network Retry Logic (Supabase Positions)

async function getMemberPositions(supabase, memberNumber, retryCount = 0) {
  try {
    const { data, error } = await supabase
      .from('positions')
      .select('positiontype')
      .eq('membernumber', memberNumber);
 
    if (error) throw new Error(`Failed: ${error.message}`);
    return data.map(p => POSITION_CODE_TO_NAME[p.positiontype]);
  } catch (error) {
    // Retry on network failures with exponential backoff
    if (retryCount < 3 && isNetworkError(error)) {
      const delay = Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s
      await sleep(delay);
      return getMemberPositions(supabase, memberNumber, retryCount + 1);
    }
    throw error;
  }
}

Security Considerations

API Credentials

  • INTERCOM_ACCESS_TOKEN stored as environment secret
  • Never logged or exposed in responses
  • Rate limiting prevents abuse

Data Privacy

  • Only sync Active/Retired members (not all statuses)
  • Phone numbers validated before sending to Intercom
  • Archive contacts with no member match (data minimization)

Webhook Validation

  • Webhook receives POST from Intercom
  • Responds 200 OK immediately (prevents retries)
  • Processes asynchronously to prevent timeout

Related Documentation