Webhooks & Event Processing
๐ Webhook Event Processingโ
Email Event Webhookโ
Endpoint: POST /v1/integrations/sendgrid/webhook
Purpose: Receive and process email delivery events from Sendgrid
Sendgrid Events: delivered, open, click, bounce, dropped, spamreport, unsubscribe
Request Body (from Sendgrid):
[
{
"email": "recipient@example.com",
"timestamp": 1704844800,
"smtp-id": "<unique-id@sendgrid.com>",
"event": "delivered",
"category": [],
"sg_event_id": "abc123...",
"sg_message_id": "msg_id.filter_id.123.456...",
"account_id": "507f191e810c19729de860ea",
"uid": "507f191e810c19729de860eb"
},
{
"email": "recipient@example.com",
"timestamp": 1704844850,
"event": "open",
"sg_event_id": "def456...",
"sg_message_id": "msg_id.filter_id.123.456...",
"useragent": "Mozilla/5.0...",
"ip": "192.168.1.1"
}
]
๐ Event Processing Logicโ
Message ID Extractionโ
// Extract msg_id from sg_message_id
// Format: msg_id.filter_id.timestamp.sequence
const msgId = event.sg_message_id.split('.')[0];
Database Updateโ
-
Find Communication Document:
const communication = await Communication.findOne({
msgID: msgId,
}); -
Append Event (with deduplication):
await Communication.updateOne(
{ msgID: msgId },
{
$addToSet: {
events: {
event: event.event,
email: event.email,
timestamp: event.timestamp,
sg_event_id: event.sg_event_id,
sg_message_id: event.sg_message_id,
account_id: event.account_id,
uid: event.uid,
useragent: event.useragent,
ip: event.ip,
url: event.url, // For click events
},
},
},
); -
Event Deduplication: Uses
sg_event_idfor uniqueness via$addToSet
Special Event Handlingโ
Open Eventsโ
Purpose: Mark message as read in conversation UI
Process:
- Find message in
support_messagescollection - Emit
supportMessageAcksocket event for read receipt
Socket Event:
axios.post(
`${process.env.CONVERSATION_SOCKET}/emit`,
{
event: 'supportMessageAck',
data: {
roomId: room._id.toString(),
messageId: message_id.toString(),
},
},
{
headers: {
Authorization: `Bearer ${jwt_token}`,
'x-account-id': account_id.toString(),
},
},
);
Unsubscribe Eventsโ
Purpose: Automatically add email to Do Not Disturb list
Process:
await DND.create({
account_id: ObjectId(account_id),
value: event.email,
type: 'permanent',
reason: 'Unsubscribed via Sendgrid webhook',
});
DND Collection:
{
_id: ObjectId,
account_id: ObjectId,
value: String, // Email address
type: String, // "permanent"
reason: String,
createdAt: Date
}
๐จ Inbound Email Webhookโ
Inbound Email Parserโ
Endpoint: POST /v1/integrations/sendgrid/webhook/inbound
Purpose: Parse incoming email replies and route to conversation system
Request Body (parsed email from Sendgrid):
{
"headers": "Message-ID: <msg123@example.com>\nIn-Reply-To: <ref456@example.com>\n...",
"from": "John Doe <john@example.com>",
"to": "support@mail.dashclicks.com",
"subject": "Re: Support Request",
"text": "Plain text content",
"html": "<p>HTML content</p>",
"envelope": "{\"to\":[\"support@mail.dashclicks.com\"],\"from\":\"john@example.com\"}"
}
๐ Inbound Email Processing Flowโ
1. Extract Headersโ
// Parse headers string
const headerLines = req.body.headers.split('\n');
// Extract In-Reply-To header (for threading)
const inReplyToHeader = headerLines.find(line => line.startsWith('In-Reply-To:'));
const referenceID = inReplyToHeader ? inReplyToHeader.split('<')[1].split('>')[0] : null;
// Extract Message-ID
const messageIdHeader = headerLines.find(line => line.startsWith('Message-ID:'));
const msgID = messageIdHeader ? messageIdHeader.split('<')[1].split('>')[0] : null;
2. Find Original Messageโ
const originalMessage = await Communication.findOne({
msgID: referenceID,
module: 'SENDGRID',
});
Email Threading:
Original email (from DashClicks):
Message-ID: <abc123@mail.dashclicks.com>
Stored in: communication.msgID = "abc123"
Reply email (from contact):
In-Reply-To: <abc123@mail.dashclicks.com>
Webhook extracts: referenceID = "abc123"
Finds original: communication.msgID === "abc123"
Routes to: Same conversation/room
3. Find or Create Contactโ
// Extract sender email
const senderEmail = req.body.from.match(/<(.+)>/)?.[1] || req.body.from;
// Find existing contact
let contact = await Contact.findOne({
email: senderEmail,
parent_account: account_id,
});
// Create if not exists
if (!contact) {
contact = await Contact.create({
email: senderEmail,
name: req.body.from.split('<')[0].trim(),
parent_account: account_id,
});
}
4. Find or Create Support Conversationโ
// Check if support conversation exists
let supportConversation = await SupportConversation.findOne({
contact_id: contact._id,
account_id: account_id,
is_closed: false,
});
// Create if not exists
if (!supportConversation) {
supportConversation = await SupportConversation.create({
contact_id: contact._id,
account_id: account_id,
subject: req.body.subject,
created_at: new Date(),
});
}
5. Find or Create Support Roomโ
// Check for existing thread in message lock
let room = await SupportMessageLock.findOne({
support_conversation_id: supportConversation._id,
});
// Create room if new conversation
if (!room) {
room = await SupportRoom.create({
account_id: account_id,
conversation_id: supportConversation._id,
participants: [contact._id],
});
// Link to default email inbound inbox
const defaultInbox = await SupportInbox.findOne({
account_id: account_id,
type: 'email_inbound',
is_default: true,
});
if (defaultInbox) {
await SupportRoom.updateOne({ _id: room._id }, { $set: { inbox_id: defaultInbox._id } });
}
}
6. Store Messagesโ
Create Two Support Messages:
-
Original Outbound Message (if new thread):
if (originalMessage) {
await SupportMessage.create({
room_id: room._id,
message: originalMessage.html || originalMessage.text,
sent_by: originalMessage.sent_by,
message_type: 'OUTGOING',
created_at: originalMessage.createdAt,
});
} -
Inbound Reply Message:
const inboundMessage = await SupportMessage.create({
room_id: room._id,
message: req.body.html || req.body.text,
sent_by: contact._id,
message_type: 'INCOMING',
created_at: new Date(),
});
Create Communication Record:
await Communication.create({
account_id: account_id,
contact_id: [contact._id],
sent_by: contact._id,
module: 'SENDGRID',
message_type: 'EMAIL',
type: 'INCOMING',
from: senderEmail,
to: [req.body.to],
subject: req.body.subject,
html: req.body.html,
text: req.body.text,
msgID: msgID,
referenceID: referenceID,
success: true,
});
7. Handle Attachmentsโ
Upload to Wasabi:
if (req.files && req.files.length > 0) {
const wasabiConfig = await WasabiConfig.findConfig();
const uploadedAttachments = await wasabiProvider.upload(wasabiConfig, req.files);
await SupportMessage.updateOne(
{ _id: inboundMessage._id },
{
$set: {
attachments: uploadedAttachments,
},
},
);
}
8. Emit Socket Eventsโ
// Join room event
await axios.post(`${process.env.CONVERSATION_SOCKET}/emit`, {
event: 'joinRoom',
data: {
roomId: room._id.toString(),
userId: contact._id.toString(),
},
});
// New message event
await socketProvider.emitMessage(inboundMessage._id);
๐ ๏ธ Webhook Configurationโ
Create Event Webhookโ
Endpoint: POST /v1/integrations/sendgrid/webhook/create
Purpose: Configure Sendgrid to send events to DashClicks
Process:
- Get subuser from account
- Create or update event webhook via Sendgrid API
Sendgrid API Call:
POST https://api.sendgrid.com/v3/user/webhooks/event/settings
Authorization: Bearer {api_key}
On-Behalf-Of: {subuser_username}
Content-Type: application/json
{
"enabled": true,
"url": "https://api.dashclicks.com/v1/e/sendgrid/webhook",
"group_resubscribe": true,
"delivered": true,
"group_unsubscribe": true,
"spam_report": true,
"bounce": true,
"deferred": false,
"unsubscribe": true,
"dropped": true,
"open": true,
"click": true,
"processed": false
}
Response:
{
"success": true,
"message": "SUCCESS",
"data": {
"enabled": true,
"url": "https://api.dashclicks.com/v1/e/sendgrid/webhook",
"delivered": true,
"open": true,
"click": true
// ...
}
}
Create Inbound Parse Webhookโ
Purpose: Configure Sendgrid to parse incoming emails
Requirements:
- Domain/subdomain MX records must point to Sendgrid
- Example MX record:
mx.sendgrid.netwith priority 10
Configuration:
POST https://api.sendgrid.com/v3/user/webhooks/parse/settings
Authorization: Bearer {api_key}
On-Behalf-Of: {subuser_username}
{
"hostname": "mail.yourdomain.com",
"url": "https://api.dashclicks.com/v1/e/sendgrid/webhook/inbound",
"spam_check": true,
"send_raw": false
}
๐๏ธ WebHookControllerโ
Location: Controllers/WebHookController.js
createWebhook()โ
Route: POST /v1/integrations/sendgrid/webhook/create
Logic:
- Get subuser from account document
- Create or update event webhook
- Subscribe to all relevant events
webhook() - Event Listenerโ
Route: POST /v1/integrations/sendgrid/webhook
Logic:
- Extract
msg_idfromsg_message_id - Find tracker document
- Append event to
eventsarray (dedupe bysg_event_id) - Special handling for
openandunsubscribeevents - Return
200 OK(always, even on errors)
Response: Always returns 200 OK to prevent Sendgrid retries
webhookInbound() - Inbound Email Parserโ
Route: POST /v1/integrations/sendgrid/webhook/inbound
Logic:
- Extract email headers
- Find original message
- Find or create contact
- Find or create support conversation
- Create support room
- Store messages
- Upload attachments
- Emit socket events
Response: 200 OK
๐งช Testingโ
Test Event Webhookโ
curl -X POST http://localhost:5003/v1/e/sendgrid/webhook \
-H "Content-Type: application/json" \
-d '[{
"email": "test@example.com",
"timestamp": 1704844800,
"event": "delivered",
"sg_event_id": "test_event_123",
"sg_message_id": "msg_id.filter_id.123.456",
"account_id": "507f191e810c19729de860ea",
"uid": "507f191e810c19729de860eb"
}]'
Test Inbound Webhookโ
curl -X POST http://localhost:5003/v1/e/sendgrid/webhook/inbound \
-H "Content-Type: application/json" \
-d '{
"headers": "Message-ID: <new@example.com>\nIn-Reply-To: <original@dashclicks.com>",
"from": "John Doe <john@example.com>",
"to": "support@mail.dashclicks.com",
"subject": "Re: Support Request",
"text": "Thank you for your help!",
"html": "<p>Thank you for your help!</p>"
}'
Create Webhook Configurationโ
curl -X POST http://localhost:5003/v1/e/sendgrid/webhook/create \
-H "Authorization: Bearer {jwt_token}" \
-H "Content-Type: application/json"
๐ Event Typesโ
Delivery Eventsโ
delivered: Email successfully delivered to recipient's serverdeferred: Recipient's server temporarily rejected (will retry)bounce: Permanent delivery failuredropped: Sendgrid dropped email (invalid recipient, unsubscribed)
Engagement Eventsโ
open: Recipient opened the emailclick: Recipient clicked a link in the email
List Management Eventsโ
spamreport: Recipient marked as spamunsubscribe: Recipient unsubscribedgroup_unsubscribe: Unsubscribed from suppression groupgroup_resubscribe: Resubscribed to suppression group
โก Performance Considerationsโ
Processing Times:
- Event webhook: ~10-50ms per event
- DND insertion: ~5-10ms
- Socket event emission: ~50-100ms
- Inbound email parsing: ~100-300ms
- Attachment upload: ~100-500ms per file
Optimization Tips:
- Always Return 200 OK: Prevents Sendgrid retries even on error
- Async Socket Events: Emit after webhook response
- Batch Event Processing: Process multiple events in single request
- Deduplicate Events: Use
$addToSetwithsg_event_id
โ ๏ธ Important Notesโ
- ๐จ Always Return 200: Webhook must return 200 OK to prevent retries
- ๐ Email Threading: Uses
Message-IDandIn-Reply-Toheaders - ๐ซ Auto-DND: Unsubscribe events automatically add to Do Not Disturb list
- ๐ Event Deduplication:
sg_event_idensures no duplicate events - ๐ Real-time Updates: Socket events for conversation UI updates
- ๐พ Attachment Storage: Inbound attachments uploaded to Wasabi
- ๐ฏ Message Linking: Inbound emails linked to original outbound messages
- ๐ง Support Integration: Inbound emails create support conversations
- ๐ข Room Creation: Each conversation thread gets dedicated support room
- ๐ Contact Lookup: Sender email used to find or create contact
๐จ Troubleshootingโ
Webhook Events Not Processingโ
Diagnosis:
- Check webhook configuration in Sendgrid dashboard
- Verify webhook URL is publicly accessible
- Check
communicationcollection for message:db.communications.findOne({ msgID: 'sendgrid_message_id' }); - Verify
sg_message_idformat:msg_id.filter_id.timestamp.sequence
Inbound Emails Not Creating Conversationsโ
Diagnosis:
- Check Sendgrid Inbound Parse configuration
- Verify MX records point to Sendgrid
- Check original message exists:
db.communications.findOne({ msgID: 'original_message_id' }); - Check contact exists:
db.contacts.findOne({ email: 'sender@example.com' }); - Verify
CONVERSATION_SOCKETenvironment variable