# 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:
- 1An inbound SMS hits your number.
- 2Twilio POSTs
application/x-www-form-urlencodedfields (From,To,Body,MessageSid, etc.) to your configured webhook URL. - 3Your endpoint returns TwiML (XML) to reply, or an empty
200to do nothing. - 4Twilio signs every request with an
X-Twilio-Signatureheader. 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
Hostand protocol must reflect the public URL. On a platform that runs HTTPS in front of you, build the URL withhttps://explicitly, as above. - Keep
TWILIO_AUTH_TOKENin 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.
- 1Push the repo to GitHub.
- 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.
- 3Add
TWILIO_AUTH_TOKENas an environment variable. - 4You get an HTTPS URL with automatic SSL. Paste
https://into your Twilio number's A MESSAGE COMES IN webhook field (Console → Phone Numbers)./sms
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
| Need | How it's handled |
|---|---|
| Public HTTPS URL | Automatic SSL + custom domain support |
| Signature env secret | Encrypted env vars |
| Survive retries | Managed Postgres for MessageSid dedupe |
| Visibility | Live 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 phoneWatch 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
statusCallbackURL 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).