The Problem
Sales teams lose valuable response time when new leads sit unnoticed in your CRM. Reps check the CRM dashboard sporadically, missing hot leads that came in minutes ago. Email notifications get buried in crowded inboxes, and by the time someone sees a new opportunity, competitors have already made contact. Your team needs instant visibility into lead activity—new signups, demo requests, high-value prospects—delivered directly where they already communicate: Slack. However, building this integration requires handling webhook authentication, parsing CRM payloads with varying structures, formatting rich Slack messages with buttons and fields, managing rate limits, and ensuring the bot doesn’t spam channels with irrelevant updates. You also need bidirectional communication—allowing reps to claim leads or update statuses directly from Slack. This tutorial provides production-ready code for a Slack bot that receives real-time webhooks from HubSpot CRM and posts formatted notifications with interactive elements.
Tech Stack & Prerequisites
- Node.js v18+ with npm
- Express.js 4.18+ for webhook server
- Slack Workspace with admin permissions to install apps
- Slack App created at api.slack.com/apps
- HubSpot Account (free tier works) with API access
- @slack/web-api 6.9+ (Slack SDK for Node.js)
- @slack/bolt 3.14+ (framework for Slack apps)
- dotenv for environment variables
- ngrok or similar for local webhook testing (development only)
- crypto (built-in Node.js module) for signature verification
Required Slack Setup:
- Slack Bot created with scopes:
chat:write,channels:read,chat:write.public - Bot Token (xoxb-…) from OAuth & Permissions
- Signing Secret from Basic Information
- Bot installed to your workspace
Required HubSpot Setup:
- Workflow created in HubSpot to trigger on contact creation/update
- Webhook action configured in workflow pointing to your server
- Private App or API Key for optional bidirectional updates
Step-by-Step Implementation
Step 1: Setup
Initialize the project:
mkdir slack-crm-bot
cd slack-crm-bot
npm init -y
npm install @slack/bolt @slack/web-api express dotenv
npm install --save-dev nodemon
Create project structure:
mkdir src webhooks utils
touch src/app.js src/slackClient.js webhooks/hubspot.js utils/formatters.js .env .gitignore
```
Your structure should be:
```
slack-crm-bot/
├── src/
│ ├── app.js
│ ├── slackClient.js
├── webhooks/
│ └── hubspot.js
├── utils/
│ └── formatters.js
├── .env
├── .gitignore
└── package.json
Update .gitignore:
echo "node_modules/
.env
*.log
.DS_Store" > .gitignore
package.json — Add scripts and type module:
{
"name": "slack-crm-bot",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"@slack/bolt": "^3.14.0",
"@slack/web-api": "^6.9.0",
"express": "^4.18.2",
"dotenv": "^16.3.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
Step 2: Configuration
.env — Store credentials securely:
# Slack Configuration
SLACK_BOT_TOKEN=xoxb-your-bot-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
SLACK_CHANNEL_ID=C01234567 # Get from Slack channel details
# HubSpot Configuration
HUBSPOT_WEBHOOK_SECRET=your_custom_webhook_secret_here
# Server Configuration
PORT=3000
NODE_ENV=development
# Optional: HubSpot API for bidirectional updates
HUBSPOT_API_KEY=your_hubspot_api_key_here
How to get Slack credentials:
- Go to https://api.slack.com/apps
- Create new app → From scratch
- Name it “CRM Lead Bot” and select workspace
- Go to OAuth & Permissions → Add Bot Token Scopes:
chat:writechannels:readchat:write.public
- Install to workspace → Copy Bot User OAuth Token (starts with
xoxb-) - Go to Basic Information → Copy Signing Secret
How to get Slack Channel ID:
# In Slack, right-click the channel → View channel details → Copy link
# The ID is the last part: https://yourworkspace.slack.com/archives/C01234567
src/slackClient.js — Initialize Slack client:
import { App } from '@slack/bolt';
import { WebClient } from '@slack/web-api';
import dotenv from 'dotenv';
dotenv.config();
// Initialize Slack App with Bolt framework
export const slackApp = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET,
socketMode: false, // Using HTTP endpoints, not socket mode
});
// Initialize Web API client for direct API calls
export const slackClient = new WebClient(process.env.SLACK_BOT_TOKEN);
// Test connection and get bot info
export async function testSlackConnection() {
try {
const auth = await slackClient.auth.test();
console.log('✓ Slack connected successfully');
console.log(` Bot User: @${auth.user}`);
console.log(` Workspace: ${auth.team}`);
return true;
} catch (error) {
console.error('✗ Slack connection failed:', error.message);
return false;
}
}
// Post a message to a channel
export async function postToChannel(channelId, blocks, text) {
try {
const result = await slackClient.chat.postMessage({
channel: channelId,
blocks: blocks,
text: text, // Fallback text for notifications
});
return result;
} catch (error) {
console.error('Error posting to Slack:', error.message);
throw error;
}
}
// Update a message (for interactive elements)
export async function updateMessage(channelId, messageTs, blocks) {
try {
const result = await slackClient.chat.update({
channel: channelId,
ts: messageTs,
blocks: blocks,
});
return result;
} catch (error) {
console.error('Error updating Slack message:', error.message);
throw error;
}
}
// Add reaction to message
export async function addReaction(channelId, messageTs, emoji) {
try {
await slackClient.reactions.add({
channel: channelId,
timestamp: messageTs,
name: emoji,
});
} catch (error) {
console.error('Error adding reaction:', error.message);
}
}
Step 3: Core Logic
utils/formatters.js — Format CRM data into Slack Block Kit messages:
// Format lead data into rich Slack message blocks
export function formatNewLeadMessage(lead) {
const firstName = lead.firstname || 'Unknown';
const lastName = lead.lastname || '';
const email = lead.email || 'No email provided';
const phone = lead.phone || 'No phone';
const company = lead.company || 'No company';
const leadSource = lead.hs_analytics_source || 'Unknown source';
const leadScore = lead.hubspotscore || 'N/A';
// Determine priority based on lead score
let priorityEmoji = '🟢';
let priorityText = 'Low';
if (leadScore !== 'N/A') {
const score = parseInt(leadScore);
if (score >= 80) {
priorityEmoji = '🔴';
priorityText = 'High';
} else if (score >= 50) {
priorityEmoji = '🟡';
priorityText = 'Medium';
}
}
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: `${priorityEmoji} New Lead: ${firstName} ${lastName}`,
emoji: true,
},
},
{
type: 'section',
fields: [
{
type: 'mrkdwn',
text: `*Email:*\n${email}`,
},
{
type: 'mrkdwn',
text: `*Phone:*\n${phone}`,
},
{
type: 'mrkdwn',
text: `*Company:*\n${company}`,
},
{
type: 'mrkdwn',
text: `*Source:*\n${leadSource}`,
},
{
type: 'mrkdwn',
text: `*Priority:*\n${priorityEmoji} ${priorityText}`,
},
{
type: 'mrkdwn',
text: `*Lead Score:*\n${leadScore}`,
},
],
},
{
type: 'divider',
},
{
type: 'actions',
elements: [
{
type: 'button',
text: {
type: 'plain_text',
text: '👤 Claim Lead',
emoji: true,
},
style: 'primary',
value: lead.hs_object_id || lead.vid,
action_id: 'claim_lead',
},
{
type: 'button',
text: {
type: 'plain_text',
text: '📞 Schedule Call',
emoji: true,
},
value: lead.hs_object_id || lead.vid,
action_id: 'schedule_call',
},
{
type: 'button',
text: {
type: 'plain_text',
text: '🗑️ Dismiss',
emoji: true,
},
style: 'danger',
value: lead.hs_object_id || lead.vid,
action_id: 'dismiss_lead',
},
],
},
];
// Add HubSpot link if object ID available
if (lead.hs_object_id) {
blocks.push({
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `<https://app.hubspot.com/contacts/your-hub-id/contact/${lead.hs_object_id}|View in HubSpot>`,
},
],
});
}
return blocks;
}
// Format lead update message
export function formatLeadUpdateMessage(lead, changes) {
const firstName = lead.firstname || 'Unknown';
const lastName = lead.lastname || '';
const changesList = changes.map(change =>
`• *${change.field}*: ${change.oldValue || 'empty'} → ${change.newValue}`
).join('\n');
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Lead Updated:* ${firstName} ${lastName}\n\n${changesList}`,
},
},
];
return blocks;
}
// Format message after button interaction
export function formatClaimedLeadMessage(lead, claimedBy) {
const firstName = lead.firstname || 'Unknown';
const lastName = lead.lastname || '';
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `✅ *Lead Claimed*\n${firstName} ${lastName} is now assigned to <@${claimedBy}>`,
},
},
];
return blocks;
}
webhooks/hubspot.js — Handle HubSpot webhook payloads:
import express from 'express';
import crypto from 'crypto';
import { postToChannel } from '../src/slackClient.js';
import { formatNewLeadMessage } from '../utils/formatters.js';
const router = express.Router();
// Verify HubSpot webhook signature (optional but recommended)
function verifyHubSpotSignature(req) {
const signature = req.headers['x-hubspot-signature'];
const webhookSecret = process.env.HUBSPOT_WEBHOOK_SECRET;
if (!webhookSecret || !signature) {
return true; // Skip verification if not configured
}
const requestBody = JSON.stringify(req.body);
const hash = crypto
.createHmac('sha256', webhookSecret)
.update(requestBody)
.digest('hex');
return hash === signature;
}
// Parse HubSpot contact properties from webhook payload
function parseContactProperties(payload) {
// HubSpot sends properties in different formats depending on webhook type
const properties = payload.properties || {};
// Extract common fields
const contact = {
hs_object_id: payload.objectId || payload.vid,
firstname: properties.firstname?.value || properties.firstname,
lastname: properties.lastname?.value || properties.lastname,
email: properties.email?.value || properties.email,
phone: properties.phone?.value || properties.phone,
company: properties.company?.value || properties.company,
hs_analytics_source: properties.hs_analytics_source?.value || properties.hs_analytics_source,
hubspotscore: properties.hubspotscore?.value || properties.hubspotscore,
};
return contact;
}
// POST /webhooks/hubspot/contact-created
router.post('/contact-created', async (req, res) => {
try {
console.log('\n📥 Received HubSpot webhook: contact-created');
// Verify signature
if (!verifyHubSpotSignature(req)) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse contact data
const contact = parseContactProperties(req.body);
console.log(` Contact: ${contact.firstname} ${contact.lastname}`);
console.log(` Email: ${contact.email}`);
// Format Slack message
const blocks = formatNewLeadMessage(contact);
const fallbackText = `New lead: ${contact.firstname} ${contact.lastname} (${contact.email})`;
// Post to Slack
await postToChannel(
process.env.SLACK_CHANNEL_ID,
blocks,
fallbackText
);
console.log('✓ Posted to Slack successfully');
// Respond to HubSpot webhook
res.status(200).json({ success: true });
} catch (error) {
console.error('Error processing webhook:', error.message);
res.status(500).json({ error: error.message });
}
});
// POST /webhooks/hubspot/contact-updated
router.post('/contact-updated', async (req, res) => {
try {
console.log('\n📥 Received HubSpot webhook: contact-updated');
const contact = parseContactProperties(req.body);
// Only notify for high-priority changes
const importantFields = ['hubspotscore', 'lifecyclestage', 'hs_lead_status'];
const hasImportantChange = Object.keys(req.body.properties || {}).some(
field => importantFields.includes(field)
);
if (!hasImportantChange) {
console.log(' Skipping - no important field changes');
return res.status(200).json({ success: true, skipped: true });
}
console.log(` Contact updated: ${contact.firstname} ${contact.lastname}`);
// Post notification (simplified for updates)
const blocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `🔄 *Lead Updated:* ${contact.firstname} ${contact.lastname}\nLead score: ${contact.hubspotscore}`,
},
},
];
await postToChannel(
process.env.SLACK_CHANNEL_ID,
blocks,
`Lead updated: ${contact.firstname} ${contact.lastname}`
);
res.status(200).json({ success: true });
} catch (error) {
console.error('Error processing update webhook:', error.message);
res.status(500).json({ error: error.message });
}
});
export default router;
src/app.js — Main application with Slack interactivity:
import express from 'express';
import dotenv from 'dotenv';
import { slackApp, testSlackConnection, updateMessage, addReaction } from './slackClient.js';
import hubspotWebhooks from '../webhooks/hubspot.js';
import { formatClaimedLeadMessage } from '../utils/formatters.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// HubSpot webhooks
app.use('/webhooks/hubspot', hubspotWebhooks);
// Slack interactive endpoints - handle button clicks
slackApp.action('claim_lead', async ({ ack, body, client }) => {
await ack();
try {
const userId = body.user.id;
const leadId = body.actions[0].value;
const messageTs = body.message.ts;
const channelId = body.channel.id;
console.log(`\n👤 Lead ${leadId} claimed by user ${userId}`);
// Update the message to show claimed status
const claimedBlocks = [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `✅ *Lead Claimed*\nThis lead has been claimed by <@${userId}>`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `Lead ID: ${leadId}`,
},
],
},
];
await updateMessage(channelId, messageTs, claimedBlocks);
// Add checkmark reaction
await addReaction(channelId, messageTs, 'white_check_mark');
// TODO: Update CRM with owner assignment via API
// await assignLeadInHubSpot(leadId, userId);
} catch (error) {
console.error('Error handling claim_lead action:', error);
}
});
slackApp.action('schedule_call', async ({ ack, body, client }) => {
await ack();
try {
const userId = body.user.id;
const leadId = body.actions[0].value;
console.log(`\n📞 Call scheduled for lead ${leadId} by user ${userId}`);
// Send ephemeral message (only visible to user who clicked)
await client.chat.postEphemeral({
channel: body.channel.id,
user: userId,
text: `📅 Opening calendar to schedule call for lead ${leadId}...`,
});
// TODO: Integrate with calendar API (Google Calendar, Calendly, etc.)
} catch (error) {
console.error('Error handling schedule_call action:', error);
}
});
slackApp.action('dismiss_lead', async ({ ack, body, client }) => {
await ack();
try {
const userId = body.user.id;
const leadId = body.actions[0].value;
const messageTs = body.message.ts;
const channelId = body.channel.id;
console.log(`\n🗑️ Lead ${leadId} dismissed by user ${userId}`);
// Delete the message
await client.chat.delete({
channel: channelId,
ts: messageTs,
});
// Send confirmation
await client.chat.postEphemeral({
channel: channelId,
user: userId,
text: `Lead ${leadId} has been dismissed.`,
});
} catch (error) {
console.error('Error handling dismiss_lead action:', error);
}
});
// Start Slack app receiver (for interactive components)
const slackReceiver = slackApp.receiver;
// Mount Slack events to Express
app.use('/slack/events', slackReceiver.router);
// Start server
async function startServer() {
try {
// Test Slack connection
await testSlackConnection();
app.listen(PORT, () => {
console.log(`\n🚀 Server running on http://localhost:${PORT}`);
console.log(`\nWebhook endpoints:`);
console.log(` POST /webhooks/hubspot/contact-created`);
console.log(` POST /webhooks/hubspot/contact-updated`);
console.log(`\nSlack events:`);
console.log(` POST /slack/events\n`);
if (process.env.NODE_ENV === 'development') {
console.log('💡 For local testing, expose with ngrok:');
console.log(` ngrok http ${PORT}\n`);
}
});
} catch (error) {
console.error('Failed to start server:', error.message);
process.exit(1);
}
}
startServer();
Step 4: Testing
Test 1: Start the Server
npm run dev
```
Expected output:
```
✓ Slack connected successfully
Bot User: @CRM Lead Bot
Workspace: YourWorkspace
🚀 Server running on http://localhost:3000
Webhook endpoints:
POST /webhooks/hubspot/contact-created
POST /webhooks/hubspot/contact-updated
Test 2: Expose Local Server with ngrok (for webhook testing)
# Install ngrok if you haven't: https://ngrok.com/download
ngrok http 3000
Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io)
Test 3: Configure HubSpot Workflow
- In HubSpot, go to Automation → Workflows
- Create workflow: Contact-based → Blank workflow
- Set enrollment trigger: Contact is created
- Add action: Send webhook
- Method: POST
- URL:
https://your-ngrok-url.ngrok.io/webhooks/hubspot/contact-created - Include all contact properties
- Turn workflow ON
[SCREENSHOT SUGGESTION: HubSpot workflow builder showing webhook action configuration with URL field]
Test 4: Create Test Contact in HubSpot
# Using HubSpot UI: Contacts → Create contact
# Or via curl with HubSpot API:
curl -X POST https://api.hubapi.com/crm/v3/objects/contacts \
-H "Authorization: Bearer YOUR_HUBSPOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"properties": {
"email": "test@example.com",
"firstname": "John",
"lastname": "Doe",
"phone": "+1234567890",
"company": "Test Corp"
}
}'
Expected Result:
- Server logs show:
📥 Received HubSpot webhook: contact-created - Slack channel receives formatted message with lead details
- Interactive buttons appear: “Claim Lead”, “Schedule Call”, “Dismiss”
Test 5: Test Interactive Buttons
- Click “Claim Lead” button in Slack
- Verify message updates to show claimed status
- Check server logs:
👤 Lead claimed by user U01234567
Test 6: Manual Webhook Test (without HubSpot)
curl -X POST http://localhost:3000/webhooks/hubspot/contact-created \
-H "Content-Type: application/json" \
-d '{
"objectId": "12345",
"properties": {
"firstname": "Jane",
"lastname": "Smith",
"email": "jane@example.com",
"phone": "+1987654321",
"company": "Acme Inc",
"hubspotscore": "85",
"hs_analytics_source": "ORGANIC_SEARCH"
}
}'
Testing Checklist:
- ✓ Server starts without errors
- ✓ Slack connection verified
- ✓ Webhook receives HubSpot payload
- ✓ Message posts to correct Slack channel
- ✓ Block Kit formatting displays correctly
- ✓ Buttons are clickable and functional
- ✓ Message updates after button click
- ✓ High-priority leads show red indicator
Test 7: Monitor Logs
# Server logs should show:
✓ Slack connected successfully
📥 Received HubSpot webhook: contact-created
Contact: Jane Smith
Email: jane@example.com
✓ Posted to Slack successfully
```
## Common Errors & Troubleshooting
### Error 1: "not_in_channel" When Posting Message
**Problem:** Slack returns error: `An API error occurred: not_in_channel`.
**Solution:** The bot needs to be added to the channel before it can post. Fix this two ways:
**Method 1 - Add bot to channel manually:**
```
1. In Slack, go to the channel
2. Type: /invite @CRM Lead Bot
3. Press Enter
```
**Method 2 - Use `chat:write.public` scope (already included):**
This allows the bot to post to any public channel without being added. Verify the scope in your Slack App settings at api.slack.com/apps → OAuth & Permissions.
If still failing, reinstall the app to workspace:
```
1. Go to api.slack.com/apps → Your App
2. OAuth & Permissions → Reinstall to Workspace
3. Update SLACK_BOT_TOKEN in .env with new token
```
### Error 2: Slack Interactive Buttons Not Working
**Problem:** Clicking buttons shows "Action failed" or nothing happens.
**Solution:** Slack needs to know where to send button click events. Configure Interactivity:
```
1. Go to api.slack.com/apps → Your App
2. Interactivity & Shortcuts → Turn ON
3. Request URL: https://your-ngrok-url.ngrok.io/slack/events
4. Save Changes
Critical: The Request URL must be publicly accessible. In production, use your actual domain. During development, use ngrok.
Verify with Slack’s Event URL tester:
- After saving, Slack sends a challenge request
- Your server must respond with the challenge value
- The Bolt framework handles this automatically
Debug interactive actions:
// Add debugging to action handler
slackApp.action('claim_lead', async ({ ack, body, client }) => {
console.log('Received action:', JSON.stringify(body, null, 2));
await ack();
// ... rest of handler
});
Error 3: “invalid_signature” from HubSpot Webhook
Problem: Server responds with 401 for valid HubSpot webhooks.
Solution: Signature verification is failing. This happens when:
Issue 1 – Wrong secret in .env:
# Make sure this matches what you set in HubSpot workflow
HUBSPOT_WEBHOOK_SECRET=your_custom_secret_here
Issue 2 – HubSpot doesn’t send signatures by default: HubSpot workflows don’t include signatures. You need to configure it manually or disable verification for HubSpot:
// In webhooks/hubspot.js, modify verification:
function verifyHubSpotSignature(req) {
// Skip verification for HubSpot workflows (they don't sign by default)
if (process.env.NODE_ENV === 'development') {
return true;
}
// Only verify if signature header exists
const signature = req.headers['x-hubspot-signature'];
if (!signature) {
console.warn('No signature provided - skipping verification');
return true;
}
// ... rest of verification code
}
Issue 3 – Body already parsed: Express middleware must preserve raw body for signature verification:
// In app.js, add this BEFORE other middleware:
app.use('/webhooks/hubspot', express.raw({ type: 'application/json' }));
For production, use webhook signature verification with HubSpot API v3: Create a custom integration app in HubSpot that includes webhook signatures, or validate the request came from HubSpot IP ranges.
Security Checklist
Protect your integration with these critical practices:
- Rotate Slack tokens regularly — Bot tokens don’t expire, but should be rotated quarterly. Generate new token in OAuth & Permissions → Reinstall App.
- Validate webhook signatures — Always verify HubSpot webhook requests to prevent unauthorized posting. Implement IP allowlisting for HubSpot’s webhook IPs:
const HUBSPOT_IPS = ['52.73.140.222', '54.166.76.185', '54.87.211.127'];
app.use('/webhooks/hubspot', (req, res, next) => {
const clientIp = req.ip || req.connection.remoteAddress;
if (!HUBSPOT_IPS.includes(clientIp) && process.env.NODE_ENV === 'production') {
return res.status(403).json({ error: 'Unauthorized IP' });
}
next();
});
- Use environment-specific channels — Never post test leads to production sales channels. Create separate channels for dev/staging:
# .env.production
SLACK_CHANNEL_ID=C_PRODUCTION_SALES
# .env.development
SLACK_CHANNEL_ID=C_DEV_TESTING
- Implement rate limiting — Prevent webhook flooding from overwhelming Slack:
import rateLimit from 'express-rate-limit';
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 50, // max 50 requests per minute
message: { error: 'Too many webhook requests' }
});
app.use('/webhooks/', webhookLimiter);
- Sanitize CRM data before posting — Never expose sensitive PII in Slack messages. Mask phone numbers and emails for non-admin users:
function sanitizeForSlack(contact, userRole) {
if (userRole !== 'admin') {
contact.phone = contact.phone ? '***-***-' + contact.phone.slice(-4) : 'N/A';
contact.email = contact.email ? '***@' + contact.email.split('@')[1] : 'N/A';
}
return contact;
}
- Encrypt secrets at rest — Use AWS Secrets Manager, Azure Key Vault, or HashiCorp Vault in production. Never commit
.envfiles. - Log webhook activity — Track all webhook requests for audit trails:
app.use('/webhooks', (req, res, next) => {
console.log(`[${new Date().toISOString()}] Webhook: ${req.path} from ${req.ip}`);
next();
});
- Set up Slack audit logs — Enable workspace audit logging to track bot activity and detect anomalies.
- Use HTTPS only — In production, disable HTTP:
if (process.env.NODE_ENV === 'production' && !req.secure) {
return res.redirect('https://' + req.headers.host + req.url);
}

Moiz Anayat is a CRM operations specialist with more than 5+ years of hands-on experience optimizing workflows, organizing customer data structures, and improving system usability within SaaS platforms. He has contributed to CRM cleanup projects, automation redesigns, and performance optimization strategies for growing teams

