WhatsApp Multi-modal Proxy Server

Node.js webhook bridge for WhatsApp text + Voximplant voice sessions

View as Markdown

For the complete documentation index, see llms.txt.

This page embeds the server code used in the WhatsApp multi-modal flow.

See this video for more information: Inbound WhatsApp calling demo.

Prerequisites

  • Node.js 18+ runtime.
  • A WhatsApp Business number connected to WhatsApp Cloud API in Meta.
  • A Meta access token with permission to read webhook events and send WhatsApp messages.
  • A Voximplant application with scenario logic that stores callback URLs in ApplicationStorage as WAB_<caller_number>.
  • Voximplant service account credentials file available as voximplant_service_account.json (or update pathToCredentials in code).
  • Public HTTPS endpoint for /webhook (for Meta webhook delivery) and /health (optional health checks).
  • NPM dependencies installed:
$npm install express body-parser axios dotenv @voximplant/apiclient-nodejs

Required environment variables:

$WHATSAPP_TOKEN=...
$WHATSAPP_PHONE_NUMBER_ID=...
$WHATSAPP_WEBHOOK_VERIFY_TOKEN=...
$VOXIMPLANT_APP_ID=...

Optional environment variables:

$API_VERSION=v18.0
$PORT=3000
server.js
1require('dotenv').config();
2const express = require('express');
3const bodyParser = require('body-parser');
4const axios = require('axios');
5const VoximplantApiClient = require('@voximplant/apiclient-nodejs').default;
6
7const app = express();
8const PORT = process.env.PORT || 3000;
9// Path to voximplant service account json file
10const parameters = {
11 pathToCredentials: './voximplant_service_account.json',
12};
13const client = new VoximplantApiClient(parameters);
14client.onReady = function () {
15 console.log("Voximplant API client is ready.");
16};
17
18
19// Middleware
20app.use(bodyParser.json());
21
22// Environment variables (similar to Lambda environment variables)
23const config = {
24 whatsappToken: process.env.WHATSAPP_TOKEN,
25 whatsappPhoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID,
26 whatsappWebhookVerifyToken: process.env.WHATSAPP_WEBHOOK_VERIFY_TOKEN,
27 apiVersion: process.env.API_VERSION || 'v18.0'
28};
29
30// WhatsApp API base URL
31const whatsappApiUrl = `https://graph.facebook.com/${config.apiVersion}/${config.whatsappPhoneNumberId}`;
32
33// Webhook verification endpoint (GET)
34app.get('/webhook', (req, res) => {
35 console.log('Webhook verification request received');
36
37 const mode = req.query['hub.mode'];
38 const token = req.query['hub.verify_token'];
39 const challenge = req.query['hub.challenge'];
40
41 if (mode && token) {
42 if (mode === 'subscribe' && token === config.whatsappWebhookVerifyToken) {
43 console.log('WEBHOOK_VERIFIED');
44 res.status(200).send(challenge);
45 } else {
46 console.log('Verification failed - invalid token');
47 res.sendStatus(403);
48 }
49 } else {
50 console.log('Verification failed - missing parameters');
51 res.sendStatus(400);
52 }
53});
54
55// Webhook message endpoint (POST)
56// NOTE: For production use, validate the X-Hub-Signature-256 header using your app secret
57// to verify that requests originate from Meta. See: https://developers.facebook.com/docs/messenger-platform/webhooks#validate-payloads
58app.post('/webhook', async (req, res) => {
59 console.log('Webhook message received:', JSON.stringify(req.body, null, 2));
60
61 try {
62 // Immediately respond to WhatsApp to acknowledge receipt
63 res.status(200).send('EVENT_RECEIVED');
64
65 // Process the webhook event
66 await processWebhookEvent(req.body);
67 } catch (error) {
68 console.error('Error processing webhook:', error);
69 // Note: We already sent 200, so we can't change the response now
70 // In a production scenario, you might want to implement proper error handling
71 }
72});
73
74// Process incoming webhook events
75async function processWebhookEvent(body) {
76 const entry = body.entry?.[0];
77 if (!entry) {
78 console.log('No entry found in webhook');
79 return;
80 }
81
82 const changes = entry.changes?.[0];
83 if (!changes || changes.field !== 'messages') {
84 console.log('No relevant changes found');
85 return;
86 }
87
88 const messages = changes.value.messages;
89 if (!messages || messages.length === 0) {
90 console.log('No messages found in webhook');
91 return;
92 }
93
94 for (const message of messages) {
95 await processMessage(message);
96 }
97}
98
99// Process individual message
100async function processMessage(message) {
101 console.log('Processing message:', message);
102
103 const from = message.from;
104 const messageType = message.type;
105
106 try {
107 switch (messageType) {
108 case 'text':
109 await handleTextMessage(from, message.text.body);
110 break;
111 case 'image':
112 await handleImageMessage(from, message.image);
113 break;
114 case 'audio':
115 await handleAudioMessage(from, message.audio);
116 break;
117 case 'document':
118 await handleDocumentMessage(from, message.document);
119 break;
120 case 'voiceai':
121 await sendTextMessage(from, message.text.body);
122 break;
123 default:
124 await sendTextMessage(from, `Sorry, I don't support ${messageType} messages yet.`);
125 break;
126 }
127 } catch (error) {
128 console.error(`Error processing ${messageType} message:`, error);
129 // Optionally send an error message to the user
130 await sendTextMessage(from, 'Sorry, I encountered an error processing your message.');
131 }
132}
133
134// Handle text messages
135async function handleTextMessage(from, text) {
136 console.log("Checking Voximplant WAB session for " + from);
137 try {
138 const ev = await client.KeyValueStorage.getKeyValueItem({
139 key: "WAB_" + from,
140 applicationId: process.env.VOXIMPLANT_APP_ID,
141 });
142 console.log(ev);
143 if (ev.result != undefined) {
144 console.log("WAB call exists");
145 let url = ev.result.value;
146 if (url == undefined) {
147 console.log("Voximplant session URL hasn't been found");
148 } else {
149 // make HTTP request to URL to communicate with the session
150 await sendMessageToVox(url, text);
151 }
152 } else {
153 console.log("WAB call doesn't exist");
154 }
155 } catch (err) {
156 console.error(err);
157 throw err;
158 }
159 // console.log(`Received text message from ${from}: ${text}`);
160
161 // const lowerText = text.toLowerCase().trim();
162
163 // if (lowerText.includes('hello') || lowerText.includes('hi')) {
164 // await sendTextMessage(from, 'Hello! Welcome to our service. How can I help you today?');
165 // } else if (lowerText.includes('help')) {
166 // await sendTextMessage(from, 'I can help you with:\n- Product information\n- Support questions\n- General inquiries\n\nJust ask me anything!');
167 // } else {
168 // await sendTextMessage(from, `You said: "${text}". This is an automated response.`);
169 // }
170}
171
172// Handle image messages
173async function handleImageMessage(from, image) {
174 console.log(`Received image message from ${from}`);
175 await sendTextMessage(from, 'Thanks for the image! I received it successfully.');
176}
177
178// Handle audio messages
179async function handleAudioMessage(from, audio) {
180 console.log(`Received audio message from ${from}`);
181 await sendTextMessage(from, 'Thanks for the audio message! I received it successfully.');
182}
183
184// Handle document messages
185async function handleDocumentMessage(from, document) {
186 console.log(`Received document from ${from}: ${document.filename}`);
187 await sendTextMessage(from, `Thanks for the document "${document.filename}"! I received it successfully.`);
188}
189
190// Send text message to Voximplant WAB session
191async function sendMessageToVox(url, text) {
192 try {
193 const response = await axios.post(
194 url,
195 {
196 text: { body: text },
197 type: 'text'
198 },
199 {
200 headers: {
201 'Content-Type': 'application/json'
202 }
203 }
204 );
205
206 console.log('Message to Voximplant sent successfully:', response.data);
207 return response.data;
208 } catch (error) {
209 console.error('Error sending message:', error.response?.data || error.message);
210 throw error;
211 }
212}
213
214// Send text message via WhatsApp API
215async function sendTextMessage(to, text) {
216 console.log('Sending text message to: ' + to);
217 try {
218 const response = await axios.post(
219 `${whatsappApiUrl}/messages`,
220 {
221 messaging_product: 'whatsapp',
222 to: to,
223 text: { body: text },
224 type: 'text'
225 },
226 {
227 headers: {
228 'Authorization': `Bearer ${config.whatsappToken}`,
229 'Content-Type': 'application/json'
230 }
231 }
232 );
233
234 console.log('Message sent successfully:', response.data);
235 return response.data;
236 } catch (error) {
237 console.error('Error sending message:', error.response?.data || error.message);
238 throw error;
239 }
240}
241
242// Additional function to send template messages (if needed)
243async function sendTemplateMessage(to, templateName, languageCode = 'en') {
244 try {
245 const response = await axios.post(
246 `${whatsappApiUrl}/messages`,
247 {
248 messaging_product: 'whatsapp',
249 to: to,
250 type: 'template',
251 template: {
252 name: templateName,
253 language: {
254 code: languageCode
255 }
256 }
257 },
258 {
259 headers: {
260 'Authorization': `Bearer ${config.whatsappToken}`,
261 'Content-Type': 'application/json'
262 }
263 }
264 );
265
266 console.log('Template message sent successfully:', response.data);
267 return response.data;
268 } catch (error) {
269 console.error('Error sending template message:', error.response?.data || error.message);
270 throw error;
271 }
272}
273
274// Health check endpoint
275app.get('/health', (req, res) => {
276 res.status(200).json({
277 status: 'OK',
278 message: 'WhatsApp Business API server is running',
279 timestamp: new Date().toISOString()
280 });
281});
282
283// Start server
284app.listen(PORT, () => {
285 console.log(`WhatsApp Business API server running on port ${PORT}`);
286 console.log(`Webhook URL: http://localhost:${PORT}/webhook`);
287 console.log(`Health check: http://localhost:${PORT}/health`);
288});
289
290// Graceful shutdown
291process.on('SIGTERM', () => {
292 console.log('SIGTERM received, shutting down gracefully');
293 process.exit(0);
294});
295
296process.on('SIGINT', () => {
297 console.log('SIGINT received, shutting down gracefully');
298 process.exit(0);
299});