Scenario: Any member sends an SMS/MMS to your Twilio number. Your server automatically forwards that message (including images/MMS) to the rest of the group and prefixes the body with the sender’s name.
Architecture: Twilio → Webhook (Express/Node.js) → Cloudflare Tunnel → your domain, managed with PM2.
Target device: ARM64 server (e.g., Ubuntu 24.04).
Table of Contents
- Preparation
- Installation & Project Layout
- Full Code (SMS + MMS supported)
- Cloudflare Tunnel & Domain
- Systemd configuration
- Twilio Console Settings
- Deploy & Keep Alive with PM2
- Health Checks & Integration Tests
- Troubleshooting (11200, 20003, 300xx, port conflicts, etc.)
- Compliance & Tips (A2P 10DLC, STOP/START, privacy)
1) Preparation
- A Twilio account with a phone number that supports SMS/MMS (region + MMS capability matters).
- A Cloudflare account with your domain hosted on Cloudflare (example:
info.example.com
). - An ARM64 server (Ubuntu 20.04+/24.04 recommended).
- Node.js 18+, npm, PM2, cloudflared.
Never commit your Twilio
AUTH_TOKEN
to a repo or blog! Use placeholders in examples.
2) Installation & Project Layout
# Install essentials
sudo apt update
sudo apt install -y curl build-essential nodejs=18* npm pm2
# PM2
sudo npm i -g pm2
# Cloudflare Tunnel
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
# Create project
mkdir -p ~/.cloudflared/sms-broadcast && cd ~/.cloudflared/sms-broadcast
# Initialize
npm init -y
npm i express body-parser twilio dotenv axios
# Static dir for re-hosting images
mkdir public
Project layout:
~/.cloudflared/sms-broadcast
├─ server.js
├─ .env
└─ public/ # Images re-hosted by your server live here
3) Full Code (SMS + MMS supported)
Put this (verbatim) into server.js
:
require('dotenv').config();
const express = require('express');
const bodyParser = require('body-parser');
const twilio = require('twilio');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const app = express();
const port = process.env.WEBHOOK_PORT || 3000;
const publicBaseUrl = process.env.PUBLIC_BASE_URL; // e.g., https://info.example.com
// Parse application/x-www-form-urlencoded (Twilio Webhook default)
app.use(bodyParser.urlencoded({ extended: false }));
// Ensure public directory exists and expose it
const publicDir = path.join(__dirname, 'public');
fs.mkdirSync(publicDir, { recursive: true });
app.use('/public', express.static(publicDir));
// Twilio client
const client = new twilio(
process.env.TWILIO_ACCOUNT_SID,
process.env.TWILIO_AUTH_TOKEN
);
/**
* Group members in E.164 format (+countrycode + number).
* The original sender is excluded from fanout (no self echo).
*/
const groupMembers = [
'+11234567890', // Zach
'+10987654321' // Bob
];
/** Phone number -> Display name */
const userNames = {
'+11234567890': 'Zach',
'+10987654321': 'Bob'
};
// Healthcheck / quick integration test: GET https://your-domain/sms
app.get('/sms', (_, res) => res.send('Twilio Webhook is running.'));
/** Download Twilio media to local disk and return a public URL */
async function downloadAndRehost(mediaUrl) {
const fileName = `m_${Date.now()}_${Math.random().toString(36).slice(2)}.jpg`;
const filePath = path.join(publicDir, fileName);
const response = await axios({
url: mediaUrl,
method: 'GET',
responseType: 'stream',
// Twilio media URLs require basic auth
auth: {
username: process.env.TWILIO_ACCOUNT_SID,
password: process.env.TWILIO_AUTH_TOKEN
}
});
await new Promise((resolve, reject) => {
const writer = fs.createWriteStream(filePath);
response.data.pipe(writer);
writer.on('finish', resolve);
writer.on('error', reject);
});
if (!publicBaseUrl) {
throw new Error('PUBLIC_BASE_URL is not set; cannot build public image URL');
}
return `${publicBaseUrl}/public/${fileName}`;
}
/** Twilio Webhook: inbound SMS/MMS */
app.post('/sms', async (req, res) => {
const senderNumber = req.body.From;
const rawBody = req.body.Body || '';
const messageBody = rawBody.trim();
const numMedia = parseInt(req.body.NumMedia || '0', 10);
console.log(`📩 From ${senderNumber}: ${messageBody} (NumMedia=${numMedia})`);
// Compliance: do not broadcast STOP/UNSUBSCRIBE/HELP keywords
// (Twilio handles carrier-level blocking; we skip fanout here.)
if (/^(stop|stopall|unsubscribe|cancel|end|quit)$/i.test(messageBody)) {
console.log('⏹️ STOP-like keyword received; skipping broadcast');
return res.status(200).send('OK');
}
const senderName = userNames[senderNumber] || senderNumber.replace('+', '');
const formattedMessage = `${senderName}:${messageBody}`;
// Handle media (supports multiple images)
const mediaUrls = [];
try {
for (let i = 0; i < numMedia; i++) {
const key = `MediaUrl${i}`;
const mediaUrl = req.body[key];
if (mediaUrl && mediaUrl.startsWith('http')) {
const publicUrl = await downloadAndRehost(mediaUrl);
mediaUrls.push(publicUrl);
console.log(`🖼️ Re-hosted: ${publicUrl}`);
}
}
} catch (err) {
console.error('❌ Media download/rehoming failed:', err.message);
// You can choose to continue with text-only, or return early
}
// Fanout (exclude the sender)
for (const member of groupMembers) {
if (member === senderNumber) continue;
try {
const payload = {
body: formattedMessage,
from: process.env.TWILIO_PHONE_NUMBER,
to: member
};
if (mediaUrls.length > 0) payload.mediaUrl = mediaUrls;
console.log(`📤 Sending to ${member} ...`);
const resp = await client.messages.create(payload);
console.log(`✅ Sent: ${resp.sid} -> ${member}`);
} catch (e) {
console.error(`❌ Failed -> ${member}: ${e.message}`);
}
}
// Acknowledge to Twilio
res.status(200).send('Message processed.');
});
// Start server
app.listen(port, () => {
console.log(`🚀 Webhook listening on ${port}`);
});
.env
example (replace with your values):
Never paste real secrets in public!
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_PHONE_NUMBER=+1XXXXXXXXXX
WEBHOOK_PORT=3000
PUBLIC_BASE_URL=https://info.example.com
4) Cloudflare Tunnel & Domain
Common commands:
# Log in & create a Tunnel (skip if already created)
cloudflared tunnel login
cloudflared tunnel create sms-tunnel
cloudflared tunnel route dns sms-tunnel info.example.com
~/.cloudflared/config.yml
(replace Tunnel ID):
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /home/ubuntu/.cloudflared/<YOUR_TUNNEL_ID>.json
ingress:
- hostname: info.example.com
service: http://localhost:3000
- service: http_status:404
# Run (testing only; Ctrl-C to stop)
cloudflared tunnel run sms-tunnel
You can also run cloudflared
as a systemd service (see next section).
5) Systemd configuration
sudo nano /etc/systemd/system/cloudflared.service
[Unit]
Description=Cloudflare Tunnel
After=network.target
[Service]
ExecStart=/usr/local/bin/cloudflared tunnel --config /home/ubuntu/.cloudflared/config.yml run
Restart=always
User=ubuntu
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl start cloudflared
sudo systemctl enable cloudflared
6) Twilio Console Settings
- Go to Phone Numbers → Active numbers → your number.
- Under Messaging → A MESSAGE COMES IN:
- Webhook:
https://info.example.com/sms
- HTTP Method:
HTTP POST
- Webhook:
- Save.
The “Sent from your Twilio trial account…” footer goes away after upgrading to a paid account.
7) Deploy & Keep Alive with PM2
# From project dir: ~/.cloudflared/sms-broadcast
pm2 start server.js --name twilio-sms
pm2 save
pm2 startup # Run the suggested sudo command to enable on boot
Common usage:
pm2 list
pm2 logs twilio-sms
pm2 restart twilio-sms
pm2 stop twilio-sms
More handy commands:
1) PM2 Install
npm install -g pm2
pm2 -v
2) Start server
pm2 start server.js --name twilio-sms
3) Inspect & manage
pm2 list
pm2 show twilio-sms
pm2 stop twilio-sms
pm2 restart twilio-sms
pm2 delete twilio-sms
4) Monitor & logs
pm2 monit
pm2 logs twilio-sms
pm2 logs twilio-sms --lines 100
5) Autostart on boot
pm2 startup
pm2 save
6) Bulk operations
pm2 restart all
pm2 stop all
pm2 delete all
pm2 reload twilio-sms
pm2 kill
8) Health Checks & Integration Tests
Healthcheck
curl -s https://info.example.com/sms
# Expected: Twilio Webhook is running.
Port conflicts
sudo lsof -i :3000
# If occupied, kill the process or change the port
Twilio → server connectivity
Send an SMS to your Twilio number from a phone, then check:
pm2 logs twilio-sms
# 📩 From +1xxx: hello (NumMedia=0)
# 📤 Sending to: +1yyy
# ✅ Sent: SMxxxxxxxx -> +1yyy
MMS test (images)
Send an MMS to your Twilio number. You should see:
🖼️ Re-hosted: https://info.example.com/public/m_....jpg
Group members should receive an SMS/MMS containing the image.
9) Troubleshooting
11200 (HTTP retrieval failure)
- Symptom: MMS fanout fails with 11200.
- Cause: Twilio can’t fetch the original
mediaUrl
(auth required/firewalled/not reachable). - Fix: This code first downloads media from Twilio (with SID/TOKEN) and re-hosts it under your own
/public
, then sends that public URL to group members.
20003 (Authentication Error)
- Symptom:
curl
/code gets 401 when accessing Twilio media. - Check: Are
TWILIO_ACCOUNT_SID
andTWILIO_AUTH_TOKEN
current and correct? Doecho $TWILIO_...
show values? Is.env
loaded? - Refresh: Regenerate/copy the latest token in the Twilio Console, then restart PM2.
Some members don’t receive messages
- Ensure their numbers are in
groupMembers
(E.164 format). - Check Twilio Console → SMS Logs for delivery status (
Delivered / Undelivered / Failed
). - Carrier blocking: have them reply START to opt-in.
- For U.S. at scale, complete A2P 10DLC registration (Twilio Console → Messaging → Compliance).
EADDRINUSE (port in use)
sudo lsof -i :3000
sudo kill -9 <pid>
pm2 restart twilio-sms
“Cannot GET /sms”
- You’re hitting GET: we provided
app.get('/sms')
for healthcheck. - Twilio Webhook must be POST in the console.
10) Compliance & Tips
- STOP/UNSUBSCRIBE/HELP: Twilio enforces carrier-level blocking; your server should not broadcast these (the code skips STOP-like keywords).
- A2P 10DLC (U.S.): For business/app use, register for better deliverability and fewer blocks.
- Privacy & Security: Don’t commit
.env
. Avoid logging secrets. Use/public
strictly for media. - Media expiry: Twilio’s original media URLs may expire; re-hosting prevents breakage.
- Number format: Always use E.164 (e.g.,
+1XXXXXXXXXX
).
Conclusion
You now have a stable group SMS/MMS broadcaster:
- Member → Twilio → your service adds a display name → everyone else in the group receives it (including images).
- Cloudflare Tunnel means no exposed ports; PM2 keeps it running; logs are clear for quick debugging.
Comments NOTHING