DEV3 Technologies home

STOP keyword opt-out handling for inbound SMS

Detect STOP, UNSUBSCRIBE and START keywords in inbound text messages, update consent atomically and answer with the required confirmation.

LaravelWebhookssmsopt-outconsentcompliance

Code

<?php

namespace App\Messaging;

use Illuminate\Support\Facades\DB;

final class KeywordConsent
{
    private const OUT = ['STOP', 'STOPALL', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'];

    private const IN = ['START', 'YES', 'UNSTOP'];

    /** Returns 'opted_out', 'opted_in' or null. The whole body must be the keyword. */
    public static function interpret(string $body): ?string
    {
        $word = strtoupper(trim(preg_replace('/[^\p{L}\p{N}\s]/u', '', $body)));

        return match (true) {
            in_array($word, self::OUT, true) => 'opted_out',
            in_array($word, self::IN, true) => 'opted_in',
            default => null,
        };
    }

    /** Applies the change and returns the confirmation to send back, or null for ordinary messages. */
    public static function apply(string $phone, string $body): ?string
    {
        $status = self::interpret($body);

        if ($status === null) {
            return null;
        }

        DB::table('consents')->upsert(
            [['phone' => $phone, 'status' => $status, 'keyword' => strtoupper(trim($body)), 'changed_at' => now()]],
            ['phone'],
            ['status', 'keyword', 'changed_at'],
        );

        return $status === 'opted_out'
            ? 'You have been unsubscribed and will receive no more messages. Reply START to rejoin.'
            : 'You are subscribed again. Reply STOP at any time to opt out.';
    }
}

How it works

What it solves

Carriers and regulations require that a reply of STOP ends all marketing texts straight away, and that START restores them. Getting this wrong risks blocked sender numbers and legal trouble, so it deserves its own small, well tested class rather than an if buried in a webhook.

How it works

KeywordConsent::interpret trims and uppercases the message, strips punctuation, and matches the whole body against two fixed lists. Matching the whole body is deliberate: a customer writing "please don't stop the delivery" must not be unsubscribed. STOP, STOPALL, UNSUBSCRIBE, CANCEL, END and QUIT opt out; START, YES and UNSTOP opt back in.

InboundSmsController normalises the sender number and calls apply. That runs an updateOrCreate on the consent row, recording the keyword and timestamp for the audit trail, and returns the confirmation text. The controller replies with a TwiML-style XML body or JSON, depending on what your provider expects, so the confirmation goes out in the same webhook response.

Messages that are not keywords fall through to your normal inbox handling.

Adapting it

Extend the lists for other languages, scope consent per sender number if you run several, and add HELP as a third keyword that returns contact details.

Pitfalls

  • Check the keyword before any automation or auto-reply runs.
  • Your sending code must check the consent row before every marketing message.
  • Do not send the confirmation to a number you just blocked via the normal queue.

How to use it

  1. Create consents (phone unique, status, keyword, changed_at).
  2. Copy both classes; route Route::post('/inbound/sms', InboundSmsController::class) and verify your provider signature in front of it.
  3. Make the field names in the controller match your provider's From and Body parameters.
  4. In your sender, skip numbers where status is opted_out.
  5. Hand non-keyword messages to your inbox code where the controller returns an empty response.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets