This guide details how to build a web application enabling users to send bulk SMS or WhatsApp messages using MessageBird. We'll create a Vue frontend using Vite for a fast development experience and a Node.js backend API to securely interact with the MessageBird service.
This application solves the common need to send announcements, notifications, or marketing messages to a list of recipients efficiently without manually messaging each one. By the end, you'll have a functional frontend to input recipients and a message, a backend API to handle the sending logic via MessageBird, and an understanding of key considerations for a production environment.
Technologies Used:
- Vite: A modern frontend build tool providing a fast development server and optimized builds.
- Vue 3: A progressive JavaScript framework for building user interfaces (Composition API).
- Node.js: A JavaScript runtime for building the backend API.
- Express: A minimal and flexible Node.js web application framework for the API.
- MessageBird: The communication platform API used for sending SMS/WhatsApp messages.
- dotenv: A module to load environment variables from a
.env
file.
System Architecture:
+-----------------+ +--------------------+ +------------------+
| Vue Frontend |----->| Node.js Backend API|----->| MessageBird API |
| (Vite/Vue) | | (Express) | | (SMS/WhatsApp) |
| - Broadcast Form| | - Validate Request | +------------------+
| - API Call | | - Use API Key |
+-----------------+ | - Loop & Send |
| - Handle Responses |
+--------------------+
|
V
+------------------+
| Database (Optional)|
| - Store Recipients |
| - Log Messages |
+------------------+
Prerequisites:
- Node.js and npm (or yarn) installed. Download Node.js
- A MessageBird account. Sign up for MessageBird
- Your MessageBird Live API Key.
- Basic understanding of Vue.js, Node.js, and REST APIs.
1. Setting up the Project
We'll set up both the frontend (Vue/Vite) and the backend (Node.js/Express) projects.
Frontend Setup (Vite + Vue)
-
Create Vite Project: Open your terminal and run the Vite scaffolding command. Choose
vue
andvue-ts
orvue
(JavaScript) as you prefer. This guide will use JavaScript.npm create vite@latest sent-messagebird-broadcast --template vue
-
Navigate and Install Dependencies:
cd sent-messagebird-broadcast npm install
-
Project Structure (Frontend): Vite creates a standard structure. We'll primarily work within the
src
directory:sent-messagebird-broadcast/ ├── public/ ├── src/ │ ├── assets/ │ ├── components/ # We'll add our form component here │ ├── App.vue # Main App component │ └── main.js # App entry point ├── index.html ├── package.json └── vite.config.js
-
Run Development Server:
npm run dev
This starts the Vite development server, typically at
http://localhost:5173
. You should see the default Vue welcome page.
Backend Setup (Node.js + Express)
-
Create Backend Directory: Inside your main project folder (
sent-messagebird-broadcast
), create a directory for the backend API.mkdir backend cd backend
-
Initialize Node.js Project:
npm init -y
This creates a
package.json
file. -
Install Backend Dependencies: We need Express for the server,
dotenv
for environment variables, the MessageBird SDK, andcors
for handling cross-origin requests from the frontend.npm install express dotenv @messagebird/api cors
-
Project Structure (Backend):
sent-messagebird-broadcast/ ├── backend/ │ ├── node_modules/ │ ├── .env # Store API keys here (DO NOT COMMIT) │ ├── server.js # Our main API server file │ └── package.json ├── src/ # Frontend code └── ... (other frontend files)
-
Create
.env
File: Create a file named.env
in thebackend
directory. Add your MessageBird Live API Key. Important: Add.env
to your.gitignore
file to avoid committing secrets.# Replace with your actual MessageBird Live API key obtained from the dashboard MESSAGEBIRD_API_KEY=YOUR_LIVE_API_KEY_HERE # Optional: Replace with your registered phone number (E.164 format) or Alphanumeric Sender ID MESSAGEBIRD_ORIGINATOR=YOUR_SENDER_ID_OR_NUMBER
-
Create Basic Server (
server.js
):require('dotenv').config(); const express = require('express'); const cors = require('cors'); const messagebird = require('@messagebird/api')(process.env.MESSAGEBIRD_API_KEY); const app = express(); const port = process.env.PORT || 3001; // Use environment port or default // Middleware app.use(cors()); // Enable CORS for requests from frontend app.use(express.json()); // Parse JSON request bodies // Basic health check route app.get('/health', (req, res) => { res.status(200).send('OK'); }); // Placeholder for our broadcast route app.post('/api/broadcast', (req, res) => { // Logic will go here in Section 3 & 4 console.log('Received broadcast request:', req.body); res.status(200).json({ message: 'Broadcast request received (placeholder)' }); }); // Start the server app.listen(port, () => { console.log(`Backend server listening on port ${port}`); if (!process.env.MESSAGEBIRD_API_KEY) { console.warn('WARNING: MESSAGEBIRD_API_KEY environment variable not set.'); } if (!process.env.MESSAGEBIRD_ORIGINATOR) { console.warn('WARNING: MESSAGEBIRD_ORIGINATOR environment variable not set. Using default.'); } });
-
Run Backend Server: Open a new terminal window, navigate to the
backend
directory, and run:node server.js
You should see
Backend server listening on port 3001
.
Now you have both frontend and backend development servers running.
2. Implementing Core Functionality (Frontend Form)
Let's create the Vue component for users to enter recipients and their message.
-
Create Component File: In the frontend project, create
src/components/BroadcastForm.vue
. -
Implement the Form: Add the following code to
BroadcastForm.vue
. We use Vue 3's Composition API (setup
script).<template> <div class=""broadcast-form""> <h2>Send Bulk Message</h2> <form @submit.prevent=""handleBroadcastSubmit""> <div class=""form-group""> <label for=""recipients"">Recipients (Phone numbers, one per line):</label> <textarea id=""recipients"" v-model=""recipientsInput"" rows=""10"" placeholder=""e.g._ +14155552671 +442071838750"" required ></textarea> <small>Enter numbers in E.164 format (e.g., +14155552671).</small> </div> <div class=""form-group""> <label for=""message"">Message:</label> <textarea id=""message"" v-model=""messageBody"" rows=""5"" placeholder=""Enter your broadcast message here..."" required maxlength=""1600"" ></textarea> <small>{{ messageBody.length }} / 1600 characters</small> </div> <button type=""submit"" :disabled=""isLoading""> {{ isLoading ? 'Sending...' : 'Send Broadcast' }} </button> </form> <div v-if=""feedbackMessage"" :class=""['feedback'_ feedbackStatus]""> {{ feedbackMessage }} </div> </div> </template> <script setup> import { ref } from 'vue'; const recipientsInput = ref(''); const messageBody = ref(''); const isLoading = ref(false); const feedbackMessage = ref(''); const feedbackStatus = ref(''); // 'success' or 'error' // Function to parse recipients from textarea const parseRecipients = (input) => { return input .split('\n') // Split by newline .map(line => line.trim()) // Trim whitespace .filter(line => line.length > 0); // Remove empty lines }; const handleBroadcastSubmit = async () => { isLoading.value = true; feedbackMessage.value = ''; feedbackStatus.value = ''; const recipients = parseRecipients(recipientsInput.value); if (recipients.length === 0) { feedbackMessage.value = 'Please enter at least one recipient.'; feedbackStatus.value = 'error'; isLoading.value = false; return; } if (!messageBody.value.trim()) { feedbackMessage.value = 'Message body cannot be empty.'; feedbackStatus.value = 'error'; isLoading.value = false; return; } try { // API Endpoint URL - Ensure your backend runs on port 3001 const apiUrl = 'http://localhost:3001/api/broadcast'; const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ recipients: recipients, message: messageBody.value.trim(), }), }); const result = await response.json(); if (!response.ok) { // Handle HTTP errors (e.g., 4xx, 5xx) throw new Error(result.error || `Server responded with ${response.status}`); } // Assuming backend returns { success: true, message: '...', details: [...] } on success // And { success: false, error: '...' } on failure if (result.success) { feedbackMessage.value = `Broadcast submitted successfully. Status: ${result.message || 'Processing initiated.'}`; feedbackStatus.value = 'success'; // Optionally clear the form on success // recipientsInput.value = ''; // messageBody.value = ''; } else { throw new Error(result.error || 'An unknown error occurred on the server.'); } } catch (error) { console.error('Error sending broadcast:', error); feedbackMessage.value = `Failed to send broadcast: ${error.message}`; feedbackStatus.value = 'error'; } finally { isLoading.value = false; } }; </script> <style scoped> .broadcast-form { max-width: 600px; margin: 2rem auto; padding: 2rem; border: 1px solid #ccc; border-radius: 8px; background-color: #f9f9f9; } .form-group { margin-bottom: 1.5rem; } label { display: block; margin-bottom: 0.5rem; font-weight: bold; } textarea { width: 100%; padding: 0.75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; box-sizing: border-box; /* Include padding and border in element's total width */ } textarea:focus { border-color: #007bff; outline: none; } small { display: block; margin-top: 0.25rem; font-size: 0.8rem; color: #666; } button { padding: 0.75rem 1.5rem; background-color: #007bff; color: white; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: background-color 0.2s ease; } button:hover { background-color: #0056b3; } button:disabled { background-color: #ccc; cursor: not-allowed; } .feedback { margin-top: 1.5rem; padding: 1rem; border-radius: 4px; text-align: center; } .feedback.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .feedback.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } </style>
- Why: We use
v-model
for two-way data binding between the textareas and reactive refs (recipientsInput
,messageBody
). ThehandleBroadcastSubmit
function prevents default form submission, parses recipients, performs basic validation, and usesfetch
to send data to our backend API. It handles loading states and displays feedback messages based on the API response.
- Why: We use
-
Use the Component in
App.vue
: Replace the content ofsrc/App.vue
to include our new form.<template> <div id=""app""> <header> <h1>MessageBird Bulk Broadcaster</h1> </header> <main> <BroadcastForm /> </main> </div> </template> <script setup> import BroadcastForm from './components/BroadcastForm.vue'; </script> <style> /* Add some basic global styles if needed */ #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: #2c3e50; margin-top: 60px; display: flex; flex-direction: column; align-items: center; } header { margin-bottom: 2rem; } main { width: 100%; max-width: 700px; /* Limit form width */ padding: 0 1rem; } </style>
If you check your browser (where the frontend npm run dev
is running), you should now see the broadcast form. Submitting it will currently log a message in the backend console and show a placeholder success message in the frontend, as the backend logic isn't fully implemented yet.
3. Building a Complete API Layer (Backend)
Let's enhance the backend POST /api/broadcast
endpoint to handle the request properly, validate input, and prepare for MessageBird integration.
-
Update
server.js
: Modify the/api/broadcast
route handler inbackend/server.js
.// ... (keep existing require statements and middleware setup) ... // Updated broadcast route app.post('/api/broadcast', async (req, res) => { // Mark as async const { recipients, message } = req.body; // --- 1. Input Validation --- if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ success: false, error: 'Recipients array is missing or empty.' }); } if (!message || typeof message !== 'string' || message.trim().length === 0) { return res.status(400).json({ success: false, error: 'Message is missing or empty.' }); } if (message.length > 1600) { // Example length limit return res.status(400).json({ success: false, error: 'Message exceeds maximum length of 1600 characters.' }); } // Basic E.164 format check (can be improved with regex) const invalidRecipients = recipients.filter(r => typeof r !== 'string' || !r.startsWith('+') || r.length < 10); if (invalidRecipients.length > 0) { return res.status(400).json({ success: false, error: `Invalid recipient format found. Ensure all numbers start with '+' and are in E.164 format. Problematic entries: ${invalidRecipients.slice(0, 5).join(', ')}...` }); } // --- 2. Authentication/Authorization (Placeholder) --- // In a real app, you'd verify user identity/permissions here. // For now, we assume the request is authorized if it reaches here. console.log('User authorized (placeholder). Proceeding with broadcast.'); // --- 3. MessageBird Interaction (Implemented in Section 4) --- try { console.log(`Attempting to send message ""${message}"" to ${recipients.length} recipients.`); // Add MessageBird sending logic here in the next step // For now, simulate success const results = recipients.map(r => ({ recipient: r, status: 'submitted', id: `fake_msg_id_${Math.random().toString(16).slice(2)}` })); console.log('Placeholder: MessageBird processing would happen here.'); // --- 4. Respond to Frontend --- res.status(200).json({ success: true, message: `Broadcast request submitted for ${recipients.length} recipients.`, details: results // Provide some detail back }); } catch (error) { console.error('Error during broadcast processing:', error); // This catch block will handle errors from the MessageBird sending logic later res.status(500).json({ success: false, error: 'An internal server error occurred while processing the broadcast.' }); } }); // ... (keep existing health check route and app.listen) ...
- Why: We added robust input validation checks for the
recipients
array and themessage
string, including format checks (basic E.164 check) and length limits. We added a placeholder comment for where authentication/authorization logic would go in a real-world application. The main logic block is wrapped in atry...catch
to handle potential errors during processing (especially when we add MessageBird calls). We simulate a successful response structure that the frontend expects.
- Why: We added robust input validation checks for the
-
Testing the Endpoint: You can test this endpoint using
curl
or a tool like Postman.Curl Example: (Run from your terminal)
curl -X POST http://localhost:3001/api/broadcast \ -H ""Content-Type: application/json"" \ -d '{ ""recipients"": [""+14155550100"", ""+14155550101""], ""message"": ""Hello from the API test!"" }'
Expected Response (JSON):
{ ""success"": true, ""message"": ""Broadcast request submitted for 2 recipients."", ""details"": [ { ""recipient"": ""+14155550100"", ""status"": ""submitted"", ""id"": ""fake_msg_id_..."" }, { ""recipient"": ""+14155550101"", ""status"": ""submitted"", ""id"": ""fake_msg_id_..."" } ] }
Test edge cases like sending empty recipients, an empty message, or malformed numbers to ensure the validation works.
4. Integrating with MessageBird
Now, let's integrate the MessageBird SDK into our backend API to actually send the messages.
-
Get MessageBird Credentials:
- Log in to your MessageBird Dashboard.
- Navigate to Developers > API access (or similar path).
- Find your Live API key. Copy this key.
- Ensure this key is securely stored in your
backend/.env
file asMESSAGEBIRD_API_KEY
. - Decide on your Originator (Sender ID). This can be:
- A phone number you own (purchased through MessageBird or verified).
- An Alphanumeric Sender ID (e.g., ""MyCompany"") - note that replies are not possible with Alphanumeric IDs, and they require pre-registration in some countries.
- Add your chosen Originator to the
backend/.env
file asMESSAGEBIRD_ORIGINATOR
.
-
Implement Sending Logic in
server.js
: Replace the placeholder comment in thetry
block of the/api/broadcast
route with the MessageBird sending logic.// ... (require statements, middleware, validation) ... app.post('/api/broadcast', async (req, res) => { const { recipients, message } = req.body; // --- Input Validation (Keep as before) --- // ... // --- Authentication/Authorization (Keep placeholder) --- // ... const originator = process.env.MESSAGEBIRD_ORIGINATOR; if (!originator) { console.error('MessageBird Originator (sender ID/number) is not configured in .env'); return res.status(500).json({ success: false, error: 'Server configuration error: Missing sender originator.' }); } try { console.log(`Sending message via MessageBird from ${originator} to ${recipients.length} recipients.`); const messagePromises = recipients.map(recipient => { return new Promise((resolve, reject) => { const params = { originator: originator, recipients: [recipient], // Send one API call per recipient for individual status tracking body: message, // Optional: Add type for WhatsApp, reportUrl for status updates etc. // type: 'sms', // Default is sms // reportUrl: 'YOUR_WEBHOOK_URL' // See Section 10 }; messagebird.messages.create(params, (err, response) => { if (err) { console.error(`Failed to send message to ${recipient}:`, err); // Resolve even on error to not fail the entire batch, but include error status resolve({ recipient: recipient, status: 'failed', error: err.errors || err.message || 'Unknown error' }); } else { // Log successful submission console.log(`Message submitted for ${recipient}, ID: ${response.id}`); resolve({ recipient: recipient, status: 'submitted', id: response.id, details: response }); } }); }); }); // Wait for all message submissions to complete (or fail individually) const results = await Promise.all(messagePromises); const successfulSubmissions = results.filter(r => r.status === 'submitted').length; const failedSubmissions = results.filter(r => r.status === 'failed').length; console.log(`Broadcast attempt complete. Success: ${successfulSubmissions}, Failed: ${failedSubmissions}`); // Respond with overall status and detailed results res.status(200).json({ success: true, // Indicate the API call succeeded, even if some messages failed submission message: `Broadcast submitted. ${successfulSubmissions} successful, ${failedSubmissions} failed submissions.`, details: results }); } catch (error) { // Catch errors during the Promise.all or other logic console.error('Critical error during broadcast processing:', error); res.status(500).json({ success: false, error: 'An internal server error occurred while processing the broadcast.' }); } }); // ... (health check, app.listen) ...
- Why:
- We retrieve the
MESSAGEBIRD_ORIGINATOR
from environment variables. - We iterate through the validated
recipients
array. - For each recipient, we create parameters (
params
) including the originator, the single recipient, and the message body. Sending one API call per recipient allows for individual status tracking and error handling, which is generally better for bulk sends than putting all recipients in one API call (though MessageBird supports that too). - We use
messagebird.messages.create(params, callback)
to initiate the sending. - Crucially, we wrap each
messagebird.messages.create
call in aPromise
. This allows us to manage the asynchronous nature of these calls. The promiseresolves
even if the MessageBird API returns an error for a specific number, capturing the failure details. This prevents one failed number from stopping the entire batch. Promise.all(messagePromises)
waits for all the individual send attempts to complete (either successfully submitted or failed).- We collect the
results
array, containing the status for each recipient. - Finally, we send a
200 OK
response back to the frontend, including a summary message and the detailedresults
array. The frontend can then interpret this data.
- We retrieve the
- Why:
-
Restart Backend: Stop (
Ctrl+C
) and restart your backend server (node server.js
) to apply the changes. -
Test: Use the frontend application to send a message to one or two real phone numbers (including your own) that you can verify. Check your phone and the MessageBird Dashboard logs (Logging > Messages) to confirm messages are sent. Observe the backend console logs and the feedback message in the frontend.
5. Error Handling, Logging, and Retry Mechanisms
Production systems need robust error handling and logging.
Backend Enhancements:
-
Improved Logging: Replace
console.log
/console.error
with a more structured logger likewinston
orpino
for production. For this guide, we'll stick toconsole
, but highlight its limitations.- Why: Structured logging (e.g., JSON format) makes logs easier to parse, filter, and analyze with log management tools (like Datadog, Splunk, ELK stack). It allows adding context (request IDs, user IDs) to log entries.
-
Specific Error Handling: The current
try...catch
aroundPromise.all
handles errors during the mapping orPromise.all
itself. The individual promise wrappers handle errors from the MessageBird API for each message.- Distinguish between:
- Submission Errors: MessageBird API rejects the request immediately (invalid number, API key error, insufficient balance). Our current code logs these per recipient.
- Delivery Errors: MessageBird accepts the message, but it later fails to deliver (handset off, number blocked). These require Webhooks for reliable tracking (See Section 10).
- Network/Server Errors: Problems connecting to MessageBird or internal server issues.
- Distinguish between:
-
Retry Mechanisms (Conceptual): For transient errors (e.g., temporary network issues connecting to MessageBird), implement a retry strategy.
- Strategy: Use libraries like
async-retry
or implement manually with exponential backoff (wait longer between retries). - Implementation Sketch (Conceptual - not added to main code for brevity):
// Conceptual retry logic for a single message send const { retry } = require('async-retry'); // npm install async-retry async function sendMessageWithRetry(params) { await retry(async bail => { // bail is a function to call if the error is not retryable return new Promise((resolve, reject) => { messagebird.messages.create(params, (err, response) => { if (err) { // Decide if the error is retryable (e.g., network error, rate limit) // or permanent (e.g., invalid number, auth error) if (isRetryableError(err)) { console.warn(`Retrying message to ${params.recipients[0]} due to: ${err.message}`); return reject(err); // Reject to trigger retry } else { console.error(`Non-retryable error for ${params.recipients[0]}:`, err); // Bail out - don't retry, but resolve the outer promise with failure return bail(new Error(`Permanent failure for ${params.recipients[0]}: ${err.message}`)); } } resolve({ /* success details */ }); }); }); }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay ms onRetry: (error, attempt) => { console.log(`Attempt ${attempt} failed for ${params.recipients[0]}. Retrying...`); } }).catch(finalError => { // This catch handles the case where all retries failed, or bail() was called console.error(`All retries failed for ${params.recipients[0]}:`, finalError); // Resolve the outer promise with failure status return Promise.resolve({ recipient: params.recipients[0], status: 'failed', error: finalError.message }); }); } // In the main loop, call sendMessageWithRetry instead of the direct Promise wrapper // const messagePromises = recipients.map(recipient => sendMessageWithRetry({ /* params */ }));
- Why: Retries improve resilience against temporary glitches without manual intervention. Exponential backoff prevents overwhelming the service during widespread issues.
- Strategy: Use libraries like
Frontend Enhancements:
-
Clearer Feedback: The current feedback is okay, but could be more detailed based on the
details
array returned from the backend.<!-- Inside BroadcastForm.vue template, update feedback section --> <div v-if=""feedbackMessage"" :class=""['feedback'_ feedbackStatus]""> <p>{{ feedbackMessage }}</p> <ul v-if=""detailedResults.length > 0"" class=""detailed-results""> <li v-for=""result in detailedResults"" :key=""result.recipient || result.id""> {{ result.recipient }}: <strong :class=""result.status === 'submitted' ? 'status-success' : 'status-error'""> {{ result.status }} </strong> <small v-if=""result.status === 'failed'""> - {{ result.error?.message || result.error || 'Failed' }}</small> <small v-if=""result.status === 'submitted'""> - ID: {{ result.id }}</small> </li> </ul> </div> <!-- Inside BroadcastForm.vue script setup --> import { ref, computed } from 'vue'; // Import computed // ... existing refs ... const detailedResults = ref([]); // Add ref for detailed results // Inside handleBroadcastSubmit, in the success block: if (result.success) { feedbackMessage.value = result.message || `Broadcast submitted successfully.`; feedbackStatus.value = 'success'; detailedResults.value = result.details || []; // Store detailed results } else { throw new Error(result.error || 'An unknown error occurred on the server.'); } // Inside handleBroadcastSubmit, in the catch block: feedbackMessage.value = `Failed to send broadcast: ${error.message}`; feedbackStatus.value = 'error'; detailedResults.value = []; // Clear details on error // Inside handleBroadcastSubmit, at the beginning: detailedResults.value = []; // Clear details before new submission
Add corresponding styles for
.detailed-results
,.status-success
,.status-error
.- Why: Shows per-recipient status, giving the user more insight into partial failures or providing message IDs for successful submissions.
6. Creating a Database Schema and Data Layer (Optional)
While not strictly required for basic sending, storing data enhances the application:
- Recipient Management: Store lists of recipients for reuse.
- Message History: Log sent messages, their content, recipients, and status.
- Audit Trails: Track who sent what and when.
- Status Tracking: Update message status via webhooks.
Conceptual Schema (using Prisma as an example ORM):
// prisma/schema.prisma
datasource db {
provider = ""postgresql"" // or mysql, sqlite
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
passwordHash String // Store hashed passwords only!
createdAt DateTime @default(now())
broadcasts Broadcast[]
}
model RecipientList {
id Int @id @default(autoincrement())
name String
recipients Recipient[]
createdAt DateTime @default(now())
// Optional: Link to a User if lists are user-specific
// userId Int?
// user User? @relation(fields: [userId], references: [id])
}
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format
firstName String?
lastName String?
createdAt DateTime @default(now())
lists RecipientList[] // Many-to-many relationship
broadcastMessages BroadcastMessageLog[]
}
model Broadcast {
id Int @id @default(autoincrement())
me