Skill 139 · Build Zoom Team Chat App
Subchapter 139.17
concepts/webhooks.mdMarkdown12 KBView on GitHub
Complete guide to understanding and implementing Zoom Team Chat webhooks for interactive chatbots.
Webhooks are HTTP POST requests that Zoom sends to your Bot Endpoint URL when specific events occur (slash commands, button clicks, form submissions, etc.).
User action in Zoom → Zoom sends webhook → Your server processes → Send responseExample flow:
1. User types "/weather San Francisco" in Zoom Team Chat
2. Zoom sends POST request to your Bot Endpoint URL
3. Your server receives webhook with payload.cmd = "San Francisco"
4. Your server calls weather API
5. Your server sends chatbot message back with weather dataConfigure Bot Endpoint URL in Zoom Marketplace:
https://abc123.ngrok.io/webhookhttps://yourdomain.com/webhookVerify endpoint - Zoom sends validation request when you save the URL
User action → Zoom webhook → Your handler → Response| Event | Trigger | When It Fires |
|---|---|---|
endpoint.url_validation | URL configured/changed | Setup only |
bot_installed | Bot added to account | Installation |
bot_notification | User messages bot or uses slash command | User interaction |
interactive_message_actions | Button clicked | User clicks button |
chat_message.submit | Form submitted | User submits form |
app_deauthorized | Bot removed from account | Uninstallation |
See: Webhook Events Reference for complete event catalog
Every webhook includes these headers:
{
'x-zm-signature': 'v0=abc123...', // Signature for verification
'x-zm-request-timestamp': '1234567890', // Unix timestamp
'content-type': 'application/json'
}{
"event": "bot_notification", // Event type
"payload": { // Event-specific data
"accountId": "...",
"toJid": "...",
"cmd": "...",
// ... more fields
}
}CRITICAL: Always verify webhook signatures to prevent unauthorized requests.
Without verification, anyone can send fake webhooks to your endpoint, potentially:
const crypto = require('crypto');
function verifyZoomWebhookSignature(req) {
const signature = req.headers['x-zm-signature'];
const timestamp = req.headers['x-zm-request-timestamp'];
const secretToken = process.env.ZOOM_VERIFICATION_TOKEN;
if (!signature || !timestamp) {
throw new Error('Missing signature headers');
}
// Construct message
const message = `v0:${timestamp}:${JSON.stringify(req.body)}`;
// Calculate expected signature
const expectedSignature = crypto
.createHmac('sha256', secretToken)
.update(message)
.digest('hex');
// Compare signatures
if (signature !== `v0=${expectedSignature}`) {
throw new Error('Invalid webhook signature');
}
return true;
}1. Extract signature and timestamp from headers
2. Construct message: "v0:{timestamp}:{JSON body}"
3. Calculate HMAC-SHA256 with secret token
4. Compare calculated signature with header signature
5. Accept if match, reject if mismatchapp.post('/webhook', (req, res) => {
try {
// Step 1: Verify signature
verifyZoomWebhookSignature(req);
// Step 2: Extract event and payload
const { event, payload } = req.body;
// Step 3: Handle event
switch (event) {
case 'endpoint.url_validation':
return handleUrlValidation(req, res);
case 'bot_installed':
return handleBotInstalled(payload, res);
case 'bot_notification':
return handleBotNotification(payload, res);
case 'interactive_message_actions':
return handleButtonClick(payload, res);
case 'app_deauthorized':
return handleBotUninstalled(payload, res);
default:
console.log('Unsupported event:', event);
return res.status(200).json({ success: true });
}
} catch (error) {
if (error.message.includes('signature')) {
return res.status(401).json({ error: 'Invalid webhook signature' });
}
return res.status(500).json({ error: error.message });
}
});Zoom sends this when you configure or change your Bot Endpoint URL.
Purpose: Verify you control the endpoint
Payload:
{
"event": "endpoint.url_validation",
"payload": {
"plainToken": "xyz123abc"
}
}Required Response:
{
"plainToken": "xyz123abc",
"encryptedToken": "hmac_sha256(plainToken, secret_token)"
}Implementation:
function handleUrlValidation(req, res) {
const { plainToken } = req.body.payload;
const encryptedToken = crypto
.createHmac('sha256', process.env.ZOOM_VERIFICATION_TOKEN)
.update(plainToken)
.digest('hex');
return res.status(200).json({
plainToken,
encryptedToken
});
}Fired when someone adds your bot to their account.
Payload:
{
"event": "bot_installed",
"payload": {
"accountId": "...",
"userId": "...",
"timestamp": 1234567890
}
}Use Case: Initialize bot state, send welcome message
Implementation:
async function handleBotInstalled(payload, res) {
console.log('Bot installed for account:', payload.accountId);
// Optional: Initialize database, send welcome message
// await initializeBotForAccount(payload.accountId);
return res.status(200).json({ success: true });
}Fired when:
Payload:
{
"event": "bot_notification",
"payload": {
"accountId": "...",
"toJid": "channel@conference.xmpp.zoom.us",
"robotJid": "bot@xmpp.zoom.us",
"userJid": "user@xmpp.zoom.us",
"cmd": "user's input text",
"userName": "John Doe",
"channelName": "Marketing",
"timestamp": 1234567890
}
}Key Fields:
cmd - User’s input after the slash commandtoJid - Where to send response (channel or DM)accountId - Account identifierUse Case: Process commands, integrate LLM, send responses
Implementation:
async function handleBotNotification(payload, res) {
const { toJid, cmd, accountId, userName } = payload;
console.log(`${userName} sent: ${cmd}`);
// Process command (e.g., call LLM)
const response = await processCommand(cmd);
// Send response
await sendChatbotMessage(toJid, accountId, {
body: [{ type: 'message', text: response }]
});
return res.status(200).json({ success: true });
}Fired when user clicks a button in a chatbot message.
Payload:
{
"event": "interactive_message_actions",
"payload": {
"accountId": "...",
"toJid": "...",
"actionItem": {
"text": "Approve",
"value": "approve" // This is what you check
},
"messageId": "...",
"userName": "John Doe"
}
}Key Field: actionItem.value - The button’s value you defined
Implementation:
async function handleButtonClick(payload, res) {
const { actionItem, toJid, accountId, userName } = payload;
console.log(`${userName} clicked: ${actionItem.value}`);
switch (actionItem.value) {
case 'approve':
await sendChatbotMessage(toJid, accountId, {
body: [{ type: 'message', text: '✅ Approved!' }]
});
break;
case 'reject':
await sendChatbotMessage(toJid, accountId, {
body: [{ type: 'message', text: '❌ Rejected' }]
});
break;
default:
console.log('Unknown action:', actionItem.value);
}
return res.status(200).json({ success: true });
}// ✅ GOOD
app.post('/webhook', (req, res) => {
verifyZoomWebhookSignature(req);
// ... handle event
});
// ❌ BAD
app.post('/webhook', (req, res) => {
// No verification - vulnerable to fake webhooks!
});Zoom expects a 200 response within 3 seconds.
// ✅ GOOD - Respond immediately, process async
app.post('/webhook', (req, res) => {
verifyZoomWebhookSignature(req);
// Respond immediately
res.status(200).json({ success: true });
// Process asynchronously
processWebhookAsync(req.body);
});
// ❌ BAD - Slow processing blocks response
app.post('/webhook', async (req, res) => {
await slowLLMCall(); // May timeout!
res.status(200).json({ success: true });
});// ✅ GOOD - Handle unknown events
switch (event) {
case 'bot_notification':
return handleBotNotification(payload, res);
default:
console.log('Unsupported event:', event);
return res.status(200).json({ success: true });
}
// ❌ BAD - Crash on unknown events
switch (event) {
case 'bot_notification':
return handleBotNotification(payload, res);
// Missing default case - crashes on new events!
}app.post('/webhook', (req, res) => {
const { event, payload } = req.body;
console.log(`[Webhook] ${event}`, {
timestamp: new Date().toISOString(),
accountId: payload.accountId,
userId: payload.userId
});
// ... handle event
});// ✅ GOOD
const SECRET_TOKEN = process.env.ZOOM_VERIFICATION_TOKEN;
// ❌ BAD - Hardcoded secret
const SECRET_TOKEN = 'abc123xyz';# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 4000
# Copy HTTPS URL to Zoom Marketplace
# Example: https://abc123.ngrok.io/webhookWEBHOOK_BASE_URL="http://YOUR_DEV_HOST:4000"
# Test with curl (will fail signature verification - expected)
curl -X POST "$WEBHOOK_BASE_URL/webhook" \
-H "Content-Type: application/json" \
-d '{"event":"test"}'
# Expected response: "Invalid webhook signature" (this is correct!)Success indicators:
bot_installed event fires when you add the botbot_notification fires when you use slash commandinteractive_message_actions| Issue | Cause | Solution |
|---|---|---|
| “Cannot GET /webhook” | Browser sends GET, webhook is POST | Normal - test with POST or Zoom |
| “Invalid signature” | Wrong secret token | Verify ZOOM_VERIFICATION_TOKEN matches Zoom Marketplace |
| URL validation fails | Response format incorrect | Return plainToken + encryptedToken |
| No webhooks received | Wrong endpoint URL | Verify URL in Zoom Marketplace matches your server |
| Webhooks timeout | Slow response | Return 200 immediately, process async |