Handle SMS STOP and START in your own code
Verify inbound webhook signatures, record STOP or START, send a confirmation, and check the consent record before every outbound SMS.
The claim
SignalWire does not manage opt-outs for you. The platform messaging page says “Customers are responsible for handling inbound stop requests and removing those customers from subscriber lists”. It adds that messages should not go out again “unless they have opted back in via an Unstop request”. The page is https://signalwire.com/docs/platform/messaging. So the record lives on your side, and two pieces of your code keep it. One is the handler that receives the inbound message webhook, behind the platform’s signature. The other is the send path that consults the record before it builds a request.
Why it holds
The vendored REST spec documents the inbound message webhook. SignalWire POSTs a JSON body whose message object carries from, to, body, type and seven more required fields, and it expects a SWML document in reply.
How it works
def handle_inbound(message):
word = keyword(message.get("body")) # trim, lowercase
sender, ours = message["from"], message["to"]
if word in STOP_WORDS:
OPT_OUTS[sender] = datetime.now(timezone.utc).isoformat(timespec="seconds")
return reply(ours, sender, STOPPED)
if word in START_WORDS:
OPT_OUTS.pop(sender, None)
return reply(ours, sender, RESUMED)
return {"version": "1.0.0", "sections": {"main": []}}
def send(to, body):
if to in OPT_OUTS:
raise OptedOut(f"{to} opted out at {OPT_OUTS[to]}; no message sent")
return http.post("/api/messaging/messages", body={"to": to, "from": FROM, "body": body})What the handler returns for a STOP:
{"version": "1.0.0", "sections": {"main": [
{"reply": {"to": "+1555YYYYYYY", "from": "+1555XXXXXXX",
"body": "You are unsubscribed from Ridgeline Cycles messages. Reply START to opt back in."}}]}}Before any of that runs, a before_request hook checks SignalWire’s signature over the request, hex(HMAC(signing_key, url + raw_body)) in X-Signalwire-Signature, and answers 403 without it. Anyone can reach a public webhook, and a forged START would undo a real STOP. The check is the one verify-a-webhook-signature explains. The keyword compares whole, after trim and lowercase, so “can you stop calling” is a message and “Stop” is an opt-out. The confirmation goes out through the document rather than through send, because it is the one message an opted-out number should still receive. Anything that is not a keyword gets an empty document, which sends nothing and records nothing.
Limitations
The record is a dictionary in the process. Replace OPT_OUTS with your database before anything depends on it.
The keyword list is this recipe’s. Which words your traffic must honour, and what the confirmation must say, are questions for your carrier agreements and your counsel, not for the platform.
What to change first
In send(), move the http.post(...) call above the if to in OPT_OUTS check, keep its result in a variable, and return that after the check. Run the verifier. The refusal assertion fails because the recorder saw a request, which is the failure this recipe exists to prevent.