Integrations
Intercom Integration
Webhooks & API

Webhooks & API

The Intercom integration includes webhook handlers for real-time contact enrichment and API endpoints for manual member lookups.

Overview

Routes File: /routes/intercom.js

Base Path: /api/intercom

Features:

  • Real-time contact enrichment when members message support
  • Email lookup endpoint for manual queries
  • Asynchronous processing to prevent webhook timeouts
  • Automatic member data updates

Endpoints

POST /api/intercom/webhook

Handles Intercom webhook events for real-time contact enrichment.

Purpose: Automatically enrich contacts when they message Intercom support

Webhook Events Handled:

  • conversation.user.created - New conversation started
  • conversation.user.replied - User replied to conversation
  • conversation_part.tag.created - Tag added to conversation (for manual re-sync)

Request Body (from Intercom):

{
  "type": "notification_event",
  "id": "notif_123",
  "topic": "conversation.user.replied",
  "app_id": "abc123",
  "data": {
    "type": "notification_event_data",
    "item": {
      "type": "conversation",
      "id": "123",
      "created_at": 1234567890,
      "source": {
        "type": "conversation",
        "id": "123",
        "author": {
          "type": "user",
          "id": "67eb0a6e8f72a828ba21c342"
        }
      },
      "contacts": {
        "type": "contact.list",
        "contacts": [
          {
            "type": "contact",
            "id": "67eb0a6e8f72a828ba21c342"
          }
        ]
      }
    }
  },
  "links": {},
  "delivered_as": "webhook",
  "created_at": 1234567890,
  "self": null
}

Response:

{
  "received": true
}

HTTP Status: 200 OK (always, to prevent Intercom retries)

Processing Flow:

  1. Receive webhook from Intercom
  2. Acknowledge immediately with 200 OK
  3. Process webhook asynchronously (setImmediate)
  4. Extract contact ID from conversation
  5. Fetch full contact from Intercom API
  6. Check if contact already has member_number
  7. If not, lookup member by email in MySQL
  8. Get positions from Supabase
  9. Update Intercom contact with member data

Example Usage:

Configure in Intercom webhook settings:

  • Webhook URL: https://platform.natca.org/api/intercom/webhook
  • Topics: conversation.user.created, conversation.user.replied
  • Method: POST

POST /api/intercom/lookup-email

Manual member lookup by email address.

Purpose: Find member information by email for manual enrichment or support queries

Request Body:

{
  "email": "john.doe@example.com"
}

Response (Found):

{
  "found": true,
  "member_number": "12345",
  "firstname": "John",
  "lastname": "Doe",
  "email": "john.doe@example.com",
  "phone": "(765) 465-7031",
  "region": "GL",
  "facility": "ZAU",
  "positions": ["Facility President", "Area Representative"]
}

Response (Not Found):

{
  "found": false
}

HTTP Status:

  • 200 OK - Success (found or not found)
  • 400 Bad Request - Missing email parameter
  • 500 Internal Server Error - Server error

Example Usage:

# curl
curl -X POST https://platform.natca.org/api/intercom/lookup-email \
  -H "Content-Type: application/json" \
  -d '{"email":"john.doe@example.com"}'
 
# JavaScript fetch
const response = await fetch('https://platform.natca.org/api/intercom/lookup-email', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'john.doe@example.com' })
});
const data = await response.json();

Webhook Processing Flow

1. Receive Webhook

router.post('/webhook', async (req, res) => {
  try {
    const event = req.body;
 
    logger.info('Intercom webhook received', {
      topic: event.topic,
      id: event.id
    });
 
    // Acknowledge webhook immediately
    res.status(200).json({ received: true });
 
    // Process webhook asynchronously
    setImmediate(async () => {
      try {
        await processWebhook(event);
      } catch (error) {
        logger.error('Webhook processing failed', {
          topic: event.topic,
          error: error.message
        });
      }
    });
 
  } catch (error) {
    logger.error('Intercom webhook handler error', {
      error: error.message,
      stack: error.stack
    });
    res.status(500).json({ error: 'Internal server error' });
  }
});

Why Async Processing?

  • Prevents webhook timeout (Intercom expects fast response)
  • Allows complex member lookups without blocking
  • Prevents Intercom from retrying on slow processing

2. Extract Contact ID

async function handleConversationEvent(conversationData) {
  const { item: conversation } = conversationData;
 
  // Get contact ID from conversation
  const contactId = conversation.source?.author?.id || conversation.contacts?.contacts?.[0]?.id;
 
  if (!contactId) {
    logger.warn('No contact ID found in conversation', { conversation_id: conversation.id });
    return;
  }
 
  // Continue processing...
}

Contact ID Sources (in order):

  1. conversation.source.author.id - Preferred (direct author)
  2. conversation.contacts.contacts[0].id - Fallback (first contact)

3. Check Existing Member Data

// Get full contact details from Intercom
const contact = await intercomClient.getContact(contactId);
 
// Check if contact already has member_number
if (contact.custom_attributes && contact.custom_attributes.member_number) {
  logger.info('Contact already has member_number', {
    contact_id: contactId,
    member_number: contact.custom_attributes.member_number
  });
  return;
}
 
// Get email from contact
const email = contact.email;
if (!email) {
  logger.info('Contact has no email, skipping lookup', { contact_id: contactId });
  return;
}

Skip Conditions:

  • Contact already has member_number custom attribute
  • Contact has no email address

4. Lookup Member in MySQL

// Connect to MySQL
const connection = await mysql.createConnection({
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASS,
  database: process.env.MYSQL_DB
});
 
try {
  // Query for member by email using helper
  const query = getMemberByEmailQuery() + '\n         LIMIT 1';
  const [rows] = await connection.execute(query, [email, 'Active', 'Retired']);
 
  if (rows.length === 0) {
    logger.info('Member not found for webhook email', { email, contact_id: contactId });
    // Intercom workflow will handle member_number collection
    return;
  }
 
  const member = rows[0];
  // Continue processing...
} finally {
  await connection.end();
}

Why MySQL for Webhooks?

  • Immediate fallback if Supabase is unavailable
  • MySQL is source of truth for legacy data
  • Lower latency for real-time lookups
  • Higher reliability for critical path

5. Get Positions from Supabase

// Get positions from Supabase (already synced)
const positions = await getMemberPositions(supabase, member.membernumber);
 
// Result: ["Facility President", "Area Representative", ...]

6. Update Intercom Contact

// Determine region and facility (use "RNAV" for Retired members)
const region = member.status === 'Retired' ? 'RNAV' : (member.region || '');
const facility = member.status === 'Retired' ? 'RNAV' : (member.facility || '');
 
// Determine member type
const memberType = getMemberTypeForIntercom(member.status, member.membertypeid);
 
// Format phone to E.164
const builtInFields = {};
const formattedPhone = formatPhoneForIntercom(member.phone);
if (formattedPhone) {
  builtInFields.phone = formattedPhone;
}
 
// Update contact
await intercomClient.updateContact(
  contactId,
  {
    member_number: member.membernumber,
    member_type: memberType,
    region: region,
    facility: facility,
    positions: positions.join(', ')
  },
  builtInFields
);
 
logger.info('Updated Intercom contact with member data', {
  contact_id: contactId,
  member_number: member.membernumber,
  email
});

Email Lookup Flow

1. Validate Request

router.post('/lookup-email', async (req, res) => {
  const { email } = req.body;
 
  if (!email) {
    return res.status(400).json({ error: 'Email address is required' });
  }
 
  logger.info('Intercom email lookup', { email });
  // Continue processing...
});

2. Query MySQL

// Connect to MySQL
const connection = await mysql.createConnection({
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASS,
  database: process.env.MYSQL_DB
});
 
try {
  // Query for member by email using helper
  const query = getMemberByEmailQuery() + '\n         LIMIT 1';
  const [rows] = await connection.execute(query, [email, 'Active', 'Retired']);
 
  if (rows.length === 0) {
    logger.info('Member not found by email', { email });
    return res.json({ found: false });
  }
 
  const member = rows[0];
  // Continue processing...
} finally {
  await connection.end();
}

3. Get Positions and Return

// Get member positions from Supabase
const positions = await getMemberPositions(supabase, member.membernumber);
 
logger.info('Member found by email', {
  email,
  member_number: member.membernumber
});
 
res.json({
  found: true,
  member_number: member.membernumber,
  firstname: member.firstname,
  lastname: member.lastname,
  email: member.email,
  phone: member.phone,
  region: member.region,
  facility: member.facility,
  positions: positions
});

Webhook Configuration in Intercom

Setup Steps

  1. Login to Intercom → Settings → Developers → Webhooks

  2. Create New Webhook:

    • Webhook URL: https://platform.natca.org/api/intercom/webhook
    • Method: POST
    • Version: 2.11
  3. Select Topics:

    • conversation.user.created
    • conversation.user.replied
    • conversation_part.tag.created (for manual re-sync)
  4. Test Webhook:

    • Click "Send test webhook"
    • Verify Platform receives and processes it
    • Check logs: doctl apps logs <app-id> --component platform
  5. Enable Webhook:

    • Toggle "Active" switch
    • Monitor for successful events

Webhook Security

Verify Webhook Source (optional):

// Add webhook signature verification (if Intercom provides secret)
const crypto = require('crypto');
 
function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-intercom-signature'];
  const timestamp = req.headers['x-intercom-timestamp'];
  const body = JSON.stringify(req.body);
 
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(`${timestamp}.${body}`);
  const computedSignature = hmac.digest('hex');
 
  return signature === computedSignature;
}
 
router.post('/webhook', async (req, res) => {
  if (process.env.INTERCOM_WEBHOOK_SECRET) {
    if (!verifyWebhookSignature(req, process.env.INTERCOM_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
  }
  // Continue processing...
});

Webhook Retry Logic

Intercom automatically retries failed webhooks:

  • Initial retry: After 1 minute
  • Subsequent retries: Exponential backoff (2min, 4min, 8min, ...)
  • Max retries: 5 attempts
  • Retry triggers: Non-200 status codes, timeouts

Best Practices:

  • Always return 200 OK immediately
  • Process asynchronously to prevent timeout
  • Log errors but don't fail webhook response

Error Handling

Webhook Processing Errors

setImmediate(async () => {
  try {
    await processWebhook(event);
  } catch (error) {
    logger.error('Webhook processing failed', {
      topic: event.topic,
      error: error.message
    });
    // Don't re-throw - webhook already acknowledged
  }
});

Error Scenarios:

  • Contact not found in Intercom
  • Member not found in MySQL
  • Intercom API error during update
  • Supabase connection failure

Handling Strategy:

  • Log error with full context
  • Don't re-throw (prevents retries)
  • Allow workflow to handle missing data

Email Lookup Errors

try {
  // Database query
  const [rows] = await connection.execute(query, [email, 'Active', 'Retired']);
  // Process and return
} catch (error) {
  logger.error('Intercom email lookup failed', {
    error: error.message,
    stack: error.stack
  });
  res.status(500).json({ error: 'Internal server error' });
} finally {
  await connection.end();
}

Error Scenarios:

  • MySQL connection failure
  • Invalid email format
  • Supabase positions query failure

Handling Strategy:

  • Return 500 error to caller
  • Log full error details
  • Always close database connection

Monitoring & Logging

Webhook Events

INFO: Intercom webhook received
  topic: conversation.user.replied
  id: notif_123

INFO: Updated Intercom contact with member data
  contact_id: 67eb0a6e8f72a828ba21c342
  member_number: 12345
  email: john.doe@example.com

Email Lookups

INFO: Intercom email lookup
  email: john.doe@example.com

INFO: Member found by email
  email: john.doe@example.com
  member_number: 12345

Error Logs

ERROR: Webhook processing failed
  topic: conversation.user.created
  error: Contact not found in Intercom

ERROR: Intercom email lookup failed
  error: Connection timeout
  stack: Error: Connection timeout\n    at ...

Accessing Logs

Development:

# Watch platform logs
npm run dev
 
# Or with logging tool
node server.js | bunyan

Production:

# DigitalOcean App Platform
doctl apps logs <app-id> --component platform --follow
 
# Filter for Intercom events
doctl apps logs <app-id> --component platform | grep -i intercom

Testing

Test Webhook Locally

# 1. Start platform server
npm run dev
 
# 2. Use ngrok to expose local server
ngrok http 1300
 
# 3. Configure Intercom webhook with ngrok URL
# https://abc123.ngrok.io/api/intercom/webhook
 
# 4. Trigger test webhook in Intercom dashboard
 
# 5. Monitor logs
tail -f logs/platform.log

Test Email Lookup

# Local
curl -X POST http://localhost:1300/api/intercom/lookup-email \
  -H "Content-Type: application/json" \
  -d '{"email":"john.doe@example.com"}'
 
# Production
curl -X POST https://platform.natca.org/api/intercom/lookup-email \
  -H "Content-Type: application/json" \
  -d '{"email":"john.doe@example.com"}'

Test Contact Enrichment

# Manually trigger webhook processing
node -e "
const handler = require('./routes/intercom');
 
const mockEvent = {
  topic: 'conversation.user.replied',
  data: {
    item: {
      id: '123',
      source: {
        author: {
          id: '67eb0a6e8f72a828ba21c342'
        }
      }
    }
  }
};
 
// Process event
handler.processWebhook(mockEvent)
  .then(() => console.log('Done'))
  .catch(err => console.error('Error:', err));
"

Tag-Based Manual Re-Sync

Overview

Support teams can manually trigger member enrichment by tagging conversations with "mynatca-sync". This feature allows support agents to re-sync contact data when:

  • A member's information is outdated or missing
  • A LEAD contact needs to be converted to USER
  • Email-based lookup failed but member number is known
  • Contact needs to be merged with duplicate entries

Support Team Workflow

  1. Open conversation with unknown or outdated member in Intercom inbox
  2. Ask member for their NATCA member number
  3. Fill in conversation data attribute:
    • Attribute Name: "Member Number Verification"
    • Value: The member number (e.g., "12345")
  4. Add tag to conversation:
    • Press T key (or click tag button)
    • Type: "mynatca-sync"
    • Press Enter to add tag
  5. System automatically:
    • Reads member_number from conversation attribute
    • Looks up member in database (Active/Retired only)
    • Creates or updates USER contact with full member data
    • Merges any existing LEAD into the USER
    • Removes "mynatca-sync" tag from conversation

Tag Event Webhook

Webhook Topic: conversation_part.tag.created

Request Body:

{
  "type": "notification_event",
  "topic": "conversation_part.tag.created",
  "data": {
    "item": {
      "type": "conversation",
      "id": "123456789",
      "conversation_parts": {
        "conversation_parts": [
          {
            "id": "987654321",
            "part_type": "tag",
            "body": "mynatca-sync",
            "created_at": 1234567890,
            "author": {
              "type": "admin",
              "id": "admin123"
            }
          }
        ]
      },
      "custom_attributes": {
        "member_number_verification": "12345"
      }
    }
  }
}

Processing Flow:

  1. Extract tag name from conversation_part.body
  2. Check if tag is "mynatca-sync" (case-insensitive)
  3. Extract member_number from conversation.custom_attributes.member_number_verification
  4. Lookup member in MySQL database using getMemberByNumberQuery()
    • Filters: Active and Retired members only
  5. Get member positions from Supabase
  6. Create or update contact in Intercom:
    • If contact exists as LEAD: Upsert to USER with member data
    • If contact exists as USER: Update with latest member data
    • If no contact exists: Create new USER contact
  7. Handle duplicate contacts:
    • If 409 conflict (duplicate exists): Search for duplicate
    • Merge LEAD into USER if needed
    • Archive older duplicate contacts
  8. Remove "mynatca-sync" tag from conversation
    • Indicates successful completion
    • Prevents re-processing

Code Example:

// Extract member number from conversation custom attributes
const memberNumber = conversation.custom_attributes?.member_number_verification;
 
if (!memberNumber) {
  logger.warn('No member number found in conversation attributes', {
    conversation_id: conversation.id
  });
  return;
}
 
// Query MySQL for member
const query = getMemberByNumberQuery() + '\n         LIMIT 1';
const [rows] = await connection.execute(query, [memberNumber, 'Active', 'Retired']);
 
if (rows.length === 0) {
  logger.info('Member not found for tag-based sync', {
    member_number: memberNumber,
    conversation_id: conversation.id
  });
  return;
}
 
const member = rows[0];
 
// Get positions from Supabase
const positions = await getMemberPositions(supabase, member.membernumber);
 
// Prepare contact data
const region = member.status === 'Retired' ? 'RNAV' : (member.region || '');
const facility = member.status === 'Retired' ? 'RNAV' : (member.facility || '');
const memberType = getMemberTypeForIntercom(member.status, member.membertypeid);
 
const customAttributes = {
  member_number: member.membernumber,
  member_type: memberType,
  region: region,
  facility: facility,
  positions: positions.join(', ')
};
 
const builtInFields = {};
const formattedPhone = formatPhoneForIntercom(member.phone);
if (formattedPhone) {
  builtInFields.phone = formattedPhone;
}
 
// Upsert contact (create or update by email)
try {
  await intercomClient.upsertContact(
    member.email,
    customAttributes,
    builtInFields,
    'user' // role
  );
} catch (error) {
  if (error.message.includes('409')) {
    // Handle duplicate contact - merge LEAD into USER
    await handleDuplicateContact(member.email, member);
  } else {
    throw error;
  }
}
 
// Remove tag from conversation
await intercomClient.removeTagFromConversation(conversation.id, 'mynatca-sync');

Error Handling

Missing Member Number:

  • Webhook processes but skips enrichment
  • Logs warning with conversation ID
  • Support agent needs to add member_number_verification attribute

Member Not Found:

  • Logs info message with member number
  • Tag remains on conversation (manual removal needed)
  • Support agent should verify member number is correct

Database Errors:

  • Logs error with full stack trace
  • Tag remains on conversation (can retry)
  • Webhook continues to process other events

Duplicate Contact Conflicts:

  • Automatically searches for duplicate
  • Merges LEAD into USER
  • Archives older duplicates
  • Updates USER with latest data
  • Removes tag on success

Tag Already Removed:

  • Intercom API returns 404 when removing tag
  • Logs warning but continues
  • No retry needed (tag already gone)

Monitoring

Success Logs:

INFO: Processing tag-based sync for conversation
  conversation_id: 123456789
  tag: mynatca-sync
  member_number: 12345

INFO: Updated Intercom contact from tag-based sync
  contact_id: 67eb0a6e8f72a828ba21c342
  member_number: 12345
  email: john.doe@example.com

INFO: Removed mynatca-sync tag from conversation
  conversation_id: 123456789

Error Logs:

WARN: No member number found in conversation attributes
  conversation_id: 123456789

INFO: Member not found for tag-based sync
  member_number: 99999
  conversation_id: 123456789

ERROR: Tag-based sync failed
  conversation_id: 123456789
  error: Database connection timeout

Testing

Local Testing:

# 1. Start platform server
npm run dev
 
# 2. Use ngrok to expose local server
ngrok http 1300
 
# 3. Configure Intercom webhook with ngrok URL
# https://abc123.ngrok.io/api/intercom/webhook
 
# 4. In Intercom:
#    - Create test conversation
#    - Add custom attribute: member_number_verification = "12345"
#    - Add tag: "mynatca-sync"
 
# 5. Monitor logs
tail -f logs/platform.log

Production Testing:

# 1. Create test conversation in Intercom
# 2. Add member_number_verification attribute
# 3. Add mynatca-sync tag
# 4. Monitor Platform logs
doctl apps logs <app-id> --component platform --follow
 
# 5. Verify contact updated in Intercom
# 6. Verify tag removed from conversation

Integration with Intercom Workflows

Auto-Response Workflow

Configure Intercom workflow to use enriched member data:

  1. Trigger: When conversation is created
  2. Condition: If member_number is set
  3. Action: Auto-reply with personalized message

Example Workflow:

Trigger: Conversation created
Condition: member_number is set
Action: Send message

"Hi {{contact.name}}! I see you're a member at {{contact.facility}} in the {{contact.region}} region. How can I help you today?"

Member Number Collection

If member not found by email:

  1. Trigger: When conversation is created
  2. Condition: If member_number is NOT set
  3. Action: Ask for member number

Example Workflow:

Trigger: Conversation created
Condition: member_number is not set
Action: Send message

"Thanks for reaching out! To better assist you, could you please provide your NATCA member number?"

Then use /api/intercom/lookup-email to verify and update contact.

Performance Considerations

Webhook Response Time

  • Target: < 500ms response time
  • Actual: ~100-200ms (immediate acknowledgment)
  • Processing: 1-3 seconds (async)

Database Connection Pooling

For high webhook volume, consider connection pooling:

// Create MySQL pool (instead of single connection)
const mysql = require('mysql2/promise');
 
const pool = mysql.createPool({
  host: process.env.MYSQL_HOST,
  user: process.env.MYSQL_USER,
  password: process.env.MYSQL_PASS,
  database: process.env.MYSQL_DB,
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});
 
// Use pool in webhook handler
router.post('/webhook', async (req, res) => {
  const connection = await pool.getConnection();
  try {
    // Use connection
  } finally {
    connection.release();
  }
});

Rate Limiting

Webhook processing respects Intercom API rate limits:

  • 166 requests per 10 seconds (via IntercomClient)
  • Automatic waiting when limit reached
  • Prevents 429 errors

Related Documentation