ADR-0045: SMS adapters with a participant opt-out flag, candidate URLs for Twilio signatures and provider-specific inbound responses
| Status | Accepted |
| Date | 2026-10-12 |
| Issue | #41 |
| Pull request | #136 |
| Related | ADR-0009, ADR-0016, ADR-0019, ADR-0031, ADR-0038 |
Context and problem statement
Issue #41 adds SMS through Twilio and Vonage: outbound messages,
inbound webhooks with signature verification, delivery receipts, MMS media on Twilio, segment counts, E.164
numbers and the STOP / START opt-out that carriers require. The acceptance criteria: an inbound SMS creates a
participant-scoped conversation, the reply is delivered and its receipt moves the delivery to delivered; and a
STOP marks the participant opted out so later deliveries fail fast with a clear reason.
Three parts did not fit the adapter behaviour as it was (ADR-0038):
- Opt-out is state, not a message. A
STOPhas to change what the pipeline does with every later delivery to that person, on any route that targets the SMS channel, and it must survive webhook re-delivery. - Twilio signs the URL.
X-Twilio-Signatureis an HMAC-SHA1 over the full URL Twilio called plus the sorted form parameters.verify_inbound_signature/3receives the channel, the headers and the raw body, not the URL, and behind a proxy the URL the server sees is not the one Twilio used anyway. - Twilio expects TwiML. The inbound controller answers every handled request with a JSON summary. Twilio logs error 12300 (invalid content type) for every inbound message that gets a JSON answer.
Decision drivers
- An opt-out must never be lost, including when the provider re-delivers the
STOPwebhook. - A delivery to an opted-out number must not reach the provider, must not be retried and must not count as a provider failure in the circuit breaker.
- Signature checks must not weaken: only a holder of the auth token may get a request accepted.
- Keep the behaviour small; prefer optional callbacks with defaults (ADR-0038).
- No new dependency:
Reqfor HTTP,Joken(already used for API tokens) for Vonage JWTs.
Considered options
Opt-out storage:
- A nullable
opted_out_atcolumn onparticipants, checked by the pipeline before every delivery. - A key in
participants.metadata. - Adapter-only check: each SMS adapter looks the number up and returns a permanent error.
Twilio's URL:
- Pass the request URL to adapters (a new callback arity or a request map).
- Candidate URLs in the adapter: the channel's
inboundandstatuspaths on a configurablewebhook_base_url, the server URL and the requestHost.
Twilio's response body:
- An optional
inbound_response/1callback returning{content_type, body}, used for200answers. - Special-case the type in the controller.
Pros and cons of the options
Option 1: opted_out_at column, pipeline check
- Good, because it is queryable, typed, and serialised with the conversation's participant for agents.
- Good, because the pipeline check runs before downgrade, middleware and the adapter: no provider call, no circuit breaker sample, no retry, and it covers every adapter (a future WhatsApp opt-out can reuse it).
- Bad, because it costs a migration (metadata-only: nullable, no default) and one lookup per delivery.
Option 2: metadata key
- Good, because it needs no migration.
- Bad, because metadata is free-form JSON, overwritten by upserts in other code paths, and awkward to query.
Option 3: adapter-only check
- Good, because it is local to the SMS adapters.
- Bad, because the adapter's error goes through
normalize_error/1and the circuit breaker records the attempt as a provider outcome; and each adapter would repeat the lookup.
Option 4: request URL for adapters
- Good, because it is exact.
- Bad, because it changes a callback every adapter implements (or adds a second one) for one provider, and the URL the server sees behind a TLS-terminating proxy is still not the URL Twilio signed.
Option 5: candidate URLs
- Good, because it needs no behaviour change and handles proxies:
webhook_base_urlstates the public URL explicitly, andHostplusX-Forwarded-Protocovers the common proxy setup. - Good, because security is unchanged: every candidate is checked with the auth token, so a forger still needs it.
- Bad, because a signature for the channel's
statusURL is also accepted oninbound(and the other way round). Both endpoints of one channel run the same handler, so this lets nothing new through.
Option 6: inbound_response/1
- Good, because it is optional, with the JSON summary as the default, and other providers that care about the answer body can use it.
- Bad, because it adds a callback.
Option 7: special case in the controller
- Bad, because the controller would name a channel type again, which ADR-0038 removed.
Decision
Chosen options: 1, 5 and 6.
- Opt-out.
participants.opted_out_at(utc_datetime_usec, nullable, never cast). The SMS adapters mark a parsed message whose whole text is an opt-out keyword (STOP,STOPALL,UNSUBSCRIBE,CANCEL,END,QUIT,OPTOUT,REVOKE) or opt-in keyword (START,YES,UNSTOP,OPTIN) with"opt_out": "stop" | "start", turn it into aneventactivity and setchannelData.smsOptOut. Twilio'sOptOutTypewins over the text.Converger.InboundcallsParticipants.set_opted_out/3before creating the activity: if the activity insert fails, the provider re-delivers and the idempotent flag is set again; the other order would turn the re-delivery into a duplicate and lose the opt-out.Pipeline.deliver/2checksParticipants.opted_out_recipient/2(the conversation participant on the target channel and an explicitmetadata.to/recipient_phone) first and marks the deliveryfailedwithrecipient <id> opted out, returned as{:error, {:halted, reason}}(not retried). The SMS adapters check their normalised recipient again, and Twilio's error21610sets the flag when the opt-out happened outside Converger. - Twilio signatures.
SmsTwilio.verify_inbound_signature/3accepts a signature over<base>/api/v1/channels/<id>/{inbound,status}for each base of: the channel'swebhook_base_url,ConvergerWeb.Endpoint.url/0, andX-Forwarded-Proto(defaulthttps) plusHost. The parameters come from the raw body (URI.query_decoder/1, sorted). Vonage needs no URL: its JWT (HS256,signature_secret) carries apayload_hashof the body, which is required. - Inbound response. New optional callback
c:Converger.Channels.Adapter.inbound_response/1returns{content_type, body}; the controller sends it for every200answer of that channel (messages and receipts).201answers stay JSON.sms_twilioreturns an empty TwiML<Response></Response>astext/xml. - Shared SMS rules live in
Converger.Channels.Sms: E.164 normalisation (with a per-channeldefault_country_codefor national numbers), GSM-7 / UCS-2 segment counting (153 / 67 per concatenated part, extension characters and astral emoji count twice) and a per-channelmax_segments(default 10). A text over the limit fails permanently instead of being sent and billed. - Types are
sms_twilioandsms_vonage(provider second, likewhatsapp_meta), registered as built-ins. Vonage supports basic auth and application JWTs (RS256 via Joken; the PEM is decoded on config validation, since Joken builds signers lazily).
Consequences
Positive
- Both acceptance criteria hold and are tested end to end (
test/converger_web/controllers/sms_inbound_test.exs). - Every channel type gets the opt-out check for free; agents see
participant.opted_out_aton the conversation. - An opted-out recipient never reaches the provider and never trips the circuit breaker.
Negative and trade-offs
- One extra lookup per delivery (the recipient's participant), for every channel type.
- An operator cannot clear
opted_out_atthrough the API yet; only an opt-in keyword from the participant clears it. - Twilio MMS only sends absolute public URLs; attachments stored in Converger (signed download paths) are not sent.
- The Vonage JWT's
iatis not checked; replays are bounded by the body hash and by idempotency keys. - Inbound media URLs on Twilio may require HTTP basic auth to download (account setting); they are stored as is.
Follow-ups
- An API to set or clear a participant's opt-out (for opt-outs collected outside SMS).
- Download inbound MMS media into Converger storage, as planned for WhatsApp in #37.
Implementation
lib/converger/channels/sms.ex: E.164, segments, keywords.lib/converger/channels/adapters/sms_twilio.ex,lib/converger/channels/adapters/sms_vonage.ex.Converger.Participants.set_opted_out/3,opted_out?/2,opted_out_recipient/2;Converger.Inboundapplies"opt_out";Converger.Pipeline.deliver/2checks it.Converger.Channels.Adapter.inbound_response/1andConvergerWeb.InboundController.- Migration
20261012100000_add_opted_out_at_to_participants. - Tests:
test/converger/channels/sms_test.exs,test/converger/channels/adapters/sms_twilio_test.exs,test/converger/channels/adapters/sms_vonage_test.exs,test/converger_web/controllers/sms_inbound_test.exs.