{"slug":"twilio-sms-and-phone-verification","meta":{"title":"Twilio — SMS Sending \u0026 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 \u0026 Verify","url":"https://www.twilio.com/docs","summary":"Official reference for the Messages API and the Verify v2 API used below."}]},"body":"# Twilio — SMS Sending \u0026 Phone Verification\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nYour app needs to text people — \"new lead came in\", \"your order shipped\" — or to prove a\nuser owns a phone number before trusting it. Twilio does both. This recipe gives you a\ncomplete `Twilio` service module (plain SMS via the Messages API, plus optional\none-time-code phone verification via Twilio Verify), the three `.env` keys it reads, and\nthe one deployment step people always miss: **recreating the web container so the new\nenv vars actually load**.\n\nGood news up front: **the `twilio-ruby` gem is already installed in the base image**\n(verified: `twilio-ruby 7.10.5` in `llamapress-simple:0.4.0g`). You cannot add gems on a\nLeo instance, but you don't need to — this one is there. Run\n`bundle list | grep twilio` inside the `llamapress` container if you want to confirm on\nyour box.\n\n\u003e **When to use:** outbound SMS notifications (owner alerts, order updates,\n\u003e one-off texts) and SMS one-time-code phone verification.\n\u003e **When not to:** email (use Action Mailer / SES), chat between users (build that\n\u003e in-app), or marketing blasts to large lists (carrier compliance — A2P 10DLC\n\u003e registration — is its own project; don't loop `send_text` over a big list).\n\n---\n\n## The 80/20 in one breath\n\n1. Get the **Account SID**, **Auth Token**, and a **Twilio phone number** from the\n   Twilio Console (`console.twilio.com`).\n2. Add `TWILIO_SID`, `TWILIO_AUTH`, and `TWILIO_NUMBER` to the `.env` at the project\n   root. No quotes, no trailing spaces. Number in E.164 form (`+15551234567`).\n3. Recreate the web container so the vars load — a plain `restart` does **not** reload\n   `.env` (see Gotchas).\n4. Copy the `Twilio` module below into `app/services/twilio.rb`.\n5. `Twilio.send_text(\"+15551234567\", \"It works!\")` — done. Verification endpoints are\n   optional Layer 3.\n\n---\n\n## Layer 1 — Environment keys (.env)\n\n```bash\n# .env  (project root, next to docker-compose.yml — the compose file already\n# passes this whole file into the web container via `env_file: .env`)\n\n# REQUIRED — Account SID from the Twilio Console dashboard (starts with \"AC\")\nTWILIO_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n\n# REQUIRED — Auth Token from the same dashboard page\nTWILIO_AUTH=your_auth_token_here\n\n# REQUIRED — a Twilio phone number you own, in E.164 format (the \"from\" number)\nTWILIO_NUMBER=+15551234567\n\n# OPTIONAL — only for the phone-verification flow (Layer 3).\n# Create a Verify Service in Console → Verify → Services; copy its SID (starts \"VA\").\nTWILIO_VERIFY_SERVICE_ID=VAxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\nThen, **from the box's host shell** (not inside a container), reload the env:\n\n```bash\n# .env is only read when a container is CREATED, not on restart.\ncd ~/Leonardo\ndocker compose down llamapress \u0026\u0026 docker compose up -d llamapress\n```\n\nVerify the vars made it in (names only — never print the values):\n\n```bash\ndocker compose exec -T llamapress bash -c 'env | grep -o \"^TWILIO_[A-Z_]*\" | sort'\n```\n\n---\n\n## Layer 2 — The service (SMS sending)\n\n```ruby\n# app/services/twilio.rb\n#\n# NOTE: this REOPENS the gem's own `Twilio` module (the gem defines\n# Twilio::REST::Client). That is intentional and works fine with Rails\n# autoloading — but the `require 'twilio-ruby'` line must stay at the top.\nmodule Twilio\n  require 'twilio-ruby'\n\n  # Send an SMS. `to` should be E.164 (\"+15551234567\"); use internationalize()\n  # if you're storing bare US numbers.\n  def Twilio.send_text(to, message)\n    client = Twilio.get_client\n    client.messages.create(\n      from: ENV['TWILIO_NUMBER'],\n      to:   to,\n      body: message\n    )\n  end\n\n  def Twilio.get_client\n    Twilio::REST::Client.new(ENV['TWILIO_SID'], ENV['TWILIO_AUTH'])\n  end\n\n  # Prefixes +1 if the number isn't internationalized already.\n  # US-centric — adapt the prefix (or use a proper phone lib) for other countries.\n  def Twilio.internationalize(number)\n    number.start_with?(\"+1\") ? number : \"+1\" + number\n  end\n\n  # --- Optional: Twilio Verify (one-time-code phone verification) ---\n  # Requires TWILIO_VERIFY_SERVICE_ID. Twilio sends the code AND checks it —\n  # you never generate or store codes yourself.\n\n  # Returns \"pending\" when the code was sent.\n  def Twilio.send_verification_token(phone_number)\n    Twilio.get_client\n          .verify.v2\n          .services(ENV['TWILIO_VERIFY_SERVICE_ID'])\n          .verifications\n          .create(to: Twilio.internationalize(phone_number), channel: 'sms')\n          .status\n  end\n\n  # Returns \"approved\" when the code matches — check for exactly that string.\n  def Twilio.check_verification_token(phone_number, code)\n    Twilio.get_client\n          .verify.v2\n          .services(ENV['TWILIO_VERIFY_SERVICE_ID'])\n          .verification_checks\n          .create(to: Twilio.internationalize(phone_number), code: code)\n          .status\n  end\nend\n```\n\n---\n\n## Layer 3 — Controller (phone-verification endpoints, optional)\n\n```ruby\n# app/controllers/phone_verifications_controller.rb\nclass PhoneVerificationsController \u003c ApplicationController\n  # If these are called from fetch() without a CSRF token, you may need:\n  # skip_before_action :verify_authenticity_token\n\n  # POST /phone_verifications/send_code   params: { phone_number }\n  def send_code\n    status = Twilio.send_verification_token(params[:phone_number])\n    render json: { status: status }              # \"pending\" = code sent\n  rescue Twilio::REST::RestError =\u003e e\n    render json: { status: \"error\", message: e.message }, status: :unprocessable_entity\n  end\n\n  # POST /phone_verifications/check_code  params: { phone_number, code }\n  def check_code\n    status = Twilio.check_verification_token(params[:phone_number], params[:code])\n    if status == \"approved\"\n      # Mark the user/record verified here, e.g.:\n      # current_user.update!(phone_verified_at: Time.current)\n      render json: { status: \"approved\" }\n    else\n      render json: { status: status }, status: :unprocessable_entity\n    end\n  rescue Twilio::REST::RestError =\u003e e\n    render json: { status: \"error\", message: e.message }, status: :unprocessable_entity\n  end\nend\n```\n\n```ruby\n# config/routes.rb  (add inside the draw block)\npost \"phone_verifications/send_code\",  to: \"phone_verifications#send_code\"\npost \"phone_verifications/check_code\", to: \"phone_verifications#check_code\"\n```\n\n---\n\n## Layer 4 — Model callback (SMS notification on new record)\n\nThe proven pattern: text the owner when a new form submission lands. The rescue matters —\nan SMS failure must never block the record from saving.\n\n```ruby\n# app/models/submission.rb  (adapt to whatever record should trigger a text)\nclass Submission \u003c ApplicationRecord\n  after_create :notify_owner_by_sms\n\n  private\n\n  def notify_owner_by_sms\n    Twilio.send_text(Twilio.internationalize(owner_phone),\n                     \"New submission: #{data.to_s.truncate(120)}\")\n  rescue =\u003e e\n    Rails.logger.error(\"Twilio notify failed: #{e.message}\")\n    # Optionally fall back to texting a hardcoded admin number here.\n  end\nend\n```\n\nFor anything higher-volume than a single notification, move the `send_text` call into an\nActiveJob (`SmsNotificationJob.perform_later(...)`) so a slow Twilio API call never sits\ninside a web request or a model callback.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **`.env` changes do NOT apply on `docker compose restart`.** Env vars are baked in when\n  a container is *created*. After editing `.env`, run\n  `docker compose down llamapress \u0026\u0026 docker compose up -d llamapress` from the box's host\n  shell (down/up the single service — don't down the whole stack). This is the #1 \"I\n  added the keys but ENV is nil\" cause.\n- **`.env` formatting bites silently.** No quotes around values, no trailing spaces, no\n  Windows line endings (`\\r`). A stray trailing space becomes part of the auth token and\n  Twilio returns 401. Check with: `grep -nE '[[:space:]]$|\\r' .env` (should print\n  nothing).\n- **The gem is already there — don't hand-roll HTTP.** Leo instances can't add gems, but\n  `twilio-ruby` ships in the base image. Confirm with\n  `bundle list | grep twilio` before writing any `Net::HTTP` fallback.\n- **`app/services/twilio.rb` reopens the gem's namespace.** The gem itself defines\n  `module Twilio`; this file adds class-level helpers to it. Keep\n  `require 'twilio-ruby'` as the first line inside the module, or `Twilio::REST::Client`\n  won't be defined when your helper loads first.\n- **Trial accounts can only text verified numbers.** Until the Twilio account is\n  upgraded, `send_text` to any number not verified in the Twilio Console raises\n  `Twilio::REST::RestError` (error 21608). This looks like a code bug; it's an account\n  limitation.\n- **Always rescue around sends in callbacks and requests.** `Twilio::REST::RestError`\n  covers bad numbers, unverified recipients, and account issues — never let it 500 a\n  request or roll back a record save.\n- **`internationalize` is US-only.** It blindly prefixes `+1`. If your users aren't in\n  the US/Canada, store numbers in E.164 from the start and skip the helper.\n- **Verify statuses are strings.** `send_verification_token` returns `\"pending\"` on\n  success; `check_verification_token` returns `\"approved\"` only on a correct code\n  (otherwise `\"pending\"`). Compare exact strings — a truthy check passes on failure too.\n- **Never print or log the credentials.** Don't `puts ENV['TWILIO_AUTH']` while\n  debugging, and don't interpolate it into error messages. Verify presence with\n  `ENV['TWILIO_AUTH'].present?` or the names-only `env | grep -o` command above.\n- **Every SMS costs money.** Don't put `send_text` inside a loop over users/records\n  without meaning to. Batch or queue deliberately.\n\n---\n\n## Files this pattern touches\n\n```\n.env                                                   # TWILIO_SID / TWILIO_AUTH / TWILIO_NUMBER (+ TWILIO_VERIFY_SERVICE_ID)\napp/services/twilio.rb                                 # the module — SMS + Verify helpers\napp/controllers/phone_verifications_controller.rb      # optional — Verify endpoints\nconfig/routes.rb                                       # optional — the two POST routes\napp/models/\u003cyour_model\u003e.rb                             # optional — after_create SMS notification\n```\n\n## How to adapt to your schema\n\n1. **Just sending texts?** Stop after Layers 1–2. Call\n   `Twilio.send_text(number, message)` from anywhere server-side (controller, model,\n   job).\n2. **Notification on a record event?** Copy Layer 4 onto your model; swap\n   `owner_phone`/`data` for your columns and keep the rescue.\n3. **Phone verification?** Create a Verify Service in the Twilio Console, add\n   `TWILIO_VERIFY_SERVICE_ID`, and wire Layers 3's two endpoints to your signup/settings\n   form (two fetch() calls: send the code, then check it; on `\"approved\"`, set a\n   `phone_verified_at` timestamp on the user).\n4. **Higher volume?** Wrap sends in an ActiveJob and consider Twilio Messaging Services\n   (sender pools) — but read up on A2P 10DLC registration first; bulk US traffic from a\n   bare number gets carrier-filtered.\n"}