Twilio — SMS Sending & Phone Verification
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Your app needs to text people — "new lead came in", "your order shipped" — or to prove a
user owns a phone number before trusting it. Twilio does both. This recipe gives you a
complete Twilio service module (plain SMS via the Messages API, plus optional
one-time-code phone verification via Twilio Verify), the three .env keys it reads, and
the one deployment step people always miss: recreating the web container so the new
env vars actually load.
Good news up front: the twilio-ruby gem is already installed in the base image
(verified: twilio-ruby 7.10.5 in llamapress-simple:0.4.0g). You cannot add gems on a
Leo instance, but you don't need to — this one is there. Run
bundle list | grep twilio inside the llamapress container if you want to confirm on
your box.
When to use: outbound SMS notifications (owner alerts, order updates, one-off texts) and SMS one-time-code phone verification. When not to: email (use Action Mailer / SES), chat between users (build that in-app), or marketing blasts to large lists (carrier compliance — A2P 10DLC registration — is its own project; don't loop
send_textover a big list).
The 80/20 in one breath
- In the Twilio Console (
console.twilio.com), create an API key (Account → API keys & tokens → Create API key). Copy the key SID (starts withSK) and the Secret — the secret shows only once. Also note the Account SID (starts withAC) and get a Twilio phone number. - Add
TWILIO_SID(theSK...key SID),TWILIO_AUTH(the key secret),TWILIO_ACCOUNT_SID(theAC...value), andTWILIO_NUMBERto the.envat the project root. No quotes, no trailing spaces. Number in E.164 form (+15551234567). - Recreate the web container so the vars load — a plain
restartdoes not reload.env(see Gotchas). - Copy the
Twiliomodule below intoapp/services/twilio.rb. Twilio.send_text("+15551234567", "It works!")— done. Verification endpoints are optional Layer 3.
Why an API key instead of the Auth Token? Twilio recommends API keys for
application code. You can revoke one key without breaking every other integration,
each app gets its own credential, and a Restricted key can be scoped to only the
permissions the app needs (least privilege). The account Auth Token is a master
credential — rotating it breaks everything that uses it. The old
TWILIO_SID=AC... + TWILIO_AUTH=<auth token> pair still works (see Layer 2 — the
module detects which kind you gave it), but prefer the API key for anything new.
Layer 1 — Environment keys (.env)
# .env (project root, next to docker-compose.yml — the compose file already
# passes this whole file into the web container via `env_file: .env`)
# PREFERRED — API key SID (starts with "SK") from Account → API keys & tokens.
# Legacy alternative: put the Account SID ("AC...") here and the Auth Token in
# TWILIO_AUTH — the module below detects which kind you gave it by the prefix.
TWILIO_SID=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# REQUIRED — the API key Secret (shown once at key creation).
# In legacy mode this holds the account Auth Token instead.
TWILIO_AUTH=your_api_key_secret_here
# REQUIRED in API key mode — the Account SID (starts with "AC"). Twilio still
# addresses resources like Messages by account; the key alone doesn't say which.
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# REQUIRED — a Twilio phone number you own, in E.164 format (the "from" number)
TWILIO_NUMBER=+15551234567
# OPTIONAL — only for the phone-verification flow (Layer 3).
# Create a Verify Service in Console → Verify → Services; copy its SID (starts "VA").
TWILIO_VERIFY_SERVICE_ID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Then, from the box's host shell (not inside a container), reload the env:
# .env is only read when a container is CREATED, not on restart.
cd ~/Leonardo
docker compose down llamapress && docker compose up -d llamapress
Verify the vars made it in (names only — never print the values):
docker compose exec -T llamapress bash -c 'env | grep -o "^TWILIO_[A-Z_]*" | sort'
Layer 2 — The service (SMS sending)
# app/services/twilio.rb
#
# NOTE: this REOPENS the gem's own `Twilio` module (the gem defines
# Twilio::REST::Client). That is intentional and works fine with Rails
# autoloading — but the `require 'twilio-ruby'` line must stay at the top.
module Twilio
require 'twilio-ruby'
# Send an SMS. `to` should be E.164 ("+15551234567"); use internationalize()
# if you're storing bare US numbers.
def Twilio.send_text(to, message)
client = Twilio.get_client
client.messages.create(
from: ENV['TWILIO_NUMBER'],
to: to,
body: message
)
end
# TWILIO_SID holds either an API key SID ("SK...", preferred) or the Account
# SID ("AC...", legacy). TWILIO_AUTH holds the matching secret. API key mode
# also needs TWILIO_ACCOUNT_SID — the three-argument client form.
def Twilio.get_client
sid = ENV['TWILIO_SID']
secret = ENV['TWILIO_AUTH']
if sid&.start_with?('SK')
Twilio::REST::Client.new(sid, secret, ENV['TWILIO_ACCOUNT_SID'])
else
Twilio::REST::Client.new(sid, secret)
end
end
# Prefixes +1 if the number isn't internationalized already.
# US-centric — adapt the prefix (or use a proper phone lib) for other countries.
def Twilio.internationalize(number)
number.start_with?("+1") ? number : "+1" + number
end
# --- Optional: Twilio Verify (one-time-code phone verification) ---
# Requires TWILIO_VERIFY_SERVICE_ID. Twilio sends the code AND checks it —
# you never generate or store codes yourself.
# Returns "pending" when the code was sent.
def Twilio.send_verification_token(phone_number)
Twilio.get_client
.verify.v2
.services(ENV['TWILIO_VERIFY_SERVICE_ID'])
.verifications
.create(to: Twilio.internationalize(phone_number), channel: 'sms')
.status
end
# Returns "approved" when the code matches — check for exactly that string.
def Twilio.check_verification_token(phone_number, code)
Twilio.get_client
.verify.v2
.services(ENV['TWILIO_VERIFY_SERVICE_ID'])
.verification_checks
.create(to: Twilio.internationalize(phone_number), code: code)
.status
end
end
Layer 3 — Controller (phone-verification endpoints, optional)
# app/controllers/phone_verifications_controller.rb
class PhoneVerificationsController < ApplicationController
# If these are called from fetch() without a CSRF token, you may need:
# skip_before_action :verify_authenticity_token
# POST /phone_verifications/send_code params: { phone_number }
def send_code
status = Twilio.send_verification_token(params[:phone_number])
render json: { status: status } # "pending" = code sent
rescue Twilio::REST::RestError => e
render json: { status: "error", message: e.message }, status: :unprocessable_entity
end
# POST /phone_verifications/check_code params: { phone_number, code }
def check_code
status = Twilio.check_verification_token(params[:phone_number], params[:code])
if status == "approved"
# Mark the user/record verified here, e.g.:
# current_user.update!(phone_verified_at: Time.current)
render json: { status: "approved" }
else
render json: { status: status }, status: :unprocessable_entity
end
rescue Twilio::REST::RestError => e
render json: { status: "error", message: e.message }, status: :unprocessable_entity
end
end
# config/routes.rb (add inside the draw block)
post "phone_verifications/send_code", to: "phone_verifications#send_code"
post "phone_verifications/check_code", to: "phone_verifications#check_code"
Layer 4 — Model callback (SMS notification on new record)
The proven pattern: text the owner when a new form submission lands. The rescue matters — an SMS failure must never block the record from saving.
# app/models/submission.rb (adapt to whatever record should trigger a text)
class Submission < ApplicationRecord
after_create :notify_owner_by_sms
private
def notify_owner_by_sms
Twilio.send_text(Twilio.internationalize(owner_phone),
"New submission: #{data.to_s.truncate(120)}")
rescue => e
Rails.logger.error("Twilio notify failed: #{e.message}")
# Optionally fall back to texting a hardcoded admin number here.
end
end
For anything higher-volume than a single notification, move the send_text call into an
ActiveJob (SmsNotificationJob.perform_later(...)) so a slow Twilio API call never sits
inside a web request or a model callback.
Gotchas (the hard-won stuff)
.envchanges do NOT apply ondocker compose restart. Env vars are baked in when a container is created. After editing.env, rundocker compose down llamapress && docker compose up -d llamapressfrom the box's host shell (down/up the single service — don't down the whole stack). This is the #1 "I added the keys but ENV is nil" cause..envformatting bites silently. No quotes around values, no trailing spaces, no Windows line endings (\r). A stray trailing space becomes part of the auth token and Twilio returns 401. Check with:grep -nE '[[:space:]]$|\r' .env(should print nothing).- A Standard API key cannot read the Account resource — don't health-check with it.
GET /Accounts/<AC>.jsonwith a Standard key returns 401 error 70051 ("required permission twilio/iam/accounts/read is missing"). The credentials are fine — that endpoint just needs a Main key. Health-check with something the app actually uses instead:client.messages.list(limit: 1). - Scoping a Restricted key? Grant what the module uses.
send_textneeds Messages read/write; the Verify helpers need Verify. Miss one and you get the same 401 error 70051 naming the missing permission — read the error, add the permission, done. - Forgot
TWILIO_ACCOUNT_SIDin API key mode? The twilio-ruby client silently falls back to using theSK...SID as the account, and every API call fails with a not-found/auth error. Ifclient.account_sidstarts withSK, that's the bug. - The gem is already there — don't hand-roll HTTP. Leo instances can't add gems, but
twilio-rubyships in the base image. Confirm withbundle list | grep twiliobefore writing anyNet::HTTPfallback. app/services/twilio.rbreopens the gem's namespace. The gem itself definesmodule Twilio; this file adds class-level helpers to it. Keeprequire 'twilio-ruby'as the first line inside the module, orTwilio::REST::Clientwon't be defined when your helper loads first.- Trial accounts can only text verified numbers. Until the Twilio account is
upgraded,
send_textto any number not verified in the Twilio Console raisesTwilio::REST::RestError(error 21608). This looks like a code bug; it's an account limitation. - Always rescue around sends in callbacks and requests.
Twilio::REST::RestErrorcovers bad numbers, unverified recipients, and account issues — never let it 500 a request or roll back a record save. internationalizeis US-only. It blindly prefixes+1. If your users aren't in the US/Canada, store numbers in E.164 from the start and skip the helper.- Verify statuses are strings.
send_verification_tokenreturns"pending"on success;check_verification_tokenreturns"approved"only on a correct code (otherwise"pending"). Compare exact strings — a truthy check passes on failure too. - Never print or log the credentials. Don't
puts ENV['TWILIO_AUTH']while debugging, and don't interpolate it into error messages. Verify presence withENV['TWILIO_AUTH'].present?or the names-onlyenv | grep -ocommand above. - Every SMS costs money. Don't put
send_textinside a loop over users/records without meaning to. Batch or queue deliberately.
Files this pattern touches
.env # TWILIO_SID / TWILIO_AUTH / TWILIO_ACCOUNT_SID / TWILIO_NUMBER (+ TWILIO_VERIFY_SERVICE_ID)
app/services/twilio.rb # the module — SMS + Verify helpers
app/controllers/phone_verifications_controller.rb # optional — Verify endpoints
config/routes.rb # optional — the two POST routes
app/models/<your_model>.rb # optional — after_create SMS notification
How to adapt to your schema
- Just sending texts? Stop after Layers 1–2. Call
Twilio.send_text(number, message)from anywhere server-side (controller, model, job). - Notification on a record event? Copy Layer 4 onto your model; swap
owner_phone/datafor your columns and keep the rescue. - Phone verification? Create a Verify Service in the Twilio Console, add
TWILIO_VERIFY_SERVICE_ID, and wire Layers 3's two endpoints to your signup/settings form (two fetch() calls: send the code, then check it; on"approved", set aphone_verified_attimestamp on the user). - Higher volume? Wrap sends in an ActiveJob and consider Twilio Messaging Services (sender pools) — but read up on A2P 10DLC registration first; bulk US traffic from a bare number gets carrier-filtered.