smsru_ruby
A modern, dependency-free, fully typed Ruby client for the SMS.ru HTTP API — typed results, typed errors, shipped RBS signatures, and first-class webhooks.
It is a clean, idiomatic Ruby port of the official SMS.ru PHP library: send single or bulk SMS, schedule delivery, check cost and delivery status, verify users by phone call, inspect your balance/limits/senders, manage the stoplist, and register delivery callbacks — all returning typed, immutable result objects and raising typed errors.
Why smsru_ruby?
- Zero runtime dependencies — only Ruby's standard library (
net/http,json,openssl). - Fully typed — immutable
Dataresult objects, not raw hashes, plus a typed error hierarchy:rescue SmsRu::Errorcatches everything. - RBS signatures shipped (
sig/) and Steep-checked — type-check your integration out of the box. - First-class webhooks — parse signed delivery and call-authorization callbacks into typed events; the signature is verified in constant time (timing-attack safe).
- Secret-safe by default — TLS verified; the optional logger never logs your
api_id, phone numbers, or message text. Configurable timeout and transport retries. - Outcome vs. delivery state — two distinct ideas, each with its own predicates (
ok?vs.delivered?/pending?/failed?), never conflated. - 100% test & documentation coverage, enforced in CI across Ruby 3.2–4.0.
What's covered
The full SMS.ru API, mapped to an idiomatic Ruby surface:
| Capability | Method |
|---|---|
| Send — 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 & verify incoming webhooks | SmsRu::Webhook |
Table of contents
- Why smsru_ruby?
- What's covered
- Supported Ruby versions
- Installation
- Quick start
- Configuration
- Sending messages
- Cost and status
- Verify by phone call
- Account information
- Stoplist
- Callbacks (webhooks)
- Error handling
- Development
- Recording test cassettes
- License
Supported Ruby versions
Ruby 3.2+ (the result objects use Data).
CI runs against ruby-head, 4.0, 3.4, 3.3, and 3.2.
Installation
# Gemfile
gem "smsru_ruby"
bundle install
# or
gem install smsru_ruby
require "smsru_ruby"
Quick start
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.
Configuration
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 — do not pass a separate text).
# 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–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 (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 — 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–102, still in transit)
status.failed? # => false (codes 104–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 vs. delivery state — two ideas, 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 — 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":
# Reject forged callbacks: SMS.ru signs every payload with your api_id.
# The check is constant-time (timing-attack safe).
unless SmsRu::Webhook.valid?(params["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/Rails, an Array in PHP). #parse handles either shape and
# returns a typed event per record.
SmsRu::Webhook.parse(params["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
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 can't 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).
License
Released under the MIT License.