Back to Blog
Tutorial10 min read2026-07-02

How to Deploy a Twilio SMS Webhook App

A practical walkthrough for building and deploying a Twilio SMS webhook handler that validates signatures, replies with TwiML, and survives carrier retries in production.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

# How to Deploy a Twilio SMS Webhook App

When someone texts your Twilio number, Twilio makes an HTTP POST to a URL you control. That URL has to be publicly reachable, fast, and secure — if it times out or returns a 5xx, Twilio retries, and you can end up double-processing messages. This guide builds a small but production-grade SMS webhook handler and deploys it so it stays up.

How Twilio webhooks actually work

Twilio's flow is simple but has sharp edges:

  1. 1An inbound SMS hits your number.
  2. 2Twilio POSTs application/x-www-form-urlencoded fields (From, To, Body, MessageSid, etc.) to your configured webhook URL.
  3. 3Your endpoint returns TwiML (XML) to reply, or an empty 200 to do nothing.
  4. 4Twilio signs every request with an X-Twilio-Signature header. You must validate it — otherwise anyone can forge inbound messages.

The two non-negotiables: validate the signature, and respond within Twilio's timeout window (it expects a quick reply and will retry on failure).

The handler

A minimal Express app using the official twilio SDK:

// index.js
const express = require('express');
const twilio = require('twilio');

const app = express();
app.use(express.urlencoded({ extended: false }));

const authToken = process.env.TWILIO_AUTH_TOKEN;

// Validate signature on every inbound request
function validateTwilio(req, res, next) {
  const signature = req.headers['x-twilio-signature'];
  const url = `https://${req.headers.host}${req.originalUrl}`;
  const valid = twilio.validateRequest(authToken, signature, url, req.body);
  if (!valid) return res.status(403).send('Invalid signature');
  next();
}

app.post('/sms', validateTwilio, (req, res) => {
  const { Body, From } = req.body;
  const twiml = new twilio.twiml.MessagingResponse();

  if (/^help$/i.test(Body.trim())) {
    twiml.message('Commands: STATUS, STOP, HELP');
  } else {
    twiml.message(`Got your message: "${Body}". Reply HELP for options.`);
  }

  res.type('text/xml').send(twiml.toString());
});

app.get('/healthz', (_req, res) => res.send('ok'));

app.listen(process.env.PORT || 3000);

A couple of details that bite people:

  • The signature is computed over the exact public URL Twilio called, including scheme and query string. If you terminate TLS at a proxy, the inbound Host and protocol must reflect the public URL. On a platform that runs HTTPS in front of you, build the URL with https:// explicitly, as above.
  • Keep TWILIO_AUTH_TOKEN in an environment variable, never in code.

Idempotency: handle retries

Twilio retries on timeouts and 5xx responses. If your handler has side effects (charging a card, sending an email), dedupe on MessageSid:

const seen = new Set(); // use Redis/Postgres in production
app.post('/sms', validateTwilio, (req, res) => {
  const sid = req.body.MessageSid;
  if (seen.has(sid)) return res.status(200).end();
  seen.add(sid);
  // ... process once
});

An in-memory set resets on restart and doesn't span replicas — use a managed datastore for anything real. A MessageSid row in Postgres with a unique constraint is the simplest durable approach.

Containerize it

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

If you'd rather not write a Dockerfile, buildpack-based platforms auto-detect Node and run npm start — but committing a Dockerfile keeps builds reproducible.

Deploy on PandaStack

Webhook handlers are the canonical "small always-on service" — exactly what a container app platform is for.

  1. 1Push the repo to GitHub.
  2. 2In the [PandaStack dashboard](https://dashboard.pandastack.io), create a container app and connect the repo. It detects the Dockerfile (or auto-buildpacks Node), builds with rootless BuildKit in an ephemeral Kubernetes Job, and deploys via Helm.
  3. 3Add TWILIO_AUTH_TOKEN as an environment variable.
  4. 4You get an HTTPS URL with automatic SSL. Paste https:///sms into your Twilio number's A MESSAGE COMES IN webhook field (Console → Phone Numbers).

One caveat for signature validation: on the free tier, apps use KEDA scale-to-zero, so the first request after idle incurs a cold start. Twilio is fairly tolerant of one slow request, but if you're handling steady traffic, run on a paid compute tier to keep at least one instance warm. SMS volume that matters is worth a warm instance.

Why this fits a container platform

NeedHow it's handled
Public HTTPS URLAutomatic SSL + custom domain support
Signature env secretEncrypted env vars
Survive retriesManaged Postgres for MessageSid dedupe
VisibilityLive app logs (Elasticsearch-backed)

Testing before you go live

Use the Twilio CLI to simulate without burning real SMS:

twilio phone-numbers:update "+1XXX" --sms-url="https://<app>/sms"
# then text the number from your phone

Watch live logs in the dashboard to confirm the signature validates and you return valid TwiML. A 403 means your URL reconstruction doesn't match what Twilio signed.

Going further

  • STOP/START compliance: Twilio handles opt-outs automatically for standard keywords, but you should still record opt-out state.
  • Status callbacks: configure a separate statusCallback URL to track delivery — same signature validation applies.
  • Media (MMS): inbound images arrive as MediaUrl0..N; download them server-side promptly since the URLs expire.

References

  • [Twilio: Receive and reply to SMS](https://www.twilio.com/docs/messaging/guides/how-to-receive-and-reply)
  • [Twilio: Validating signatures](https://www.twilio.com/docs/usage/security#validating-requests)
  • [Twilio Node.js SDK](https://github.com/twilio/twilio-node)
  • [TwiML for Messaging](https://www.twilio.com/docs/messaging/twiml)

Webhook handlers are tiny but unforgiving — they need to be reachable, secure, and idempotent. PandaStack's free tier gives you HTTPS, env vars, and a managed Postgres to dedupe on, so you can ship one in an afternoon. Start at [dashboard.pandastack.io](https://dashboard.pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also