Send SMS from Ruby or Rails with Twilio, Plivo, or a notification API. Runnable code for each option, plus where to put the send so it can safely retry.
Updated Sep 11, 2026
Last updated: September 2026. Package versions verified against RubyGems on 2026-09-11.
To send an SMS from Ruby, call a provider's API with a sender number, a recipient number, and a body. That is a handful of lines with any of the options below. The decisions that actually matter are where you put that call, and how much of the surrounding machinery you plan to build yourself.
SMS earns its place for time-sensitive product messages: verification codes, delivery updates, appointment reminders, and security alerts. This guide covers three ways to send one, with runnable code for each.
The first two are the same shape, so switching between them is an afternoon. The third is a layer rather than a third vendor, and you use it with Twilio or Plivo rather than instead of one.
Twilio's Programmable Messaging API sends and receives SMS, reports delivery status through webhooks, and schedules sends.
What you need first
Install
Add it to your Gemfile:
gem "twilio-ruby"
Then bundle install. Verified against twilio-ruby 7.11.2. Do not pin to a 5.x version you find in an older tutorial; 5.x predates several API changes.
Send the message
require "twilio-ruby"# With no arguments, the client reads TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN# from the environment.client = Twilio::REST::Client.newmessage = client.messages.create(from: ENV.fetch("TWILIO_PHONE_NUMBER"),to: ENV.fetch("CELL_PHONE_NUMBER"),body: "Your order has shipped.")puts "#{message.sid} #{message.status}"
ENV.fetch rather than ENV[] is deliberate: it raises on a missing variable instead of passing nil to Twilio and getting a confusing API error back.
The status you get is queued or accepted, not delivered. To find out what actually happened, either poll client.messages(message.sid).fetch or register a status callback webhook.
Where it gets thin: one provider, message copy in your source code, and delivery events arriving as raw webhooks you store and interpret yourself.
Plivo covers the same ground at generally lower per-message pricing, which is why it appears in high-volume sending. The tradeoff is a smaller community, so unusual failures take longer to diagnose.
What you need first
Install
Add it to your Gemfile:
gem "plivo"
Verified against plivo 4.63.4.
Send the message
require "plivo"include Plivo# With no arguments, RestClient reads PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN# from the environment.client = RestClient.newresponse = client.messages.create(src: ENV.fetch("PLIVO_PHONE_NUMBER"),dst: ENV.fetch("CELL_PHONE_NUMBER"),text: "Your order has shipped.")puts response.message_uuid
include Plivo at the top level is what the gem's own README does, and it is what makes the bare RestClient constant resolve. If your app also uses the rest-client gem, skip the include and write Plivo::RestClient.new to avoid a constant collision.
Both src and dst need E.164 formatting. For delivery tracking, stand up an endpoint and give Plivo its URL.
Where it gets thin: the same gaps as Twilio, since it is the same layer.
Both options above solve delivery. Here is what tends to arrive in the fortnight after delivery works:
A notification API such as Courier sits above your provider and handles those. You keep Twilio or Plivo delivering, and your Ruby code stops knowing which one it is.
What the extra layer gives you:
Set it up
Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then connect an SMS provider under Channels using the credentials that provider already gave you. This is the same Twilio or Plivo account from the sections above; you are not changing who delivers.
Install
Add it to your Gemfile:
gem "trycourier"
Verified against trycourier 6.5.0, which requires Ruby 3.2 or newer. The gem is trycourier but the require is courier.
Send the message
require "courier"# Reads COURIER_API_KEY from the environment.client = Courier::Client.newbeginres = client.send_.message(message: {to: { phone_number: "+15558675309" },content: {title: "Order update",body: "Hi Erika, your order has shipped."}})puts res.request_idrescue Courier::Errors::APIStatusError => ewarn "Courier rejected the send: #{e.message}"end
The method is send_, with a trailing underscore, because send is already a method on every Ruby object and the SDK cannot shadow it.
On the rescue: the error hierarchy is namespaced under Courier::Errors, with APIStatusError covering HTTP failures and subclasses such as BadRequestError and AuthenticationError for specific statuses. Courier::Errors::Error is the base if you want to catch everything.
The version you will run in production references a template you published in Courier and addresses the user rather than a raw number:
res = client.send_.message(message: {to: { user_id: "user-123" },template: "ORDER_SHIPPED",data: { name: "Erika", tracking_url: "https://example.com/t/abc" }})
Nothing in that call names SMS. Routing and the user's saved preferences decide the channel, so moving a notification to push, or sending both, is configuration rather than code.
Where it gets thin: one more service in the path, and it is the wrong tool for two-way SMS conversations, which belong with your provider's inbound APIs.
This matters more than the gem you pick. A provider call takes a few hundred milliseconds on a good day and fails intermittently on a bad one, so it does not belong in a controller action.
# app/jobs/order_shipped_notification_job.rbclass OrderShippedNotificationJob < ApplicationJobqueue_as :notificationsretry_on Courier::Errors::APIConnectionError, wait: :polynomially_longer, attempts: 5def perform(user_id, tracking_url)client = Courier::Client.newclient.send_.message(message: {to: { user_id: user_id },template: "ORDER_SHIPPED",data: { tracking_url: tracking_url }})endend
Then the controller or model enqueues it and returns immediately:
OrderShippedNotificationJob.perform_later(user.id, tracking_url)
Two things this buys you. Active Job retries transient network failures without you writing a retry loop, and your request latency stops depending on a third party's response time. Retry only on connection and timeout errors; retrying a BadRequestError will fail identically five times.
If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Ruby that compiles and Ruby that does not.
The MCP server gives the agent typed tool calls. In Claude Code:
claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY
Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:
npx skills add trycourier/courier-skills
Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.
Then ask in plain language: "send an SMS when an order ships, and fall back to email if it fails." The agent picks the right primitive, and knows which channels need a recorded opt-in.
This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.
| Twilio | Plivo | Notification API | |
|---|---|---|---|
| Gem | twilio-ruby 7.11.2 | plivo 4.63.4 | trycourier 6.5.0 |
| Require | twilio-ruby | plivo | courier |
| Client | Twilio::REST::Client.new | RestClient.new | Courier::Client.new |
| Send call | client.messages.create | client.messages.create | client.send_.message |
| Reads credentials from ENV | yes | yes | yes |
| Other channels | SMS, WhatsApp, voice, email | SMS, WhatsApp, voice | whatever you connect |
| Templates outside your code | no | no | yes |
| Preferences and opt-outs | you build it | you build it | built in |
| Provider failover | n/a | n/a | yes, across providers |
| Best for | first send, widest community | high volume, cost-sensitive | SMS inside a product flow |
If you need one text message sent this afternoon, add twilio-ruby and stop reading. If SMS is becoming a feature of your product, the decision you will wish you had made earlier is the layer that lets you change provider, wording, and channel without touching Ruby.
FAQ
Twilio::REST::Client.new and client.messages.create(from:, to:, body:). Ruby has no built-in SMS capability, so every route means calling a provider's HTTP API.twilio-ruby if you want the widest community and the shortest path to a first send, and plivo if per-message cost is the deciding factor at volume. Both are single-provider gems. Use trycourier on top of one of them when SMS is one channel in a notification system, because it moves templates, per-user preferences, and provider failover out of your application code.module that nothing includes or extends. Defining def send_sms in a bare module makes it an instance method of that module, so calling send_sms(...) at the top level finds nothing. Either define it as def self.send_sms, or extend the module, or skip the module and call the provider client directly.ENV["TWILIO_AUTH_TOKEN"] or ENV["COURIER_API_KEY"], and keep them out of committed files. In Rails, use encrypted credentials through rails credentials:edit or your host's environment settings. Both the Twilio and Plivo clients also read their credentials from conventionally named environment variables if you construct them with no arguments.client.send_.message, while which provider delivers the SMS, in what order, and with what fallback becomes a dashboard setting. With a provider gem the vendor is baked into your require line and your method calls, so a switch means touching code and redeploying.client.send_.message with a user_id and a template, and the platform resolves whether that becomes SMS, email, push, or in-app based on the template's routing and that user's saved preferences. Adding a channel later is a configuration change rather than a second integration.Keep exploring
One API, every channel
Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.
Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.