Home / Articles

Sending and Receiving SMS in Node.js with Twilio Webhooks and Express

Build a small Express server that sends texts through Twilio, receives replies via a webhook exposed with ngrok, and answers them automatically with TwiML.

Publisher: REACTAPP.TOP6 min read1483 words

This article is published in English.

Sources: **Build a Two-Way SMS with Node.js and Twilio (Full Code Included)**

Most SMS tutorials stop once a message leaves your server. The more useful half is inbound: noticing that a user replied, reading the text and responding. That loop powers OTP flows, support bots and appointment reminders people confirm with one word. By the end of this walkthrough you will have an Express server that texts a phone and reacts whenever that phone writes back.

Prerequisites

  • A Twilio account. At the time of writing the free trial includes roughly $15 of credit, plenty for experimenting.
  • Postman or any HTTP client.
  • Node.js installed locally.
  • ngrok, which gives Twilio a public address that forwards to your laptop.

A minimal Express server

Create a folder with a server.js file. The server binds to localhost on port 3000, enables JSON and URL-encoded body parsing, and exposes a health-check route under /api/v1/. The URL-encoded parser matters: Twilio delivers incoming messages as form-encoded POST bodies, so without it req.body would be empty later.

const express = require("express");
const bodyParser = require("body-parser");
const hostname = "127.0.0.1";
const port = 3000;
const app = express();

app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

const baseUrl = "/api/v1/";

app.get(`${baseUrl}`, (req, res) => {
  res.status(200).json({ message: "Hello World" });
});
app.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Install the dependencies:

npm init -y
npm install express body-parser

Run node server.js and open http://127.0.0.1:3000/api/v1/; a "Hello World" response confirms the basics. Express 4.16+ also ships express.json() and express.urlencoded(), so body-parser is optional in new projects.

Exposing localhost with ngrok

Twilio lives on the public internet and your server on a private machine, so Twilio cannot reach it directly. ngrok opens a tunnel and hands you a public HTTPS URL forwarding to a local port. Heroku used to be the free option for this, but its free tier is gone.

Install and authenticate ngrok using the ngrok docs, then add a script to package.json:

"scripts": {
  "ngrok": "ngrok http http://127.0.0.1:3000/"
}

Keep the server running in one terminal, then start the tunnel in a second:

npm run ngrok

ngrok prints an address such as https://abcd-1234.ngrok-free.app. Leave that terminal open.

Watch out: on the free plan the generated URL usually changes every time ngrok restarts. When a working webhook suddenly goes quiet, a stale URL in the Twilio console is the likely cause, so update it after each restart.

Configuring Twilio

In the console dashboard, copy your Account SID and Auth Token into a .env file. The token is a secret: never commit it, and list .env in .gitignore.

ACCOUNT_SID_TWILIO=your_account_sid
AUTH_TOKEN_TWILIO=your_auth_token

You can pin products in the side navigation; only Messaging and Phone Numbers matter here.

Getting a phone number

  1. Open Phone Numbers, then Buy a number.
  2. Pick any available number; US numbers tend to be cheapest.
  3. The cost comes out of the trial balance and the number appears among your active numbers.

Creating a Messaging Service

  1. Under Messaging, create a new Messaging Service.
  2. In the Sender Pool step, add your number.
  3. In the Integration step, choose Send a webhook and set both Request URL and Fallback URL to your ngrok address with the /api/v1/sms path appended. That route is built below.
  4. Skip the optional step and finish.

Store the Messaging Service SID and your number in .env:

FROM_TWILIO=+1XXXXXXXXXX
MESSAGING_SERVICE_SID_TWILIO=your_messaging_service_sid

Wiring the Twilio SDK into Node

Install the SDK plus dotenv, which loads .env into process.env:

npm install twilio dotenv

Put this at the very top of server.js. Order matters: the client reads credentials when it is created, so if dotenv loads later both values are undefined and every call fails authentication.

require("dotenv").config();

const client = require("twilio")(
  process.env.ACCOUNT_SID_TWILIO,
  process.env.AUTH_TOKEN_TWILIO
);

Sending an outbound text

This route calls client.messages.create() and reports the outcome as JSON. The try/catch returns Twilio errors, such as a malformed number, to the caller instead of crashing the process.

app.post(`${baseUrl}sendSms`, async (req, res) => {
  try {
    await client.messages.create({
      body: "Hello",
      from: process.env.FROM_TWILIO,
      messagingServiceSid: process.env.MESSAGING_SERVICE_SID_TWILIO,
      to: "+92XXXXXXXXXX", // whoever you're texting
    });
    res.status(200).json({ message: "Text sent successfully" });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

What the fields mean:

  • body: the message text.
  • from: your Twilio number.
  • messagingServiceSid: links the message to the service and its sender pool.
  • to: the recipient in E.164 format (+1..., +92...).

Call POST /api/v1/sendSms from Postman and the phone should receive "Hello". In real code, read to and body from the request and validate them rather than hardcoding.

Trial accounts can only text numbers verified in the Twilio console. If the request succeeds but nothing arrives, verify the recipient or upgrade.

Receiving replies through the webhook

When someone texts your Twilio number, Twilio POSTs the message to the webhook configured in the Messaging Service. Your server accepts it and answers with TwiML, Twilio's XML format for describing the next action. Import the helper with your other requires:

const MessagingResponse = require("twilio").twiml.MessagingResponse;

Then add the listener. The classic mistake is a path mismatch: the route must equal the path entered in the console, here /api/v1/sms. The handler logs req.body.Body and returns an empty TwiML document as text/xml, meaning no reply.

app.post(`${baseUrl}sms`, (req, res) => {
  // Fires every time someone replies to your number
  const twiml = new MessagingResponse();
console.log("INCOMING ----->", req.body.Body);
  res.writeHead(200, { "Content-Type": "text/xml" });
  res.end(twiml.toString());
});

Text your Twilio number and the message shows up in the terminal; the loop is closed. The payload also carries From, the sender's number, which you need once you track conversations per user.

Because the endpoint is public, anyone who finds the URL can POST fake messages. Twilio signs each webhook with your Auth Token in the X-Twilio-Signature header, and the Node SDK includes validation helpers. Turn them on before deploying beyond a local test.

Replying automatically

The server listens but stays silent. Adding a message to the TwiML response turns it into a keyword bot. Place this inside the /sms handler after twiml is created; it trims and lowercases the text before choosing a reply:

const incoming = req.body.Body.trim().toLowerCase();
if (incoming === "hi") {
  twiml.message("Hey there! How can I help?");
} else {
  twiml.message("Hmm, I didn't catch that.");
}

Twilio sends the returned reply to the sender, so no extra messages.create() call is needed.

The complete server

Everything in one file, with the auto-reply left commented out:

require("dotenv").config();

const express = require("express");
const bodyParser = require("body-parser");
const MessagingResponse = require("twilio").twiml.MessagingResponse;
const client = require("twilio")(
  process.env.ACCOUNT_SID_TWILIO,
  process.env.AUTH_TOKEN_TWILIO
);
const hostname = "127.0.0.1";
const port = 3000;
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

const baseUrl = "/api/v1/";
// Health check
app.get(`${baseUrl}`, (req, res) => {
  res.status(200).json({ message: "Hello World" });
});

// Goal 1: Send an SMS
app.post(`${baseUrl}sendSms`, async (req, res) => {
  try {
    await client.messages.create({
      body: "Hello",
      from: process.env.FROM_TWILIO,
      messagingServiceSid: process.env.MESSAGING_SERVICE_SID_TWILIO,
      to: "+92XXXXXXXXXX", // whoever you're texting
    });
    res.status(200).json({ message: "Text sent successfully" });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Goal 2: Receive replies (this endpoint must match your Twilio webhook)
app.post(`${baseUrl}sms`, (req, res) => {
  const twiml = new MessagingResponse();
  console.log("INCOMING ----->", req.body.Body);
  // Optional auto-reply:
  // const incoming = req.body.Body.trim().toLowerCase();
  // if (incoming === "hi") {
  //   twiml.message("Hey there! How can I help?");
  // } else {
  //   twiml.message("Hmm, I didn't catch that.");
  // }
  res.writeHead(200, { "Content-Type": "text/xml" });
  res.end(twiml.toString());
});

app.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

Key takeaways

  • Outbound SMS is one SDK call; inbound SMS is a webhook, so the server must be reachable and speak TwiML.
  • Twilio posts form-encoded data, hence the URL-encoded parser.
  • The console webhook path and the Express route must match, and a restarted free tunnel means a new URL.
  • Silent test failures are often the verified-numbers restriction, not a bug.
  • Validate signatures and remove hardcoded values before real use.

From here you can persist conversations, grow the keyword bot, or add OTP login. For wider patterns around callbacks and retries, see six integration patterns for connecting Node.js services reliably.