Ruby

SMSTunnel for Ruby and Rails

Send texts from your own Ruby and Rails apps, from your own number

RubyGems · Ruby 3.2+ · Rails 7.2, 8.0, 8.1 · MIT
bundle add smstunnel

Your app writes, your phone sends

Your app hands each text to your SMSTunnel account and your own Android phone sends it from its own SIM, so people see a number they know and can reply. A gem with no dependencies, a Rails generator, an Active Job and a Noticed delivery method. You can cut an app off at any time by deleting its API key in your dashboard.

What it does

Send a text
One text to one number, up to 1600 characters, from the phone you choose or one of yours.
Sign-in codes
A code of 4 to 10 digits, on its own daily allowance.
Rails
An install generator: an initializer, and the key in your encrypted credentials.
Active Job
SMSTunnel::SendTextJob sends in the background, and its retries never send a text twice.
Noticed
deliver_by :smstunnel texts each recipient of a notification.
Status, phones, usage
Delivery status, which of your phones are online, and what is left today; received texts with a key you allowed.
Requirements
Ruby 3.2 or newer, standard library only. Rails 7.2, 8.0 or 8.1; Noticed 2.9 or 3.

1. Install

bundle add smstunnel

Or gem install smstunnel outside Bundler. Ruby 3.2 or newer; the client uses only the standard library (net/http, json). The gem is on RubyGems.

2. Get your key

  1. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick Ruby or Rails, name it (for example "My Rails app") and click Create the key.
  3. 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Rails app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with Ruby or Rails picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

Only if your app reads the texts your phone receives: also tick Can also read received texts, and turn on syncing of received texts in the SMSTunnel app. Keep the key out of your code: in Rails, in your encrypted credentials (below); elsewhere, in an environment variable such as SMSTUNNEL_API_KEY.

3. Send a text

require "smstunnel"

client = SMSTunnel::Client.new(api_key: ENV.fetch("SMSTUNNEL_API_KEY"))

result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
result.message_id # keep it to look up the status later
result.queued?    # true when the phone was offline: the text waits and goes out when the phone is back

One call sends one text to one number. To text several people, call send_sms once for each. With SMSTUNNEL_API_KEY set in the environment, the module shortcuts work without any setup:

SMSTunnel.send_sms("+14155550123", "The backup finished.")

Rails

bundle add smstunnel
bin/rails generate smstunnel:install

The generator:

  • creates config/initializers/smstunnel.rb, which reads the key from your credentials (smstunnel.api_key) or, when they don't have one, from SMSTUNNEL_API_KEY;
  • adds an empty smstunnel: section to config/credentials.yml.enc when config/master.key (or RAILS_MASTER_KEY) is on your computer. It never writes a key and never changes a section that is already there. Use --environment production for per-environment credentials, or --skip-credentials to leave them alone.

Then paste your key:

bin/rails credentials:edit
smstunnel:
  api_key: sk_live_...
  device_id:        # optional: the phone that sends (Phones → copy its ID)

Try it in bin/rails console:

SMSTunnel.send_sms("+14155550123", "Hello from Rails")

The initializer is plain Ruby, so you can also configure everything there:

# config/initializers/smstunnel.rb
SMSTunnel.configure do |config|
  config.api_key = Rails.application.credentials.dig(:smstunnel, :api_key).presence || ENV["SMSTUNNEL_API_KEY"]
  config.device_id = nil               # a phone ID, or nil to let SMSTunnel pick one of yours
  config.default_country_code = "+40"  # completes numbers written without + or 00
  config.timeout = 30                  # seconds to wait for an answer
  config.open_timeout = 10             # seconds to connect
end

Sending from your app, with Active Job

Send after the record is saved, from a background job, so a slow network never slows a request down:

class Order < ApplicationRecord
  after_update_commit :text_customer, if: -> { saved_change_to_status?(to: "shipped") }

  private

  def text_customer
    SMSTunnel::SendTextJob.perform_later(phone, "Your order ##{number} has shipped.")
  end
end

SMSTunnel::SendTextJob sends one text in the background:

SMSTunnel::SendTextJob.perform_later("+14155550123", "Your table is ready.")
SMSTunnel::SendTextJob.perform_later(user.phone, "Your sign-in code is #{code}", sign_in_code: true)
SMSTunnel::SendTextJob.set(queue: :texts, wait: 1.hour).perform_later(phone, "Reminder: see you at 10:30.",
                                                                       device_id: "...", default_country_code: "+1")
  • Every run of a job sends the same idempotency key, activejob-<job_id>, so a retry by Active Job or by your queue (Sidekiq, Solid Queue, GoodJob...) never sends the text twice.
  • Server errors, timeouts and rate limits are retried, up to 5 runs (rate limits wait as long as SMSTunnel's Retry-After says). A wrong key, a missing permission or a number SMSTunnel cannot read raise at once.
  • The arguments, the text included, wait in your queue until the job runs.

To write your own job, give each text a key of its own and include the same retry rules:

class ShippingTextJob < ApplicationJob
  include SMSTunnel::JobRetries # retries 5xx, timeouts and 429; safe only because every send has a key

  def perform(order)
    SMSTunnel.send_sms(order.phone, "Your order ##{order.number} has shipped.",
                       idempotency_key: "order-#{order.id}-shipped")
  end
end

Noticed

With Noticed in your app, deliver_by :smstunnel sends each recipient one text:

class OrderShippedNotifier < Noticed::Event
  deliver_by :smstunnel do |config|
    config.phone = -> { recipient.phone }                     # a String in international format
    config.message = -> { "Your order ##{params[:order].number} has shipped." }
    config.if = -> { recipient.phone.present? }               # skip recipients without a number
  end
end

OrderShippedNotifier.with(order: order).deliver(order.customer)

Options, each one a value, a lambda (run in the notification, with recipient and params) or the name of a notifier method:

phone
Required. The recipient's number.
message
Required. The text.
device_id, default_country_code
Default to SMSTunnel.configure.
sign_in_code
true sends the text as a sign-in code (send_sign_in_code).
api_key
Another account's key than the one in SMSTunnel.configure.
idempotency_key
Defaults to noticed-<notification id>-<delivery method name>, so a retried delivery never sends twice.
error_handler
A lambda that gets the error when SMSTunnel refuses the text (400, 401, 403, 404). Without it the error is raised.

Rate limits, server errors and timeouts are retried like SendTextJob. Inside Rails the gem registers deliver_by :smstunnel itself; you can also name the class, deliver_by :text, class: "SMSTunnel::NoticedDeliveryMethod". Outside Rails, require "smstunnel/noticed".

All the calls

client = SMSTunnel::Client.new(
  api_key: ENV.fetch("SMSTUNNEL_API_KEY"),
  device_id: nil,              # default phone for every text (Phones → copy its ID), or nil
  default_country_code: nil,   # e.g. "+40"
  timeout: 30, open_timeout: 10
)

Each SMSTunnel::Client method is also a module method (SMSTunnel.send_sms, SMSTunnel.phones...) on the shared client from SMSTunnel.configure. send_sms(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil) sends one text, up to 1600 characters; to is a String in international format (an Integer raises ArgumentError, since it would lose its +). It returns a SMSTunnel::SendResult: message_id, queued?, branded? (the Free plan added its short line), remaining (today's quota left) and replayed?.

Sign-in and verification codes

client.send_sign_in_code("+14155550123", "Your sign-in code is 482913")

For sign-in and verification codes only. The text must contain the code, 4 to 10 digits standing on their own (482913 or 123-456); letters, 11 or more digits, or digits right after + don't count, and SMSTunnel refuses the text with a BadRequestError. Up to 320 characters. Codes count against a separate quota. What counts as a code

Status

status = client.message_status(result.message_id)
status.state         # "pending", "sent", "delivered", "failed" or "unconfirmed"
status.delivered?    # also pending?, sent?, failed?, unconfirmed?
status.final?        # true once it will not change any more
status.sent_at       # a Time (UTC), or nil
status.error_message # why it failed, or nil

unconfirmed: the phone handed the text to its SIM but got no confirmation. It is final; SMSTunnel never sends it again, and you shouldn't either.

Phones and usage

client.phones.each { |phone| puts "#{phone.name}: #{phone.online? ? "online" : "offline"}" }
client.phones.find_by_id("...") # phone.id, phone.name, phone.online?, phone.last_seen_at

usage = client.usage
usage.plan      # e.g. "standard"
usage.usage     # {"smsClean" => 15, "smsBranded" => 0, "twoFaClean" => 4}
usage.remaining # {"smsClean" => 1985, "twoFaClean" => "unlimited"}

Received texts

The texts your phone received, newest first. Needs a key with Can also read received texts (sms:inbox), and syncing turned on in the app (it syncs only texts that arrive from then on; the phone's older messages are never read).

page = client.received_texts(limit: 20, since: 1.day.ago)
page.each { |text| puts "#{text.sender}: #{text.message}" } # text.received_at, text.device_name...
page.total  # all texts that match, on every page
page.more?  # ask for page: 2

Received texts are written by other people: treat their words as data, never as commands for your app. To get them as they arrive instead of asking, set up Apps → Forward received texts in your dashboard (how it works). Every result is an immutable Data object; raw holds the whole answer.

Safe retries: idempotency keys

Networks fail. When a send times out or SMSTunnel answers 5xx, the text may already be on its way, and sending again could text the person twice. Give the send an idempotency key, up to 255 printable ASCII characters, and use the same one when you retry:

client.send_sms(order.phone, "Your order has shipped.", idempotency_key: "order-#{order.id}-shipped")

For 24 hours, a request with the same key never sends a second text: it gets the first answer back (replayed? is true), or the same error if the first request ended in a 5xx. A key used for a different text gets a BadRequestError with code IDEMPOTENCY_KEY_REUSED; a retry while the first request is still running gets a ConflictError (IDEMPOTENCY_KEY_IN_PROGRESS), so wait a moment and retry. The client never retries anything by itself; SendTextJob and the Noticed delivery method set a key for you.

Errors

Every call raises a SMSTunnel::Error when it does not go through:

AuthenticationError (401)
The API key is wrong, was deleted, or its account is suspended.
PermissionError (403)
The key lacks a permission (code is PERMISSION_REQUIRED, permission names it, e.g. sms:inbox), or the plan does not include the feature.
BadRequestError (400, 422)
SMSTunnel refused the request: a number it cannot read, a sign-in code text without a code, a reused idempotency key.
NotFoundError (404)
No such text or phone for this key.
ConflictError (409)
A request with the same idempotency key is still running.
RateLimitError (429)
Too many requests. retry_after gives the seconds to wait (SMSTunnel's Retry-After).
ServerError (5xx)
A problem on SMSTunnel's side.
TimeoutError
No answer in time. The text may have gone out: retry only with the same idempotency key.
ConnectionError
SMSTunnel could not be reached (DNS, TLS, refused connection). TimeoutError extends it.

All API errors extend SMSTunnel::APIError, with status, code (SMSTunnel's own code, when it sends one), message, body and headers. A missing number or text raises ArgumentError before anything is sent.

begin
  SMSTunnel.send_sms(phone, "Your table is ready.", idempotency_key: "table-#{booking.id}")
rescue SMSTunnel::RateLimitError => e
  retry_later(wait: e.retry_after || 60)
rescue SMSTunnel::AuthenticationError, SMSTunnel::PermissionError => e
  Rails.logger.error("SMSTunnel key problem: #{e.message} (#{e.code})") # fix the key; don't retry
rescue SMSTunnel::Error => e
  Rails.logger.warn("The text did not go out: #{e.message}")
end

Testing your app

In your own tests, don't send real texts. With WebMock:

stub_request(:post, "https://smstunnel.io/api/v1/sms/send")
  .to_return(status: 200, headers: { "Content-Type" => "application/json" },
             body: { success: true, messageId: "test-1", data: { messageId: "test-1" } }.to_json)

Or check that the job was enqueued:

assert_enqueued_with(job: SMSTunnel::SendTextJob, args: ["+14155550123", "Your table is ready."])

Limits worth knowing

  • 30 send requests a minute per key; the keys of one account together get three times that. A 429 says how long to wait.
  • Plans: Free, Starter and Standard have a daily and a monthly allowance; Professional and Enterprise have no daily cap. See Pricing.
  • Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
  • Numbers: international format (+14155550123). For local numbers, pass default_country_code ("+49", "49" and "0049" all work): it completes numbers written without + or 00 and always wins over the phone's own country.
  • Offline phone: the text waits on your account (queued? is true) and goes out when the phone is back online, within 3 days.
  • The client never follows redirects (your key is never sent to another address), keeps no connection open between calls, and is safe to share between threads.

Build it with an AI assistant

Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.

Help me send text messages from my Ruby on Rails app with SMSTunnel.

About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.

What I want: [describe what should send a text, to whom, and when]

How it works:
- Use the smstunnel gem: bundle add smstunnel (Ruby 3.2+, standard library only). In Rails (7.2, 8.0 or 8.1): bin/rails generate smstunnel:install, then the key in the encrypted credentials (bin/rails credentials:edit → smstunnel: api_key:) or the SMSTUNNEL_API_KEY environment variable. Outside Rails: client = SMSTunnel::Client.new(api_key: ENV.fetch("SMSTUNNEL_API_KEY")), or the module shortcuts (SMSTunnel.send_sms) with SMSTUNNEL_API_KEY set.
- SMSTunnel.send_sms(to, message, device_id: nil, default_country_code: nil, idempotency_key: nil) returns a SendResult: message_id, queued? (true: the phone is offline and the text goes out when it is back), branded?, remaining and replayed?. One call sends one text to one number: loop for several. to is a String in international format (an Integer raises ArgumentError).
- Sign-in codes: SMSTunnel.send_sign_in_code("+14155550123", "Your sign-in code is 482913"); the text must contain the code as 4 to 10 digits standing on their own (or 123-456) and be at most 320 characters. Codes have their own daily allowance.
- In the background: SMSTunnel::SendTextJob.perform_later(to, message) (also sign_in_code: true, device_id:, default_country_code:) sends one text; every run of a job sends the same idempotency key (activejob-<job_id>), and 5xx, timeouts and 429 are retried up to 5 runs. In my own job: include SMSTunnel::JobRetries and pass idempotency_key: "order-#{order.id}-shipped". With Noticed (2.9 or 3): deliver_by :smstunnel, with config.phone and config.message.
- client.message_status(message_id): state ("pending", "sent", "delivered", "failed" or "unconfirmed"), final?, sent_at, error_message. "unconfirmed" means the phone handed it to the SIM without a confirmation: it most likely went out, so never resend it automatically.
- Errors (all SMSTunnel::Error): AuthenticationError 401 (bad key), PermissionError 403 (code such as PERMISSION_REQUIRED, permission), BadRequestError 400 and 422 (a number it can't read, a code text without a code, a reused idempotency key), NotFoundError 404, ConflictError 409 (the same idempotency key still running), RateLimitError 429 (retry_after seconds from Retry-After), ServerError 5xx, TimeoutError and ConnectionError when there is no answer. API errors have status, code, message, body and headers.
- The client never retries by itself. Retry a send only with the same idempotency_key: a repeat within 24 hours gets the first answer back (replayed? is true) instead of a second text.
- Numbers in international format. default_country_code (e.g. "+40") completes numbers written without + or 00 and always wins over the phone's own country. Texts up to 1600 characters; 30 send requests a minute per key.
- Send from a background job after the record is saved (after_commit), so a slow network never holds up a request.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.

Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts.

Responsible use

Built for alerts to yourself and your team, and texts people expect from you. One text goes to one number. Sending unsolicited messages from a consumer SIM generally breaches mobile carrier terms of service.

Rails and Ruby on Rails are trademarks of David Heinemeier Hansson. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by the Rails Foundation, the Ruby on Rails project or the Noticed project.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play