Twilio — SMS Sending & Phone Verification | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Twilio — SMS Sending & Phone Verification

Drop-in Twilio module for sending SMS texts and verifying phone numbers (Twilio Verify), the API-key-first credential setup (TWILIO_SID/TWILIO_AUTH/TWILIO_ACCOUNT_SID/TWILIO_NUMBER), and the container-recreate step that actually loads them. The twilio-ruby gem is already in the base image.

Proven on llamapress.ai controller · model

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_text over a big list).


The 80/20 in one breath

  1. In the Twilio Console (console.twilio.com), create an API key (Account → API keys & tokens → Create API key). Copy the key SID (starts with SK) and the Secret — the secret shows only once. Also note the Account SID (starts with AC) and get a Twilio phone number.
  2. Add TWILIO_SID (the SK... key SID), TWILIO_AUTH (the key secret), TWILIO_ACCOUNT_SID (the AC... value), and TWILIO_NUMBER to the .env at the project root. No quotes, no trailing spaces. Number in E.164 form (+15551234567).
  3. Recreate the web container so the vars load — a plain restart does not reload .env (see Gotchas).
  4. Copy the Twilio module below into app/services/twilio.rb.
  5. 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)

  • .env changes do NOT apply on docker compose restart. Env vars are baked in when a container is created. After editing .env, run docker compose down llamapress && docker compose up -d llamapress from 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.
  • .env formatting 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>.json with 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_text needs 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_SID in API key mode? The twilio-ruby client silently falls back to using the SK... SID as the account, and every API call fails with a not-found/auth error. If client.account_sid starts with SK, that's the bug.
  • The gem is already there — don't hand-roll HTTP. Leo instances can't add gems, but twilio-ruby ships in the base image. Confirm with bundle list | grep twilio before writing any Net::HTTP fallback.
  • app/services/twilio.rb reopens the gem's namespace. The gem itself defines module Twilio; this file adds class-level helpers to it. Keep require 'twilio-ruby' as the first line inside the module, or Twilio::REST::Client won't be defined when your helper loads first.
  • Trial accounts can only text verified numbers. Until the Twilio account is upgraded, send_text to any number not verified in the Twilio Console raises Twilio::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::RestError covers bad numbers, unverified recipients, and account issues — never let it 500 a request or roll back a record save.
  • internationalize is 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_token returns "pending" on success; check_verification_token returns "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 with ENV['TWILIO_AUTH'].present? or the names-only env | grep -o command above.
  • Every SMS costs money. Don't put send_text inside 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

  1. Just sending texts? Stop after Layers 1–2. Call Twilio.send_text(number, message) from anywhere server-side (controller, model, job).
  2. Notification on a record event? Copy Layer 4 onto your model; swap owner_phone/data for your columns and keep the rescue.
  3. 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 a phone_verified_at timestamp on the user).
  4. 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.

Related