Build a “Group SMS/MMS Broadcaster” with Twilio + Node.js + Cloudflare Tunnel (on ARM64)

zach Posted on 25 days ago 53 Views HackMech


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
  • 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 and TWILIO_AUTH_TOKEN current and correct? Do echo $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.