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.
- 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
Dataobject rather than a decoded Hash. - No runtime dependencies. The gem loads
net/http,json, andopensslfrom 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..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..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?(witherror_code/error_texton a rejectedSms/CostItem) answers did the request succeed for this recipient.status_code(withdelivered?/pending?/failed?) answers where the message is in delivery, and onlyStatusand 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
(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.}"
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.
Links
- CHANGELOG.md records every released change.
- CONTRIBUTING.md covers setup, tests, and the pull request process.
- SECURITY.md explains how to report a vulnerability privately.
- API documentation is generated from the source with YARD.
- LICENSE is the MIT License.