smsru-ruby

smsru-ruby is a Ruby client for the SMS.ru HTTP API, for applications that send SMS, check delivery, and verify users by phone call.

Gem version CI Coverage

  • Covers the whole SMS.ru HTTP API. Sending, cost, delivery status, flash call and callcheck verification, balance and limits, stoplist, callback registration, and inbound webhook parsing.
  • 11 typed result objects and 12 named status constants. Every response arrives as a frozen Data object rather than a decoded Hash.
  • No runtime dependencies. The gem loads net/http, json, and openssl from the standard library and nothing else.
  • A Ruby port of the official SMS.ru PHP library. Same API coverage, with keyword arguments, namespaced sub-resources, and raised errors in place of flat get_* methods and returned status codes.
  • 100% line coverage and 100% documented public API. Both gates run in CI on every push.

Installation

Add the gem to your Gemfile.

gem "smsru-ruby"

Then run bundle install. Without Bundler, run gem install smsru-ruby.

Quick start

Create a client with your API id, then send a message.

require "smsru-ruby"

client = SmsRu.new("YOUR_API_ID")
result = client.deliver("79991234567", "Hello from Ruby!")

result.messages.first.sms_id  # => "000000-10000000"
client.my.balance             # => 4762.58

Get your api_id in the SMS.ru dashboard under Settings, API.

API coverage

The full SMS.ru API, mapped to an idiomatic Ruby surface:

Capability Method
Send a single, bulk, or per-number text client.deliver
Price a message before sending client.cost
Delivery status, with state predicates client.status
Verify by flash call (outbound) client.call
Verify by callcheck (inbound) client.callcheck
Balance, limits, free limit, senders client.my
Validate credentials client.auth.ok?
Stoplist: add, remove, list client.stoplist
Webhook URLs: add, remove, list client.callbacks
Parse and verify incoming webhooks SmsRu::Webhook

Configuration

Every client option is a keyword argument on SmsRu.new:

SmsRu.new(
  "YOUR_API_ID",
  timeout: 30,        # open/read timeout in seconds (default: 30)
  test: false,        # when true, every `deliver` defaults to test mode (no charge)
  retries: 5,         # retries on transport failure; 0 disables (default: 5, matching the PHP lib)
  from: "MyCompany",  # default sender name for `deliver` (override per call)
  logger: Logger.new($stdout) # optional; logs the request path + transport failures
)

Retries apply only to transport-level problems (timeouts, refused connections). API errors are never retried. They are raised immediately.

from is a per-client default so you don't repeat your sender name on every call; a per-call from: always wins. The logger logs only the request path and transport failures, never your api_id, phone numbers, or message text.

Sending messages

#deliver accepts the recipient(s) in three shapes:

# 1. One number
client.deliver("79991234567", "Hi there")

# 2. Same text to many numbers (Array)
client.deliver(["79991234567", "79991234568"], "Hi everyone")

# 3. A different text per number (Hash, with no separate text argument).
#    Use braces so Ruby treats it as a positional Hash, not keyword arguments.
client.deliver({
  "79991234567" => "Hi Alice",
  "79991234568" => "Hi Bob"
})

Optional keyword arguments (all optional):

client.deliver(
  "79991234567", "Hi",
  from: "MyCompany",     # approved sender name
  time: Time.now.to_i + 3600, # scheduled send (UNIX time, up to 2 months ahead)
  ttl: 60,               # message lifetime in minutes (1 to 1440)
  daytime: true,         # defer night-time sends to the recipient's daytime
  translit: true,        # transliterate Cyrillic to Latin
  test: true,            # test mode for this call (overrides the client default)
  ip: "192.0.2.1",       # end-user IP (for auth-code anti-fraud)
  partner_id: 12345      # partner program id
)

The result is a SmsRu::SendResult. Individual recipients can fail even when the overall request succeeds, so inspect each message:

result = client.deliver(["79991234567", "74993221627"], "Hi")
result.balance                 # => 4122.56
result.messages.each do |sms|
  if sms.ok?
    puts "#{sms.phone}: sent as #{sms.sms_id}"
  else
    puts "#{sms.phone}: rejected (#{sms.error_code}) #{sms.error_text}"
  end
end

# Or use the collection helpers:
result.ok?                     # => true only if every recipient was accepted
result.ok                      # => [SmsRu::Sms, ...] accepted recipients
result.failed                  # => [SmsRu::Sms, ...] rejected recipients

Cost and status

Price a message before sending, and read the delivery state afterwards:

# Price a message before sending (text is optional; omit it for the price of 1 SMS)
cost = client.cost("79991234567", "How much?")
cost.total_cost  # => 1.74
cost.total_sms   # => 2

# Same collection helpers as a send result:
cost.ok?                       # => true only if every recipient was priced
cost.failed                    # => [SmsRu::CostItem, ...] recipients that errored
cost.failed.first.error_code   # => 207

# Delivery status takes one id or an Array of ids
status = client.status("000000-10000000")
status.status_code  # => 103   (the delivery state code)
status.status_text  # => "Сообщение доставлено"

# State predicates instead of memorizing codes:
status.delivered?   # => true  (code 103)
status.pending?     # => false (codes 100 to 102, still in transit)
status.failed?      # => false (codes 104 to 108, 150)
status.found?       # => true  (false only when the id is unknown, code -1)

statuses = client.status(["000000-10000000", "000000-10000001"]) # => [SmsRu::Status, ...]

Every code has a named constant under SmsRu::Statuses (e.g. SmsRu::Statuses::DELIVERED == 103, ::EXPIRED, ::READ) for the cases the predicates don't cover. The same predicates are available on SmsRu::Events::SmsStatus from webhook payloads.

Outcome and delivery state are two ideas with two names. ok? (with error_code/error_text on a rejected Sms/CostItem) answers did the request succeed for this recipient. status_code (with delivered?/pending?/failed?) answers where the message is in delivery, and only Status and webhook events carry it.

Verify by phone call

Two ways to verify a user by phone call, with no SMS required.

Outbound (flash call). SMS.ru calls the user; the last 4 digits of the calling number are the code. You receive the expected code to compare against what the user enters:

call = client.call("79991234567")
call.code     # => "1435", the last 4 digits the user will see
call.call_id  # => "000000-10000000"

Inbound (callcheck). The user calls a number you show them; SMS.ru drops the call (free for the caller) and marks the check confirmed:

check = client.callcheck.add("79991234567")
check.call_phone_pretty  # => "+7 (800) 500-8275", show this to the user

# Poll until the user has called (or receive it via a callback/webhook):
client.callcheck.status(check.check_id).confirmed?  # => true

Account information

Account reads are grouped under client.my:

client.my.balance          # => 4762.58 (a Float)

limit = client.my.limit
limit.total_limit          # => 100
limit.used_today           # => 7
limit.available_today      # => 93

free = client.my.free_limit
free.total_free            # => 5
free.used_today            # => 3
free.available_today       # => 2

client.my.senders          # => ["MyCompany", "AnotherName"]

Check that the configured api_id is valid:

client.auth.ok?            # => true

Stoplist

Numbers on the stoplist never receive messages and are never charged.

client.stoplist.add("79991234567", note: "spam complaint") # => true
client.stoplist.list   # => [#<data SmsRu::StoplistEntry phone="79991234567", note="spam complaint">]
client.stoplist.remove("79991234567") # => true

Callbacks (webhooks)

Register URLs that SMS.ru will POST delivery and call-authorization statuses to. Each method returns the full list of registered URLs:

client.callbacks.add("https://example.com/sms/callback") # => ["https://example.com/sms/callback"]
client.callbacks.list   # => [...]
client.callbacks.remove("https://example.com/sms/callback") # => [...]

In your webhook handler, verify the signature, parse the payload, and acknowledge it by replying with the string "100":

# In Rails, params[:data] is ActionController::Parameters rather than a Hash.
# Convert it with .to_unsafe_h first, or the numeric-key ordering the signature
# depends on is skipped and the check below rejects the payload. The payload is
# signature-verified, so to_unsafe_h is safe here (.to_h would drop keys).
# In bare Rack params["data"] is already a Hash; pass it as-is.
data = params[:data].to_unsafe_h

# Reject forged callbacks: SMS.ru signs every payload with your api_id.
# The check is constant-time (timing-attack safe).
unless SmsRu::Webhook.valid?(data, params[:hash], "YOUR_API_ID")
  return head(:forbidden)
end

# SMS.ru sends up to 100 records as POST fields data[0]..data[N]
# (a Hash in Rack, an Array in PHP). #parse handles either shape and
# returns a typed event per record.
SmsRu::Webhook.parse(data).each do |event|
  case event
  when SmsRu::Events::SmsStatus        # delivery report
    # event.id, event.status_code, event.created_at; event.delivered? => 103
    update_delivery_status(event.id, event.status_code)
  when SmsRu::Events::CallcheckStatus  # call-authorization result
    confirm_authorization(event.id) if event.confirmed? # or event.expired?
  # SmsRu::Events::Test (heartbeat) and ::Unknown (future types) fall through
  end
end

# Respond with exactly "100", or SMS.ru retries every 60s for up to 5 days.

Error handling

Every error inherits from SmsRu::Error:

SmsRu::Error                  # base class
├─ SmsRu::ConnectionError     # network/timeout/invalid response (after retries)
└─ SmsRu::ResponseError       # API returned a non-OK status; has #code and #text
   ├─ SmsRu::AuthError        # invalid api_id/token/account (codes 200, 300, 301, 302)
   └─ SmsRu::InsufficientFundsError # not enough money (code 201)
begin
  client.deliver("79991234567", "Hi")
rescue SmsRu::AuthError => e
  warn "Check your api_id: #{e.text}"
rescue SmsRu::InsufficientFundsError
  warn "Top up your balance"
rescue SmsRu::ResponseError => e
  warn "SMS.ru error #{e.code}: #{e.text}"
rescue SmsRu::ConnectionError => e
  warn "Could not reach SMS.ru: #{e.message}"
end

Note that per-recipient failures in a bulk deliver are not raised. They are reported on each SmsRu::Sms in result.messages (see above).

Development

Ruby 3.2 or newer is required, because the result objects use Data. CI runs against ruby-head, 4.0, 3.4, 3.3, and 3.2.

bin/setup            # install dependencies
bundle exec rake     # run RuboCop, validate RBS signatures, and the test suite
bundle exec rake steep        # type-check lib/ against sig/ (Steep, strict diagnostics)
bundle exec rake steep:stats  # report type coverage (typed % per file)
bundle exec rake rbs:test     # run the suite verifying real values against the signatures
bin/console          # an IRB session with the gem loaded

The signatures are held to their own standard: Steep runs under its strict diagnostics (no implicit untyped, no unannotated collections) at 100% type coverage, gated in CI. Loosely-typed JSON from SMS.ru (which returns, say, total_limit as the string "10") is normalized into the declared types at the parse boundary, and rbs:test checks that the values flowing through the suite actually match sig/ at runtime, so the types cannot drift from the code.

Recording test cassettes

End-to-end tests replay real SMS.ru responses recorded with VCR. The cassettes are not committed with secrets: your api_id is filtered out. To record them once against your own account (message sends use test=1, so they are free):

SMSRU_API_ID=your_real_api_id bundle exec rake vcr:record

This writes test/cassettes/*.yml. Commit them, then COVERAGE=true bundle exec rake runs fully offline at 100% coverage. Before cassettes are recorded, the end-to-end tests are skipped (the unit and transport tests still run).

Help and project status

Ask a question or report a defect in the issue tracker. Both go to the same place. For a security vulnerability, follow SECURITY.md instead of opening an issue.

Leonid Svyatov maintains this gem alone. He reads every issue, fixes defects in the SMS.ru API coverage, and keeps the gem running on supported Ruby versions. Feature work depends on the time he has. CONTRIBUTING.md records who merges and releases.