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:
- Fetch Active/Retired members from Supabase
- 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)
- Handle duplicates automatically
- 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:
- Fetch all contacts from Intercom (or filter by role)
- 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
- 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:
- Receive webhook from Intercom (conversation created/replied)
- Extract contact ID from conversation
- Fetch full contact from Intercom API
- If contact missing member_number:
- Lookup member by email in MySQL
- Get positions from Supabase
- Update Intercom contact with member data
- Respond 200 OK immediately (async processing)
Data Sources:
- MySQL: Member lookup by email (primary source)
- Supabase: Positions only
Endpoints:
POST /api/intercom/webhook- Webhook receiverPOST /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 contactssearchContactsByRole(role)- Search by role (lead/user)searchContactByEmail(email)- Find contact by emailsearchContactsByEmail(email)- Find ALL contacts by email (for duplicates)searchContactByExternalId(id)- Find contact by member numberupdateContact(id, customAttrs, builtInFields)- Update contactupsertContact(email, data)- Create or update by emailarchiveContact(id)- Soft delete contactunarchiveContact(id)- Restore archived contactunblockContact(id)- NEW: Unblock a blocked contactmergeLead(leadId, userId)- Merge lead into usersearchContactOpenConversations(id)- Count open conversationssearchContactOpenTickets(id)- Count open tickets
5. Helper Libraries
Phone Formatter (lib/phoneFormatter.js)
- Converts US phone numbers to E.164 format (
+17654657031) - Uses
libphonenumber-jsfor accurate parsing - Handles various input formats:
(765) 465-7031,765-465-7031, etc. - Returns
nullfor 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 JOINsgetMemberPositions(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 → UpdateState Transitions
| Current State | Desired Operation | Required Steps |
|---|---|---|
| Active | Update | Direct update |
| Archived | Update | Unarchive → Update |
| Blocked | Update | Unblock → Update |
| Blocked + Archived | Update | Unblock → Unarchive → Update |
API Behavior by State
Active Contact:
updateContact()- ✅ WorksupsertContact()- ✅ 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 conflictunarchiveContact()- ❌ 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 Code | Error Message | Contact State | Resolution |
|---|---|---|---|
| 409 | "A contact matching those details already exists" | Active duplicate | Extract ID, update |
| 409 | "An archived contact matching those details already exists" | Archived duplicate | Unarchive → Update |
| 400 | "not_restorable" | Blocked | Unblock first |
| 400 | "User has been blocked and is not restorable" | Blocked + Archived | Unblock → Unarchive |
| 404 | "not_found" or "contact_not_found" | Archived or deleted | Try 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_TOKENstored 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