3 ways to send SMS with Ruby

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.

Which option should you pick?

  • Twilio for the shortest path to a working send and the largest body of existing answers when something breaks.
  • Plivo if per-message cost is what you are optimizing at volume.
  • A notification API in front of either one, once SMS is part of a product flow rather than a script. That is the point where templates, opt-outs, retries, and multi-channel routing stop being small problems.

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.

1. Send SMS with Twilio

Twilio's Programmable Messaging API sends and receives SMS, reports delivery status through webhooks, and schedules sends.

What you need first

  • A Twilio account, trial or paid.
  • Ruby 3.1 or newer.
  • An SMS-enabled Twilio number.
  • For US consumer traffic, a registered 10DLC campaign or a verified toll-free number. Missing this is the most common reason working code delivers nothing in production.

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.new
message = 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.

2. Send SMS with Plivo

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

  • A Plivo account, plus your Auth ID and Auth Token.
  • A Plivo number or registered sender ID. On a trial account you can only send to numbers you have verified with Plivo.

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.new
response = 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.

3. Send SMS with a notification API

Both options above solve delivery. Here is what tends to arrive in the fortnight after delivery works:

  • Message copy is a string literal in your code, so a wording change is a deploy.
  • Someone replies STOP and you now own an opt-out list and the obligation behind it.
  • The same event needs to reach one user by SMS and another by email.
  • Your provider has a bad hour and there is no second path.
  • Twenty events fire in a minute and one user gets twenty texts.
  • Someone asks how many notifications failed last week and you cannot answer.

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:

  • Automatic failover between providers when one starts erroring.
  • Journeys for multi-step sequences, with digest and throttling nodes so a burst of events is not a burst of texts.
  • One send endpoint for SMS, email, push, in-app, Slack, and Microsoft Teams.
  • Per-user, per-channel preferences enforced before a send.
  • Templates edited outside your codebase, with localization per user.
  • One log covering every channel.

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.new
begin
res = client.send_.message(
message: {
to: { phone_number: "+15558675309" },
content: {
title: "Order update",
body: "Hi Erika, your order has shipped."
}
}
)
puts res.request_id
rescue Courier::Errors::APIStatusError => e
warn "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.

Where to put the send in a Rails app

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.rb
class OrderShippedNotificationJob < ApplicationJob
queue_as :notifications
retry_on Courier::Errors::APIConnectionError, wait: :polynomially_longer, attempts: 5
def perform(user_id, tracking_url)
client = Courier::Client.new
client.send_.message(
message: {
to: { user_id: user_id },
template: "ORDER_SHIPPED",
data: { tracking_url: tracking_url }
}
)
end
end

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.

Building this with an AI agent

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.

Comparing the three options

TwilioPlivoNotification API
Gemtwilio-ruby 7.11.2plivo 4.63.4trycourier 6.5.0
Requiretwilio-rubyplivocourier
ClientTwilio::REST::Client.newRestClient.newCourier::Client.new
Send callclient.messages.createclient.messages.createclient.send_.message
Reads credentials from ENVyesyesyes
Other channelsSMS, WhatsApp, voice, emailSMS, WhatsApp, voicewhatever you connect
Templates outside your codenonoyes
Preferences and opt-outsyou build ityou build itbuilt in
Provider failovern/an/ayes, across providers
Best forfirst send, widest communityhigh volume, cost-sensitiveSMS 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.

Next steps


FAQ

Frequently asked questions

Install a provider gem, build a client from credentials held in environment variables, and call that client's create-message method with a sender number, a recipient number, and a body. With Twilio that is 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.

One API, every channel

Ship notifications without the boilerplate

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.