A working sample application for building a Zoom Chat bot using Node.js and Express. The bot demonstrates two complete flows: an AI Research Assistant powered by the Anthropic Claude API, and an Employee Appreciation multi-step form flow.
- Responds to messages and button interactions in Zoom Chat
- Routes all responses as threaded replies within the originating conversation
- Sends interactive cards with buttons and multi-field forms
- Maintains per-user session state across a multi-step flow
- Integrates with the Anthropic Claude API for AI-generated responses
- Supports both direct messages and channel conversations
| Service | Trigger | Description |
|---|---|---|
| Research Assistant | Click button or type menu |
Ask a question, draft a professional response |
| Employee Appreciation | Click button or type menu |
Multi-step form to recognize a teammate |
| Command | What it does |
|---|---|
menu, help, start, services |
Show the main service menu |
cancel, stop, quit, exit |
Stop the current flow |
- Zoom Developer Account
- A General OAuth App created in the Zoom App Marketplace
- An Upstash Redis database (free tier works) — the bot stores flow state, form drafts, conversation history, and the cached Zoom token in Redis
- Node.js v20 or higher (the app uses native
fetch— no extra HTTP library needed) - ngrok or another tunneling tool for local development
The quickest way to a working app is to upload the ready-made manifest instead of clicking through every Marketplace setting by hand.
➡️ See 0-app-manifest/ — it contains app-manifest.json
(scopes, chatbot subscription, events, webview, and redirect URIs already configured) plus
a short guide to importing it in the Zoom App Marketplace. Replace example.ngrok.app with
your tunnel URL, upload, and your app is configured.
Then come back here to run the server locally.
1. Clone the repository
git clone https://github.com/zoom/chatbot-services-nodejs-sample.git
cd chatbot-services-nodejs-sample2. Install dependencies
npm install3. Set up your environment variables
Copy the example file and fill in your credentials:
cp .env.example .env# .env
# ─── Zoom App Credentials ─────────────────────────────────────────────────────
ZOOM_CLIENT_ID=your_zoom_client_id_here
ZOOM_CLIENT_SECRET=your_zoom_client_secret_here
ZOOM_ACCOUNT_ID=your_zoom_account_id_here
# ─── Zoom Chat Bot Settings ──────────────────────────────────────────────
ZOOM_BOT_JID=your_bot_jid_here
ZOOM_VERIFICATION_TOKEN=your_verification_token_here
# ─── OAuth Redirect ───────────────────────────────────────────────────────────
ZOOM_REDIRECT_URI=https://your-ngrok-url.ngrok-free.app/auth/callback
# ─── Anthropic / Claude ───────────────────────────────────────────────────────
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_MODEL=claude-sonnet-4-6
# ─── Server ───────────────────────────────────────────────────────────────────
PORT=4000
SESSION_SECRET=change-this-to-a-long-random-string-in-production
FRONTEND_ORIGIN=http://localhost:3000Never commit your
.envfile. It is already listed in.gitignore.
4. Expose your local server with ngrok
ngrok http 4000Copy the HTTPS forwarding URL (e.g. https://abc123.ngrok-free.app) and use it in step 6.
5. Start the server
npm startThe server binds to port 4000 and waits silently for incoming webhook events — this is normal. You will see log output when Zoom sends events.
6. Configure your Zoom App
In the Zoom App Marketplace, open your app and set:
- Chatbot Endpoint URL:
https://your-ngrok-url/webhooks - OAuth Redirect URL:
https://your-ngrok-url/auth/callback - Chat Subscription: enabled
How It Works
Every interaction starts with Zoom sending a POST request to /webhooks. The webhook handler verifies the request signature, then routes the event by type:
Zoom sends webhook → POST /webhooks
│
├── Timestamp checked (±5 min) + signature verified (HMAC-SHA256, constant-time)
│
└── Switch on event type:
bot_notification → user typed a message (direct message)
interactive_message_actions → user clicked a button
chat_message.submit → user submitted a form
team_chat.app_invited → bot added to a channel
team_chat.app_mention → bot @mentioned in a channel (intent detection → routes)
chat_message.replied → user replied in a channel thread (continues active flows)
chat_message.sent → message posted in a channel (continues active flows only)
bot_installed → bot installed by a user
endpoint.url_validation → Zoom verifying the webhook URL
All bot responses are sent as threaded replies within the conversation where the interaction started. The replyMainMessageId from each incoming event is passed through the entire handler chain and set as reply_to in the Zoom API request.
For multi-step flows, the thread root is stored in session state at the start of the flow so that later steps (like form submission) can thread correctly even when the event type changes.
User: "menu" → Bot sends service menu card
User clicks "Research Assistant" → Bot sends research sub-menu
User clicks "Ask a Question" → Bot: "What question would you like to research?"
User types question → Claude API streams response → Bot sends answer (threaded)
Conversation history is maintained per user (last 20 messages) so Claude has context for follow-up questions.
User clicks "Employee Appreciation"
→ Bot: "Who would you like to appreciate? Enter their full name."
User types name
→ Bot: "Enter the employee's email address."
User types email
→ Bot sends multi-field form card (What was done / Why it mattered / Who it helped)
User fills form and clicks Submit
→ chat_message.submit event fires
→ Bot sends confirmation card with colored fields (threaded)
Note on form field values: The submitted field values arrive in the
chat_message.submitevent'ssubmit_items. Seesrc/handlers/submit-handler.js.
| Variable | Required | Description |
|---|---|---|
ZOOM_CLIENT_ID |
Yes | OAuth app Client ID |
ZOOM_CLIENT_SECRET |
Yes | OAuth app Client Secret |
ZOOM_ACCOUNT_ID |
Yes | Zoom Account ID |
ZOOM_BOT_JID |
Yes | Bot's Zoom JID (from Chat Subscription) |
ZOOM_BOT_NAME |
No | Bot display name (e.g. Zoom Service Chatbot). Used to strip the bot's @mention from channel messages so user input parses correctly |
ZOOM_VERIFICATION_TOKEN |
Yes | Webhook signature verification token |
ZOOM_REDIRECT_URI |
Yes | OAuth callback URL (must match Marketplace setting) |
WEBVIEW_URL |
No | Public URL of the Zoom App webview for the "Open App" button (default: origin of ZOOM_REDIRECT_URI + /webview) |
ANTHROPIC_API_KEY |
Yes | Anthropic API key |
ANTHROPIC_MODEL |
No | Claude model to use (default: claude-sonnet-4-6) |
UPSTASH_REDIS_REST_URL |
Yes | Upstash Redis REST URL (backs state, form drafts, history, token cache) |
UPSTASH_REDIS_REST_TOKEN |
Yes | Upstash Redis REST token |
NODE_ENV |
No | Set to production in deployment — hides internal error details and enables secure cookies |
PORT |
No | Server port (default: 4000) |
SESSION_SECRET |
Yes in prod | Session cookie secret. Required when NODE_ENV=production (dev falls back to a placeholder) |
FRONTEND_ORIGIN |
No | CORS allowed origin (default: http://localhost:3000) |