sms-consent
Package @27colab/sms-consent · Source on GitHub · MIT · Node 20+ · ESM · zero runtime dependencies
What it does
Section titled “What it does”- Keyword classifier: STOP / STOPALL / UNSUBSCRIBE / CANCEL / END / QUIT, START / UNSTOP, HELP, YES. Tolerant of case, accents, punctuation, emoji, zero-width characters and whitespace. English and Spanish built in, extensible.
- Consent state machine per
(accountId, phone):none → pending → opted_in → opted_out, with idempotent STOP and re-opt-in via START. guardSend(): a pure allow/deny decision with a reason (consent required, opt-outs honored, opt-out footer required, quiet hours in the recipient’s time zone).sendGuarded(): guard plus provider call that never reports a “phantom send”.ConsentStoreinterface with in-memory and SQLite adapters.- Twilio webhook helper (no Twilio SDK): inbound STOP/START/HELP handling, TwiML response,
X-Twilio-Signatureverification.
Install
Section titled “Install”npm install @27colab/sms-consentBring your own keys
Section titled “Bring your own keys”None for the package itself. To actually send, supply your own SMS provider call. To use the Twilio webhook helper you pass your Twilio Auth Token for signature verification; the package never calls Twilio.
Quickstart (runs without keys)
Section titled “Quickstart (runs without keys)”This sample walks the whole lifecycle with an in-memory store and a fake send: a refused send before consent, double opt-in, a send, a quiet-hours deferral, then STOP.
import { MemoryConsentStore, applyConsentEvent, ensureOptOutFooter, handleInbound, defaultReplies, sendGuarded,} from '@27colab/sms-consent';
const store = new MemoryConsentStore();const outbox: Array<{ to: string; body: string }> = [];// Replace with your provider call (Twilio, etc.). It is a plain function.const send = async (to: string, body: string) => void outbox.push({ to, body });
const accountId = 'acct-1';const phone = '+15550001111';const tz = 'America/New_York';const noon = new Date('2026-03-10T17:00:00Z'); // 1 pm in New York: outside quiet hours
// 1. Before any consent, a normal message is refused and never reaches the provider.console.log(await sendGuarded({ store, accountId, phone, timeZone: tz, now: noon, body: ensureOptOutFooter('Your order shipped.'), send }));
// 2. Double opt-in: request, then the recipient replies YES.await applyConsentEvent(store, accountId, phone, { type: 'request_opt_in' });await sendGuarded({ store, accountId, phone, timeZone: tz, now: noon, kind: 'opt_in_request', send, body: 'Acme: Reply YES to get texts. Msg & data rates may apply. Reply STOP to opt out.',});const replies = defaultReplies('Acme', 'help@example.test');console.log((await handleInbound({ store, accountId, from: phone, body: 'yes', replies })).record?.status);
// 3. Now a message goes out, unless it lands in quiet hours.console.log(await sendGuarded({ store, accountId, phone, timeZone: tz, now: noon, body: ensureOptOutFooter('Your order shipped.'), send }));const night = new Date('2026-03-11T03:30:00Z'); // 11:30 pm in New Yorkconsole.log(await sendGuarded({ store, accountId, phone, timeZone: tz, now: night, body: ensureOptOutFooter('Late nudge.'), send }));
// 4. STOP wins, and the confirmation is a `reply`.const stop = await handleInbound({ store, accountId, from: phone, body: 'Please stop', replies });console.log(stop.keyword, stop.record?.status);console.log(await sendGuarded({ store, accountId, phone, timeZone: tz, now: noon, body: ensureOptOutFooter('One more thing.'), send }));
console.log('provider calls:', outbox.length);Recorded output:
{ sent: false, reason: 'no_consent', detail: 'No consent on record; send an opt-in request first.'}opted_in{ sent: true }{ sent: false, reason: 'quiet_hours', detail: 'Inside recipient quiet hours.', retryAt: 2026-03-11T12:00:00.000Z}STOP opted_out{ sent: false, reason: 'opted_out', detail: 'Recipient opted out; only START re-enables messaging.'}provider calls: 2Note the last line: the provider was called twice in total. The first send (no consent), the quiet-hours send and the post-STOP send were all refused before reaching it.
SQLite and the Twilio webhook
Section titled “SQLite and the Twilio webhook”Needs your Twilio Auth Token and a public webhook URL. Typechecked, not executed.
// Typechecked, not run: storage and webhook wiring.import Database from 'better-sqlite3';import { SqliteConsentStore, defaultReplies, handleTwilioInbound } from '@27colab/sms-consent';
const store = new SqliteConsentStore(new Database('consent.db'));const replies = defaultReplies('Acme', 'help@example.test');
// Inside your POST handler for the Twilio messaging webhook:export async function onTwilioWebhook(rawFormBody: string, signature: string | undefined, publicWebhookUrl: string) { const res = await handleTwilioInbound({ store, accountId: 'acct-1', replies, body: rawFormBody, verify: { authToken: process.env.TWILIO_AUTH_TOKEN!, url: publicWebhookUrl, signature }, }); return res; // respond with res.status, res.contentType, res.body}Twilio’s Advanced Opt-Out may already intercept STOP, START and HELP for your Messaging Service; this helper keeps your ledger in sync with what recipients texted. Always pass verify outside of tests. SqliteConsentStore accepts any handle shaped like better-sqlite3 or node:sqlite, creates sms_consent plus an append-only sms_consent_events log, and never deletes rows. Implement ConsentStore (get/put) for Postgres or anything else.
API overview
Section titled “API overview”| Export | Purpose |
|---|---|
classifyInbound(body, options?) | STOP, START, HELP, YES or OTHER. Add languages via { stop, start, help, yes }. |
applyConsentEvent(store, accountId, phone, event) | Events: request_opt_in, confirm, grant, stop, start. Returns { record, changed }. |
getConsent, normalizePhone | Read a record; normalize a phone number (invalid input throws InvalidPhoneError on writes). |
handleInbound({ store, accountId, from, body, replies }) | Classify, update consent, and return the reply text to send with kind reply. |
defaultReplies(brand, helpContact?) | Stock STOP/START/HELP replies. |
guardSend(record, body, options) | Pure decision: { allow: true } or { allow: false, reason, detail, retryAt? }. |
sendGuarded({ store, accountId, phone, body, send, ... }) | Guard plus provider call; sent: true only if allowed and the provider resolved. |
ensureOptOutFooter, hasOptOutFooter | Append or detect an opt-out instruction. |
isQuietHour, nextAllowedTime | Quiet-hours helpers (default 21:00–08:00 recipient-local). |
MemoryConsentStore, SqliteConsentStore | Stores. |
handleTwilioInbound, verifyTwilioSignature, twiml, parseFormBody | Twilio webhook helpers. |
Guard reasons
Section titled “Guard reasons”| reason | meaning |
|---|---|
empty_body | nothing to send |
opted_out | recipient sent STOP; only START re-enables |
no_consent / pending_confirmation | no confirmed opt-in on record |
missing_opt_out_footer | body does not tell the recipient how to opt out |
timezone_required | quiet hours are on but no recipient time zone was given (fails closed) |
quiet_hours | inside the quiet window; retryAt is the next allowed minute |
send_failed | (sendGuarded only) the provider call threw |
Send kinds: message (needs opted_in), opt_in_request (allowed for none and pending, never for opted_out), reply (a direct response to an inbound message such as a STOP confirmation; always allowed, so use it only for that).
Limits
Section titled “Limits”- STOP wins ties. A message of up to three words containing a stop word (“please stop”, “yes stop”) is a STOP. Longer free text only counts when it starts with a stop word; “I can never stop thinking about it” is
OTHER. okandsureare intentionally not YES: too ambiguous to treat as consent.- Your consent collection is outside this package. It records and enforces; it cannot tell you whether the way you obtained consent is sufficient.
- Quiet hours are a convention, not a legal rule set. The default window is a starting point; whether and when your messages must be restricted depends on your jurisdiction and program.
Background reading, linked from the package README: CTIA Messaging Principles and Best Practices, Twilio Advanced Opt-Out, Twilio webhook security.