---
title: Twilio — SMS Sending & Phone Verification
slug: twilio-sms-and-phone-verification
category: Integrations
summary: Drop-in Twilio module for sending SMS texts and verifying phone numbers (Twilio Verify), the TWILIO_SID/TWILIO_AUTH/TWILIO_NUMBER .env keys it needs, and the container-recreate step that actually loads them. The twilio-ruby gem is already in the base image.
tags: [twilio, sms, verification, api, env, service, notifications]
status: stable
visibility: public
source_project: llamapress.ai
layers: [controller, model]
related:
  - title: Unsplash API — Stock Photos with Attribution
    url: /cookbook/unsplash-api-stock-photos
    summary: Sibling integration recipe — same .env-key + service-object pattern, but for an API with no gem available.
  - title: Twilio API docs — Messages & Verify
    url: https://www.twilio.com/docs
    summary: Official reference for the Messages API and the Verify v2 API used below.
---

# 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. Get the **Account SID**, **Auth Token**, and a **Twilio phone number** from the
   Twilio Console (`console.twilio.com`).
2. Add `TWILIO_SID`, `TWILIO_AUTH`, 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.

---

## Layer 1 — Environment keys (.env)

```bash
# .env  (project root, next to docker-compose.yml — the compose file already
# passes this whole file into the web container via `env_file: .env`)

# REQUIRED — Account SID from the Twilio Console dashboard (starts with "AC")
TWILIO_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# REQUIRED — Auth Token from the same dashboard page
TWILIO_AUTH=your_auth_token_here

# 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:

```bash
# .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):

```bash
docker compose exec -T llamapress bash -c 'env | grep -o "^TWILIO_[A-Z_]*" | sort'
```

---

## Layer 2 — The service (SMS sending)

```ruby
# 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

  def Twilio.get_client
    Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])
  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)

```ruby
# 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
```

```ruby
# 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.

```ruby
# 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).
- **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_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.
