{"id":51596913,"url":"https://github.com/svyatov/smsru_ruby","last_synced_at":"2026-07-11T19:30:40.748Z","repository":{"id":367597801,"uuid":"1281478009","full_name":"svyatov/smsru_ruby","owner":"svyatov","description":"A modern, dependency-free, fully typed Ruby client for the SMS.ru HTTP API","archived":false,"fork":false,"pushed_at":"2026-06-26T16:34:33.000Z","size":104,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-26T18:12:45.155Z","etag":null,"topics":["api-client","rbs","ruby","ruby-gem","sms","sms-ru-api","smsru","typed","webhooks"],"latest_commit_sha":null,"homepage":"https://rubydoc.info/gems/smsru_ruby","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/svyatov.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE.txt","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-26T15:40:40.000Z","updated_at":"2026-06-26T16:34:38.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/svyatov/smsru_ruby","commit_stats":null,"previous_names":["svyatov/smsru_ruby"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/svyatov/smsru_ruby","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svyatov%2Fsmsru_ruby","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svyatov%2Fsmsru_ruby/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svyatov%2Fsmsru_ruby/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svyatov%2Fsmsru_ruby/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/svyatov","download_url":"https://codeload.github.com/svyatov/smsru_ruby/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/svyatov%2Fsmsru_ruby/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35374165,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-11T02:00:05.354Z","response_time":104,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api-client","rbs","ruby","ruby-gem","sms","sms-ru-api","smsru","typed","webhooks"],"created_at":"2026-07-11T19:30:40.162Z","updated_at":"2026-07-11T19:30:40.740Z","avatar_url":"https://github.com/svyatov.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# smsru_ruby\n\n[![Gem Version](https://badge.fury.io/rb/smsru_ruby.svg)](https://rubygems.org/gems/smsru_ruby)\n[![CI](https://github.com/svyatov/smsru_ruby/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/smsru_ruby/actions/workflows/main.yml)\n[![codecov](https://codecov.io/gh/svyatov/smsru_ruby/branch/main/graph/badge.svg)](https://codecov.io/gh/svyatov/smsru_ruby)\n[![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/smsru_ruby)\n[![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org)\n[![Types: RBS](https://img.shields.io/badge/types-RBS-8A2BE2.svg)](https://github.com/svyatov/smsru_ruby/tree/main/sig)\n\nA modern, **dependency-free**, **fully typed** Ruby client for the [SMS.ru](https://sms.ru) HTTP API —\ntyped results, typed errors, shipped RBS signatures, and first-class webhooks.\n\nIt is a clean, idiomatic Ruby port of the official [SMS.ru PHP library](https://sms.ru/php):\nsend single or bulk SMS, schedule delivery, check cost and delivery status, verify\nusers by phone call, inspect your balance/limits/senders, manage the stoplist, and\nregister delivery callbacks — all returning typed, immutable result objects and\nraising typed errors.\n\n## Why smsru_ruby?\n\n- **Zero runtime dependencies** — only Ruby's standard library (`net/http`, `json`, `openssl`).\n- **Fully typed** — immutable `Data` result objects, not raw hashes, plus a typed error hierarchy: `rescue SmsRu::Error` catches everything.\n- **RBS signatures shipped** (`sig/`) and Steep-checked — type-check your integration out of the box.\n- **First-class webhooks** — parse signed delivery and call-authorization callbacks into typed events; the signature is verified in **constant time** (timing-attack safe).\n- **Secret-safe by default** — TLS verified; the optional logger never logs your `api_id`, phone numbers, or message text. Configurable timeout and transport retries.\n- **Outcome vs. delivery state** — two distinct ideas, each with its own predicates (`ok?` vs. `delivered?`/`pending?`/`failed?`), never conflated.\n- **100% test \u0026 documentation coverage, enforced in CI** across Ruby 3.2–4.0.\n\n## What's covered\n\nThe full SMS.ru API, mapped to an idiomatic Ruby surface:\n\n| Capability | Method |\n| --- | --- |\n| Send — single, bulk, or per-number text | `client.deliver` |\n| Price a message before sending | `client.cost` |\n| Delivery status, with state predicates | `client.status` |\n| Verify by flash call (outbound) | `client.call` |\n| Verify by callcheck (inbound) | `client.callcheck` |\n| Balance, limits, free limit, senders | `client.my` |\n| Validate credentials | `client.auth.ok?` |\n| Stoplist — add, remove, list | `client.stoplist` |\n| Webhook URLs — add, remove, list | `client.callbacks` |\n| Parse \u0026 verify incoming webhooks | `SmsRu::Webhook` |\n\n## Table of contents\n\n- [Why smsru_ruby?](#why-smsru_ruby)\n- [What's covered](#whats-covered)\n- [Supported Ruby versions](#supported-ruby-versions)\n- [Installation](#installation)\n- [Quick start](#quick-start)\n- [Configuration](#configuration)\n- [Sending messages](#sending-messages)\n- [Cost and status](#cost-and-status)\n- [Verify by phone call](#verify-by-phone-call)\n- [Account information](#account-information)\n- [Stoplist](#stoplist)\n- [Callbacks (webhooks)](#callbacks-webhooks)\n- [Error handling](#error-handling)\n- [Development](#development)\n- [Recording test cassettes](#recording-test-cassettes)\n- [License](#license)\n\n## Supported Ruby versions\n\nRuby **3.2+** (the result objects use [`Data`](https://docs.ruby-lang.org/en/3.2/Data.html)).\nCI runs against `ruby-head`, `4.0`, `3.4`, `3.3`, and `3.2`.\n\n## Installation\n\n```ruby\n# Gemfile\ngem \"smsru_ruby\"\n```\n\n```sh\nbundle install\n# or\ngem install smsru_ruby\n```\n\n```ruby\nrequire \"smsru_ruby\"\n```\n\n## Quick start\n\n```ruby\nclient = SmsRu.new(\"YOUR_API_ID\")\n\nresult = client.deliver(\"79991234567\", \"Hello from Ruby!\")\nresult.messages.first.sms_id   # =\u003e \"000000-10000000\"\nclient.my.balance              # =\u003e 4762.58\n```\n\nGet your `api_id` in the SMS.ru dashboard under\n[Settings → API](https://sms.ru/?panel=api).\n\n## Configuration\n\n```ruby\nSmsRu.new(\n  \"YOUR_API_ID\",\n  timeout: 30,        # open/read timeout in seconds (default: 30)\n  test: false,        # when true, every `deliver` defaults to test mode (no charge)\n  retries: 5,         # retries on transport failure; 0 disables (default: 5, matching the PHP lib)\n  from: \"MyCompany\",  # default sender name for `deliver` (override per call)\n  logger: Logger.new($stdout) # optional; logs the request path + transport failures\n)\n```\n\nRetries apply only to transport-level problems (timeouts, refused connections).\nAPI errors are never retried — they are raised immediately.\n\n`from` is a per-client default so you don't repeat your sender name on every call;\na per-call `from:` always wins. The `logger` logs only the request path and\ntransport failures — never your `api_id`, phone numbers, or message text.\n\n## Sending messages\n\n`#deliver` accepts the recipient(s) in three shapes:\n\n```ruby\n# 1. One number\nclient.deliver(\"79991234567\", \"Hi there\")\n\n# 2. Same text to many numbers (Array)\nclient.deliver([\"79991234567\", \"79991234568\"], \"Hi everyone\")\n\n# 3. A different text per number (Hash — do not pass a separate text).\n#    Use braces so Ruby treats it as a positional Hash, not keyword arguments.\nclient.deliver({\n  \"79991234567\" =\u003e \"Hi Alice\",\n  \"79991234568\" =\u003e \"Hi Bob\"\n})\n```\n\nOptional keyword arguments (all optional):\n\n```ruby\nclient.deliver(\n  \"79991234567\", \"Hi\",\n  from: \"MyCompany\",     # approved sender name\n  time: Time.now.to_i + 3600, # scheduled send (UNIX time, up to 2 months ahead)\n  ttl: 60,               # message lifetime in minutes (1–1440)\n  daytime: true,         # defer night-time sends to the recipient's daytime\n  translit: true,        # transliterate Cyrillic to Latin\n  test: true,            # test mode for this call (overrides the client default)\n  ip: \"192.0.2.1\",       # end-user IP (for auth-code anti-fraud)\n  partner_id: 12345      # partner program id\n)\n```\n\nThe result is a `SmsRu::SendResult`. Individual recipients can fail even when the\noverall request succeeds, so inspect each message:\n\n```ruby\nresult = client.deliver([\"79991234567\", \"74993221627\"], \"Hi\")\nresult.balance                 # =\u003e 4122.56\nresult.messages.each do |sms|\n  if sms.ok?\n    puts \"#{sms.phone}: sent as #{sms.sms_id}\"\n  else\n    puts \"#{sms.phone}: rejected (#{sms.error_code}) #{sms.error_text}\"\n  end\nend\n\n# Or use the collection helpers:\nresult.ok?                     # =\u003e true only if every recipient was accepted\nresult.ok                      # =\u003e [SmsRu::Sms, ...] accepted recipients\nresult.failed                  # =\u003e [SmsRu::Sms, ...] rejected recipients\n```\n\n## Cost and status\n\n```ruby\n# Price a message before sending (text is optional; omit it for the price of 1 SMS)\ncost = client.cost(\"79991234567\", \"How much?\")\ncost.total_cost  # =\u003e 1.74\ncost.total_sms   # =\u003e 2\n\n# Same collection helpers as a send result:\ncost.ok?                       # =\u003e true only if every recipient was priced\ncost.failed                    # =\u003e [SmsRu::CostItem, ...] recipients that errored\ncost.failed.first.error_code   # =\u003e 207\n\n# Delivery status — one id or an Array of ids\nstatus = client.status(\"000000-10000000\")\nstatus.status_code  # =\u003e 103   (the delivery state code)\nstatus.status_text  # =\u003e \"Сообщение доставлено\"\n\n# State predicates instead of memorizing codes:\nstatus.delivered?   # =\u003e true  (code 103)\nstatus.pending?     # =\u003e false (codes 100–102, still in transit)\nstatus.failed?      # =\u003e false (codes 104–108, 150)\nstatus.found?       # =\u003e true  (false only when the id is unknown, code -1)\n\nstatuses = client.status([\"000000-10000000\", \"000000-10000001\"]) # =\u003e [SmsRu::Status, ...]\n```\n\nEvery code has a named constant under `SmsRu::Statuses` (e.g.\n`SmsRu::Statuses::DELIVERED == 103`, `::EXPIRED`, `::READ`) for the cases the\npredicates don't cover. The same predicates are available on\n`SmsRu::Events::SmsStatus` from webhook payloads.\n\n\u003e **Outcome vs. delivery state — two ideas, two names.** `ok?` (with\n\u003e `error_code`/`error_text` on a rejected `Sms`/`CostItem`) answers *did the\n\u003e request succeed for this recipient*. `status_code` (with\n\u003e `delivered?`/`pending?`/`failed?`) answers *where the message is in delivery* —\n\u003e and only `Status` and webhook events carry it.\n\n## Verify by phone call\n\nTwo ways to verify a user by phone call — no SMS required.\n\n**Outbound (flash call).** SMS.ru calls the user; the last 4 digits of the\ncalling number are the code. You receive the expected `code` to compare against\nwhat the user enters:\n\n```ruby\ncall = client.call(\"79991234567\")\ncall.code     # =\u003e \"1435\" — the last 4 digits the user will see\ncall.call_id  # =\u003e \"000000-10000000\"\n```\n\n**Inbound (callcheck).** The user calls a number you show them; SMS.ru drops the\ncall (free for the caller) and marks the check confirmed:\n\n```ruby\ncheck = client.callcheck.add(\"79991234567\")\ncheck.call_phone_pretty  # =\u003e \"+7 (800) 500-8275\" — show this to the user\n\n# Poll until the user has called (or receive it via a callback/webhook):\nclient.callcheck.status(check.check_id).confirmed?  # =\u003e true\n```\n\n## Account information\n\nAccount reads are grouped under `client.my`:\n\n```ruby\nclient.my.balance          # =\u003e 4762.58 (a Float)\n\nlimit = client.my.limit\nlimit.total_limit          # =\u003e 100\nlimit.used_today           # =\u003e 7\nlimit.available_today      # =\u003e 93\n\nfree = client.my.free_limit\nfree.total_free            # =\u003e 5\nfree.used_today            # =\u003e 3\nfree.available_today       # =\u003e 2\n\nclient.my.senders          # =\u003e [\"MyCompany\", \"AnotherName\"]\n```\n\nCheck that the configured `api_id` is valid:\n\n```ruby\nclient.auth.ok?            # =\u003e true\n```\n\n## Stoplist\n\nNumbers on the stoplist never receive messages and are never charged.\n\n```ruby\nclient.stoplist.add(\"79991234567\", note: \"spam complaint\") # =\u003e true\nclient.stoplist.list   # =\u003e [#\u003cdata SmsRu::StoplistEntry phone=\"79991234567\", note=\"spam complaint\"\u003e]\nclient.stoplist.remove(\"79991234567\") # =\u003e true\n```\n\n## Callbacks (webhooks)\n\nRegister URLs that SMS.ru will POST delivery and call-authorization statuses to.\nEach method returns the full list of registered URLs:\n\n```ruby\nclient.callbacks.add(\"https://example.com/sms/callback\") # =\u003e [\"https://example.com/sms/callback\"]\nclient.callbacks.list   # =\u003e [...]\nclient.callbacks.remove(\"https://example.com/sms/callback\") # =\u003e [...]\n```\n\nIn your webhook handler, verify the signature, parse the payload, and\nacknowledge it by replying with the string `\"100\"`:\n\n```ruby\n# In Rails, params[:data] is ActionController::Parameters, not a Hash — convert\n# it with .to_unsafe_h first, or the numeric-key ordering the signature depends\n# on is skipped and the check below rejects the payload. The payload is\n# signature-verified, so to_unsafe_h is safe here (.to_h would drop keys).\n# In bare Rack params[\"data\"] is already a Hash; pass it as-is.\ndata = params[:data].to_unsafe_h\n\n# Reject forged callbacks: SMS.ru signs every payload with your api_id.\n# The check is constant-time (timing-attack safe).\nunless SmsRu::Webhook.valid?(data, params[:hash], \"YOUR_API_ID\")\n  return head(:forbidden)\nend\n\n# SMS.ru sends up to 100 records as POST fields data[0]..data[N]\n# (a Hash in Rack, an Array in PHP). #parse handles either shape and\n# returns a typed event per record.\nSmsRu::Webhook.parse(data).each do |event|\n  case event\n  when SmsRu::Events::SmsStatus        # delivery report\n    # event.id, event.status_code, event.created_at; event.delivered? =\u003e 103\n    update_delivery_status(event.id, event.status_code)\n  when SmsRu::Events::CallcheckStatus  # call-authorization result\n    confirm_authorization(event.id) if event.confirmed? # or event.expired?\n  # SmsRu::Events::Test (heartbeat) and ::Unknown (future types) fall through\n  end\nend\n\n# Respond with exactly \"100\", or SMS.ru retries every 60s for up to 5 days.\n```\n\n## Error handling\n\nEvery error inherits from `SmsRu::Error`:\n\n```ruby\nSmsRu::Error                  # base class\n├─ SmsRu::ConnectionError     # network/timeout/invalid response (after retries)\n└─ SmsRu::ResponseError       # API returned a non-OK status; has #code and #text\n   ├─ SmsRu::AuthError        # invalid api_id/token/account (codes 200, 300, 301, 302)\n   └─ SmsRu::InsufficientFundsError # not enough money (code 201)\n```\n\n```ruby\nbegin\n  client.deliver(\"79991234567\", \"Hi\")\nrescue SmsRu::AuthError =\u003e e\n  warn \"Check your api_id: #{e.text}\"\nrescue SmsRu::InsufficientFundsError\n  warn \"Top up your balance\"\nrescue SmsRu::ResponseError =\u003e e\n  warn \"SMS.ru error #{e.code}: #{e.text}\"\nrescue SmsRu::ConnectionError =\u003e e\n  warn \"Could not reach SMS.ru: #{e.message}\"\nend\n```\n\nNote that per-recipient failures in a bulk `deliver` are **not** raised — they are\nreported on each `SmsRu::Sms` in `result.messages` (see above).\n\n## Development\n\n```sh\nbin/setup            # install dependencies\nbundle exec rake     # run RuboCop, validate RBS signatures, and the test suite\nbundle exec rake steep        # type-check lib/ against sig/ (Steep, strict diagnostics)\nbundle exec rake steep:stats  # report type coverage (typed % per file)\nbundle exec rake rbs:test     # run the suite verifying real values against the signatures\nbin/console          # an IRB session with the gem loaded\n```\n\nThe signatures are held to their own standard: Steep runs under its **strict**\ndiagnostics (no implicit `untyped`, no unannotated collections) at **100% type\ncoverage**, gated in CI. Loosely-typed JSON from SMS.ru (which returns, say,\n`total_limit` as the string `\"10\"`) is normalized into the declared types at the\nparse boundary, and `rbs:test` checks that the values flowing through the suite\nactually match `sig/` at runtime — so the types can't drift from the code.\n\n## Recording test cassettes\n\nEnd-to-end tests replay real SMS.ru responses recorded with [VCR](https://github.com/vcr/vcr).\nThe cassettes are not committed with secrets — your `api_id` is filtered out. To\nrecord them once against your own account (message sends use `test=1`, so they are\nfree):\n\n```sh\nSMSRU_API_ID=your_real_api_id bundle exec rake vcr:record\n```\n\nThis writes `test/cassettes/*.yml`. Commit them, then `COVERAGE=true bundle exec rake`\nruns fully offline at 100% coverage. Before cassettes are recorded, the end-to-end\ntests are skipped (the unit and transport tests still run).\n\n## License\n\nReleased under the [MIT License](LICENSE.txt).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvyatov%2Fsmsru_ruby","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsvyatov%2Fsmsru_ruby","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsvyatov%2Fsmsru_ruby/lists"}