Skip to content

sms-consent

  • 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”.
  • ConsentStore interface with in-memory and SQLite adapters.
  • Twilio webhook helper (no Twilio SDK): inbound STOP/START/HELP handling, TwiML response, X-Twilio-Signature verification.
Terminal window
npm install @27colab/sms-consent

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.

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.

samples/src/sms-consent-quickstart.ts
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 York
console.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:

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: 2

Note 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.

Needs your Twilio Auth Token and a public webhook URL. Typechecked, not executed.

samples/src/sms-consent-sqlite-twilio.ts
// 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.

ExportPurpose
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, normalizePhoneRead 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, hasOptOutFooterAppend or detect an opt-out instruction.
isQuietHour, nextAllowedTimeQuiet-hours helpers (default 21:00–08:00 recipient-local).
MemoryConsentStore, SqliteConsentStoreStores.
handleTwilioInbound, verifyTwilioSignature, twiml, parseFormBodyTwilio webhook helpers.
reasonmeaning
empty_bodynothing to send
opted_outrecipient sent STOP; only START re-enables
no_consent / pending_confirmationno confirmed opt-in on record
missing_opt_out_footerbody does not tell the recipient how to opt out
timezone_requiredquiet hours are on but no recipient time zone was given (fails closed)
quiet_hoursinside 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).

  • 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.
  • ok and sure are 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.