Build a Slack Scam Detection Bot
Create a Slack bot that automatically scans phone numbers and URLs posted in channels using the ScamVerify™ API.
This tutorial walks you through building a Slack bot that monitors channels for phone numbers and URLs, checks them against the ScamVerify™ threat intelligence database, and replies in-thread with a risk assessment. By the end, you will have a working bot that protects your Slack workspace in real time.
Prerequisites
- Node.js 18 or later
- A ScamVerify™ API key (get one at scamverify.ai/settings/api)
- A Slack workspace where you have admin permissions
- ngrok for local development (or any tunnel tool)
What You Will Build
A Slack bot that:
- Listens for messages in channels it is invited to
- Extracts phone numbers and URLs from message text
- Calls the ScamVerify™ API to check each one
- Replies in the original thread with a formatted risk report
- Uses color-coded warnings based on verdict severity
Create the Slack app
Go to api.slack.com/apps and click Create New App. Choose From scratch, name it "ScamVerify Bot", and select your workspace.
Under OAuth & Permissions, add these Bot Token Scopes:
channels:history(read messages in public channels)chat:write(post replies)groups:history(read messages in private channels, optional)
Install the app to your workspace and copy the Bot User OAuth Token (starts with xoxb-).
Under Event Subscriptions, enable events and set the Request URL to your server (you will set this up in the next step). Subscribe to the message.channels bot event.
Set up the project
Create a new directory and install dependencies.
mkdir slack-scambot && cd slack-scambot
npm init -y
npm install express dotenvCreate a .env file with your credentials:
SCAMVERIFY_API_KEY=sv_live_your_key_here
SLACK_BOT_TOKEN=xoxb-your-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
PORT=3000You can find the Signing Secret under Basic Information in your Slack app settings.
Create the ScamVerify™ client
Create a reusable module for calling the ScamVerify™ API.
// scamverify.js
class ScamVerifyClient {
constructor(apiKey) {
if (!apiKey || !apiKey.startsWith('sv_')) {
throw new Error('Invalid API key. Keys must start with sv_live_ or sv_test_');
}
this.apiKey = apiKey;
this.baseUrl = 'https://scamverify.ai/api/v1';
}
async lookupPhone(phoneNumber) {
const response = await fetch(`${this.baseUrl}/phone/lookup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone_number: phoneNumber }),
});
if (!response.ok) {
throw new Error(`Phone lookup failed: ${response.status}`);
}
return response.json();
}
async lookupUrl(url) {
const response = await fetch(`${this.baseUrl}/url/lookup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (!response.ok) {
throw new Error(`URL lookup failed: ${response.status}`);
}
return response.json();
}
}
module.exports = { ScamVerifyClient };Build the extraction helpers
Create utility functions to find phone numbers and URLs in message text.
// extractors.js
function extractPhoneNumbers(text) {
// Matches US phone numbers: (555) 123-4567, 555-123-4567, 5551234567, +15551234567
const phoneRegex = /(?:\+?1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g;
const matches = text.match(phoneRegex) || [];
// Normalize to digits only
return [...new Set(matches.map(m => m.replace(/\D/g, '')))];
}
function extractUrls(text) {
// Matches http/https URLs and bare domains
const urlRegex = /https?:\/\/[^\s<>]+/gi;
const matches = text.match(urlRegex) || [];
return [...new Set(matches)];
}
module.exports = { extractPhoneNumbers, extractUrls };Build the Slack message formatter
Create a module that formats ScamVerify™ results as Slack Block Kit messages with color-coded attachments.
// formatter.js
const VERDICT_COLORS = {
safe: '#36a64f',
low_risk: '#2196F3',
medium_risk: '#FF9800',
high_risk: '#f44336',
scam: '#d32f2f',
};
const VERDICT_EMOJI = {
safe: ':white_check_mark:',
low_risk: ':large_blue_circle:',
medium_risk: ':warning:',
high_risk: ':rotating_light:',
scam: ':no_entry:',
};
function formatPhoneResult(phoneNumber, result) {
const emoji = VERDICT_EMOJI[result.verdict] || ':question:';
const color = VERDICT_COLORS[result.verdict] || '#808080';
return {
color,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *Phone Number Check: ${phoneNumber}*\n`
+ `*Verdict:* ${result.verdict.replace('_', ' ').toUpperCase()}\n`
+ `*Risk Score:* ${result.risk_score}/100\n`
+ `*Confidence:* ${Math.round(result.confidence * 100)}%`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `> ${result.explanation}`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: 'Powered by <https://scamverify.ai|ScamVerify\u2122>',
},
],
},
],
};
}
function formatUrlResult(url, result) {
const emoji = VERDICT_EMOJI[result.verdict] || ':question:';
const color = VERDICT_COLORS[result.verdict] || '#808080';
return {
color,
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `${emoji} *URL Check: ${url}*\n`
+ `*Verdict:* ${result.verdict.replace('_', ' ').toUpperCase()}\n`
+ `*Risk Score:* ${result.risk_score}/100`,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `> ${result.explanation}`,
},
},
{
type: 'context',
elements: [
{
type: 'mrkdwn',
text: 'Powered by <https://scamverify.ai|ScamVerify\u2122>',
},
],
},
],
};
}
module.exports = { formatPhoneResult, formatUrlResult };Build the Express server
Create the main server that receives Slack events, processes messages, and replies with results.
// server.js
require('dotenv').config();
const express = require('express');
const crypto = require('crypto');
const { ScamVerifyClient } = require('./scamverify');
const { extractPhoneNumbers, extractUrls } = require('./extractors');
const { formatPhoneResult, formatUrlResult } = require('./formatter');
const app = express();
app.use(express.json());
const client = new ScamVerifyClient(process.env.SCAMVERIFY_API_KEY);
const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
const SLACK_SIGNING_SECRET = process.env.SLACK_SIGNING_SECRET;
// Verify requests are from Slack
function verifySlackRequest(req, res, next) {
const timestamp = req.headers['x-slack-request-timestamp'];
const signature = req.headers['x-slack-signature'];
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(400).send('Request too old');
}
const sigBase = `v0:${timestamp}:${JSON.stringify(req.body)}`;
const computed = 'v0=' + crypto
.createHmac('sha256', SLACK_SIGNING_SECRET)
.update(sigBase)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature))) {
return res.status(401).send('Invalid signature');
}
next();
}
// Post a message to Slack
async function postSlackMessage(channel, threadTs, attachments) {
await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
'Authorization': `Bearer ${SLACK_BOT_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
channel,
thread_ts: threadTs,
attachments,
}),
});
}
// Process a message for phone numbers and URLs
async function processMessage(channel, threadTs, text) {
const phones = extractPhoneNumbers(text);
const urls = extractUrls(text);
if (phones.length === 0 && urls.length === 0) return;
const attachments = [];
for (const phone of phones) {
try {
const result = await client.lookupPhone(phone);
attachments.push(formatPhoneResult(phone, result));
} catch (err) {
console.error(`Phone lookup error for ${phone}:`, err.message);
}
}
for (const url of urls) {
try {
const result = await client.lookupUrl(url);
attachments.push(formatUrlResult(url, result));
} catch (err) {
console.error(`URL lookup error for ${url}:`, err.message);
}
}
if (attachments.length > 0) {
await postSlackMessage(channel, threadTs, attachments);
}
}
// Handle Slack events
app.post('/slack/events', verifySlackRequest, async (req, res) => {
const { type, event, challenge } = req.body;
// Slack URL verification handshake
if (type === 'url_verification') {
return res.json({ challenge });
}
// Respond immediately to avoid Slack retries
res.status(200).send();
// Ignore bot messages to prevent loops
if (event?.bot_id || event?.subtype === 'bot_message') return;
if (type === 'event_callback' && event?.type === 'message') {
await processMessage(event.channel, event.ts, event.text || '');
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`ScamVerify Slack bot running on port ${PORT}`);
});Run and test
Start your server and expose it with ngrok.
node server.js
# In another terminal:
ngrok http 3000Copy the ngrok HTTPS URL and paste it into your Slack app's Event Subscriptions Request URL as https://your-ngrok-url/slack/events. Slack will send a verification challenge, and your server will respond automatically.
Invite the bot to a channel by typing /invite @ScamVerify Bot, then paste a phone number or URL. The bot will reply in-thread with a risk assessment.
Hey team, got a call from (555) 867-5309 and they sent me this link:
https://suspicious-site.example.com/verify-accountThe bot will reply with color-coded results for both the phone number and the URL.
Deploy to production
For production, replace ngrok with a proper deployment. Here is a Dockerfile for containerized hosting:
FROM node:18-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]Deploy to Railway, Render, or any container hosting service. Update the Slack Event Subscriptions URL to your production domain.
For high-traffic workspaces, consider adding a job queue (like BullMQ) to process lookups asynchronously. This prevents Slack's 3-second timeout from causing duplicate events.
Next Steps
- Add the
/scamcheckslash command for on-demand lookups - Use the batch endpoint to process multiple URLs in a single API call
- Set up a dedicated
#scam-alertschannel for high-risk findings - Add reaction-based scanning (scan a link when someone adds a specific emoji reaction)