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 startedconversation.user.replied- User replied to conversationconversation_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:
- Receive webhook from Intercom
- Acknowledge immediately with 200 OK
- Process webhook asynchronously (setImmediate)
- Extract contact ID from conversation
- Fetch full contact from Intercom API
- Check if contact already has member_number
- If not, lookup member by email in MySQL
- Get positions from Supabase
- 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 parameter500 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):
conversation.source.author.id- Preferred (direct author)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_numbercustom 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
-
Login to Intercom → Settings → Developers → Webhooks
-
Create New Webhook:
- Webhook URL:
https://platform.natca.org/api/intercom/webhook - Method:
POST - Version:
2.11
- Webhook URL:
-
Select Topics:
- ✅
conversation.user.created - ✅
conversation.user.replied - ✅
conversation_part.tag.created(for manual re-sync)
- ✅
-
Test Webhook:
- Click "Send test webhook"
- Verify Platform receives and processes it
- Check logs:
doctl apps logs <app-id> --component platform
-
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.comEmail Lookups
INFO: Intercom email lookup
email: john.doe@example.com
INFO: Member found by email
email: john.doe@example.com
member_number: 12345Error 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 | bunyanProduction:
# DigitalOcean App Platform
doctl apps logs <app-id> --component platform --follow
# Filter for Intercom events
doctl apps logs <app-id> --component platform | grep -i intercomTesting
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.logTest 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
- Open conversation with unknown or outdated member in Intercom inbox
- Ask member for their NATCA member number
- Fill in conversation data attribute:
- Attribute Name: "Member Number Verification"
- Value: The member number (e.g., "12345")
- Add tag to conversation:
- Press
Tkey (or click tag button) - Type: "mynatca-sync"
- Press Enter to add tag
- Press
- 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:
- Extract tag name from conversation_part.body
- Check if tag is "mynatca-sync" (case-insensitive)
- Extract member_number from conversation.custom_attributes.member_number_verification
- Lookup member in MySQL database using
getMemberByNumberQuery()- Filters: Active and Retired members only
- Get member positions from Supabase
- 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
- Handle duplicate contacts:
- If 409 conflict (duplicate exists): Search for duplicate
- Merge LEAD into USER if needed
- Archive older duplicate contacts
- 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: 123456789Error 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 timeoutTesting
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.logProduction 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 conversationIntegration with Intercom Workflows
Auto-Response Workflow
Configure Intercom workflow to use enriched member data:
- Trigger: When conversation is created
- Condition: If
member_numberis set - 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:
- Trigger: When conversation is created
- Condition: If
member_numberis NOT set - 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