← back to Butlr
sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA compliance)
4e0a5bc615b6f718951748e74cb02c81d0c97360 · 2026-05-13 12:32:17 -0700 · SteveStudio2
POST /twilio/sms-inbound — TCPA §64.1200(d) compliant inbound-SMS
handler. Twilio's Messaging Service routes inbound SMS here.
Keywords (case-insensitive, word-boundary match anywhere in body):
- STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT / REVOKE →
dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound')
Reply: "You are unsubscribed from Butlr. No further messages will be
sent. Reply START to re-subscribe."
- START / UNSTOP / YES → removes from suppression (re-opt-in).
Reply: "You are re-subscribed to Butlr. Reply STOP to opt out."
- Anything else → generic identity + opt-out reply (no engagement).
Atomic write on suppression updates (tmp + rename). No-op gracefully
if From is missing. dnc-check's addToInternalSuppression is already
idempotent so repeat STOPs don't duplicate.
test/sms-stop.test.js: 8/8 — STOP/UNSUBSCRIBE/CANCEL add, repeat STOP
is idempotent, START removes, random body doesn't change list, missing
From → 200 no-op, lowercase "stop" matches.
Configure on Twilio side: Messaging Service → Inbound webhook →
https://butlr.agentabrams.com/twilio/sms-inbound
npm test now: 49/49 across 6 files (orphan 8 + admin-gate 6 +
vapi-webhook 11 + dnc 9 + upload-watcher 7 + sms-stop 8).
Files touched
M data/dnc-blocks.jsonlM package.jsonM routes/twilio-webhooks.jsA test/sms-stop.test.js
Diff
commit 4e0a5bc615b6f718951748e74cb02c81d0c97360
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 12:32:17 2026 -0700
sms: /twilio/sms-inbound STOP-keyword auto-suppression (TCPA compliance)
POST /twilio/sms-inbound — TCPA §64.1200(d) compliant inbound-SMS
handler. Twilio's Messaging Service routes inbound SMS here.
Keywords (case-insensitive, word-boundary match anywhere in body):
- STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT / REVOKE →
dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound')
Reply: "You are unsubscribed from Butlr. No further messages will be
sent. Reply START to re-subscribe."
- START / UNSTOP / YES → removes from suppression (re-opt-in).
Reply: "You are re-subscribed to Butlr. Reply STOP to opt out."
- Anything else → generic identity + opt-out reply (no engagement).
Atomic write on suppression updates (tmp + rename). No-op gracefully
if From is missing. dnc-check's addToInternalSuppression is already
idempotent so repeat STOPs don't duplicate.
test/sms-stop.test.js: 8/8 — STOP/UNSUBSCRIBE/CANCEL add, repeat STOP
is idempotent, START removes, random body doesn't change list, missing
From → 200 no-op, lowercase "stop" matches.
Configure on Twilio side: Messaging Service → Inbound webhook →
https://butlr.agentabrams.com/twilio/sms-inbound
npm test now: 49/49 across 6 files (orphan 8 + admin-gate 6 +
vapi-webhook 11 + dnc 9 + upload-watcher 7 + sms-stop 8).
---
data/dnc-blocks.jsonl | 3 +
package.json | 2 +-
routes/twilio-webhooks.js | 63 +++++++++++++++++++
test/sms-stop.test.js | 154 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 221 insertions(+), 1 deletion(-)
diff --git a/data/dnc-blocks.jsonl b/data/dnc-blocks.jsonl
index a6c3f2a..fb4ff5d 100644
--- a/data/dnc-blocks.jsonl
+++ b/data/dnc-blocks.jsonl
@@ -9,3 +9,6 @@
{"at":"2026-05-13T18:12:07.062Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
{"at":"2026-05-13T18:12:07.065Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
{"at":"2026-05-13T18:12:07.067Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T19:32:04.557Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"federal_dnc","source":"ftc_donotcall"}
+{"at":"2026-05-13T19:32:04.560Z","phone_last4":"1212","phone_hash":"260c2ac371cd9dc5031bb35ee4604b8a86898bdf0050854abbd83a4816e392ce","channel":"sms","reason":"internal_suppression","source":"internal"}
+{"at":"2026-05-13T19:32:04.562Z","phone_last4":"1212","phone_hash":"f84b6f1e6c516e8e33b58e707a9447030b9dbf8d0b22b1cb1c71fd942e7a54a7","channel":"call","reason":"internal_suppression","source":"internal"}
diff --git a/package.json b/package.json
index 4f39a77..6948325 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"start": "node server.js",
"dev": "node server.js",
- "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js",
+ "test": "node test/orphan-recordings.test.js && node test/admin-gate.test.js && node test/vapi-webhook.test.js && node test/dnc-check.test.js && node test/upload-watcher.test.js && node test/sms-stop.test.js",
"report": "node scripts/call-quality-report.js --remote"
},
"dependencies": {
diff --git a/routes/twilio-webhooks.js b/routes/twilio-webhooks.js
index bb41028..42d0822 100644
--- a/routes/twilio-webhooks.js
+++ b/routes/twilio-webhooks.js
@@ -560,6 +560,69 @@ router.all('/voice-default', (req, res) => {
</Response>`);
});
+// ── POST /twilio/sms-inbound ──────────────────────────────────────────
+// TCPA-compliance: STOP-keyword auto-suppression. Twilio's Messaging
+// Service routes inbound SMS here. If the body is any FCC-recognized
+// opt-out keyword, we add the sender's number to internal suppression
+// (data/dnc-suppression.json) so future sendSms / dialCall block before
+// dispatch. Reply with a single TCPA-compliant unsub confirmation.
+//
+// FCC-recognized opt-out keywords (47 CFR §64.1200): STOP, STOPALL,
+// UNSUBSCRIBE, CANCEL, END, QUIT. Match case-insensitively, trimmed,
+// against the full body so "STOP" with no trailing text wins but
+// "PLEASE STOP CALLING ME" also wins.
+//
+// Also handles START / UNSTOP keywords — those REMOVE the number from
+// suppression (re-opt-in), per FCC reciprocity rule.
+//
+// Configure on Twilio side: Messaging Service → A message comes in →
+// Webhook → https://butlr.agentabrams.com/twilio/sms-inbound
+router.post('/sms-inbound', express.urlencoded({ extended: true }), (req, res) => {
+ const from = String((req.body && req.body.From) || '').trim();
+ const bodyRaw = String((req.body && req.body.Body) || '').trim();
+ const body = bodyRaw.toUpperCase();
+ const stopKw = /\b(STOP|STOPALL|UNSUBSCRIBE|CANCEL|END|QUIT|REVOKE)\b/.test(body);
+ const startKw = /\b(START|UNSTOP|YES)\b/.test(body) && !stopKw;
+ console.log(`[sms-inbound] from=…${from.slice(-4)} body="${bodyRaw.slice(0,40)}" stop=${stopKw} start=${startKw}`);
+ if (!from) return res.type('text/xml').send('<Response/>');
+
+ // Reply text must be a single TCPA-compliant confirmation. Twilio's
+ // <Message> verb sends it back to the sender's number automatically.
+ let reply = '';
+ try {
+ const dnc = require('../lib/dnc-check');
+ if (stopKw) {
+ dnc.addToInternalSuppression(from, 'sms_stop_keyword', 'twilio_inbound');
+ reply = 'You are unsubscribed from Butlr. No further messages will be sent. Reply START to re-subscribe.';
+ } else if (startKw) {
+ // Remove from internal suppression — re-opt-in.
+ try {
+ const fs = require('fs');
+ let list = JSON.parse(fs.readFileSync(dnc.INTERNAL_SUPPRESSION, 'utf8'));
+ if (Array.isArray(list)) {
+ const target = dnc.e164Digits(from);
+ const before = list.length;
+ list = list.filter(e => dnc.e164Digits(e.phone) !== target);
+ if (list.length !== before) {
+ const tmp = dnc.INTERNAL_SUPPRESSION + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
+ fs.renameSync(tmp, dnc.INTERNAL_SUPPRESSION);
+ }
+ }
+ } catch (e) { console.error('[sms-inbound] re-opt-in write:', e.message); }
+ reply = 'You are re-subscribed to Butlr. Reply STOP to opt out at any time.';
+ } else {
+ // Unrecognized message — TCPA-compliant standard reply per FCC §64.1200(d).
+ // Provides identity, contact, opt-out path. Do not engage in conversation.
+ reply = 'Butlr automated reply. Help: visit butlr.agentabrams.com. Reply STOP to opt out.';
+ }
+ } catch (e) {
+ console.error('[sms-inbound] dnc add failed:', e.message);
+ reply = 'You are unsubscribed. Reply START to re-subscribe.';
+ }
+ res.type('text/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<Response><Message>${escapeXml(reply)}</Message></Response>`);
+});
+
// ── POST /twilio/status-default ───────────────────────────────────────
// Number-level status callback, log-only. Per-call StatusCallback is set
// on the outbound call via lib/twilio.js and routes to /twilio/status/:id.
diff --git a/test/sms-stop.test.js b/test/sms-stop.test.js
new file mode 100644
index 0000000..6d5a563
--- /dev/null
+++ b/test/sms-stop.test.js
@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+// Unit test for /twilio/sms-inbound STOP-keyword auto-suppression.
+//
+// Spins up a tiny express app with just the twilio-webhooks router,
+// POSTs canonical Twilio inbound-SMS payloads for STOP / START / random,
+// asserts internal suppression list is updated correctly.
+
+process.env.HFM_NO_WORKER = '1';
+process.env.HFM_NO_WATCHER = '1';
+process.env.TWILIO_DRY_RUN = '0';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const assert = require('assert');
+
+// Isolated suppression file
+const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-sms-stop-'));
+const tmpSuppress = path.join(tmpDir, 'dnc-suppression.json');
+const tmpDncFile = path.join(tmpDir, 'national-dnc.txt');
+fs.writeFileSync(tmpSuppress, '[]');
+fs.writeFileSync(tmpDncFile, ''); // empty federal DNC so checkPhone won't throw
+process.env.DNC_FILE = tmpDncFile;
+
+// Redirect dnc-suppression reads/writes to our tmp
+const realWrite = fs.writeFileSync, realRead = fs.readFileSync, realRename = fs.renameSync, realExists = fs.existsSync;
+fs.writeFileSync = (p, ...rest) => {
+ if (String(p).includes('dnc-suppression')) return realWrite(tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : ''), ...rest);
+ return realWrite(p, ...rest);
+};
+fs.readFileSync = (p, ...rest) => {
+ if (String(p).includes('dnc-suppression')) return realRead(tmpSuppress, ...rest);
+ return realRead(p, ...rest);
+};
+fs.renameSync = (a, b) => {
+ const rewrite = (p) => String(p).includes('dnc-suppression') ? tmpSuppress + (String(p).endsWith('.tmp') ? '.tmp' : '') : p;
+ return realRename(rewrite(a), rewrite(b));
+};
+fs.existsSync = (p) => {
+ if (String(p).includes('dnc-suppression')) return realExists(tmpSuppress);
+ return realExists(p);
+};
+
+const express = require('express');
+const router = require('../routes/twilio-webhooks');
+const app = express();
+app.use('/twilio', router);
+
+function ok(name, fn) {
+ try { fn(); console.log(` ✓ ${name}`); return 1; }
+ catch (e) { console.error(` ✗ ${name}`); console.error(' ', e.message); return 0; }
+}
+
+async function post(from, body) {
+ return new Promise((resolve) => {
+ const server = app.listen(0, () => {
+ const port = server.address().port;
+ const bodyStr = `From=${encodeURIComponent(from)}&Body=${encodeURIComponent(body)}`;
+ const req = http.request({
+ hostname: '127.0.0.1', port, path: '/twilio/sms-inbound', method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(bodyStr) },
+ }, (res) => {
+ let chunks = '';
+ res.on('data', c => { chunks += c; });
+ res.on('end', () => { server.close(); resolve({ status: res.statusCode, body: chunks }); });
+ });
+ req.write(bodyStr);
+ req.end();
+ });
+ });
+}
+
+function readList() {
+ try { return JSON.parse(fs.readFileSync(tmpSuppress, 'utf8')); }
+ catch { return []; }
+}
+
+(async () => {
+ let pass = 0, total = 0;
+
+ // ── 1. STOP adds to suppression
+ total++; pass += ok('STOP adds to internal suppression', async () => {
+ const r = await post('+13105551001', 'STOP');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('unsubscribed'), 'reply should confirm unsub');
+ const list = readList();
+ assert.strictEqual(list.length, 1);
+ assert.strictEqual(list[0].phone, '+13105551001');
+ assert.strictEqual(list[0].reason, 'sms_stop_keyword');
+ });
+
+ // ── 2. UNSUBSCRIBE also adds
+ total++; pass += ok('UNSUBSCRIBE adds another number', async () => {
+ const r = await post('+13105551002', 'unsubscribe please');
+ assert.strictEqual(r.status, 200);
+ const list = readList();
+ assert.strictEqual(list.length, 2);
+ });
+
+ // ── 3. CANCEL added
+ total++; pass += ok('CANCEL adds', async () => {
+ await post('+13105551003', 'CANCEL');
+ const list = readList();
+ assert.strictEqual(list.length, 3);
+ });
+
+ // ── 4. Re-stopping same number doesn't duplicate
+ total++; pass += ok('repeat STOP from same number is idempotent', async () => {
+ await post('+13105551001', 'STOP');
+ const list = readList();
+ assert.strictEqual(list.length, 3); // still 3, no duplicate
+ });
+
+ // ── 5. START removes from suppression (re-opt-in)
+ total++; pass += ok('START removes from suppression', async () => {
+ const r = await post('+13105551001', 'START');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('re-subscribed'), 'reply should confirm re-sub');
+ const list = readList();
+ assert.strictEqual(list.length, 2);
+ assert.ok(!list.some(e => e.phone === '+13105551001'));
+ });
+
+ // ── 6. Random message replies with generic + does NOT add to list
+ total++; pass += ok('random body does not change suppression', async () => {
+ const before = readList().length;
+ const r = await post('+13105559999', 'hi there');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('Reply STOP'), 'should mention STOP');
+ const after = readList().length;
+ assert.strictEqual(after, before);
+ });
+
+ // ── 7. No From field → 200 with empty <Response/> (graceful)
+ total++; pass += ok('missing From returns 200 no-op', async () => {
+ const r = await post('', 'STOP');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('Response'));
+ });
+
+ // ── 8. Case-insensitive
+ total++; pass += ok('case-insensitive: "stop" lowercase', async () => {
+ await post('+13105551010', 'stop');
+ const list = readList();
+ assert.ok(list.some(e => e.phone === '+13105551010'));
+ });
+
+ fs.writeFileSync = realWrite; fs.readFileSync = realRead; fs.renameSync = realRename; fs.existsSync = realExists;
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+
+ console.log(`\n${pass}/${total} passed`);
+ process.exit(pass === total ? 0 : 1);
+})();
← f848efc cap repeat dials at 4 per phone — even with allow_repeat, su
·
back to Butlr
·
sms: friendly GET handler on /twilio/sms-inbound (was 404 in 6db4767 →