SMS API · Ruby 3.2+ · Rails

SMS API w Ruby i Rails: Net::HTTP, ActiveJob, webhooki

Wysyłka SMS z Ruby to jedno żądanie POST /v1/messages z biblioteki standardowej. Poniżej kompletny klient Przypominamy::Client na Net::HTTP i JSON, hierarchia wyjątków po error.code, ponawianie 429/5xx z tym samym Idempotency-Key, wysyłka w tle przez ActiveJob oraz kontroler webhooka z OpenSSL::HMAC i secure_compare. Bez gemów.

Bez gemówRuby 3.2+ · Rails 7.1+Klucz pk_test_… od razu po rejestracji

Instalacja i wymagania

Nie ma czego instalować. Cały kod z tej strony korzysta z biblioteki standardowej Ruby; część dla Rails potrzebuje tylko tego, co Rails już ma.

  • Ruby 3.2 lub nowszynet/http, json i openssl są w stdlib. Kod używa argumentów nazwanych, Hash#compact i operatora bezpiecznej nawigacji &..
  • Klucz API pk_live_… lub testowy pk_test_… z panelu app.przypominamy.com. Konto testowe ma od razu 25 SMS-ów gratis na 2 własne numery.
  • Zmienna środowiskowa PRZYPOMINAMY_API_KEY albo Rails.application.credentials.przypominamy[:api_key] — klucz nigdy nie trafia do repozytorium.
Konfiguracjaterminal
export PRZYPOMINAMY_API_KEY=pk_test_...
# Rails: bin/rails credentials:edit
#   przypominamy:
#     api_key: pk_live_...
#     webhook_secret: whsec_...

API to zwykły REST z JSON-em: nagłówek Authorization: Bearer, odpowiedź 201 z obiektem wiadomości, błędy w polu error z kodem, komunikatem i nazwą parametru. Jeśli w projekcie masz już faraday albo httpx, możesz ich użyć zamiast Net::HTTP — logika po stronie API jest identyczna.

Pierwszy SMS: klasa klienta

Klient trzyma klucz i adres bazowy, otwiera połączenie Net::HTTP z timeoutami, serializuje żądanie i mapuje odpowiedź na hash z kluczami-symbolami. Błędy zamienia na wyjątki z kodem.

Klient SMSlib/przypominamy.rb
# frozen_string_literal: true

require "net/http"
require "json"
require "uri"

module Przypominamy
  # Koperta błędu API: { "error": { code, message, param }, "request_id" }
  class Error < StandardError
    attr_reader :status, :code, :param, :request_id, :retry_after

    def initialize(status:, code:, message:, param: nil, request_id: nil, retry_after: nil)
      @status = status
      @code = code
      @param = param
      @request_id = request_id
      @retry_after = retry_after
      super("przypominamy: #{code} (#{status}): #{message} [request_id=#{request_id}]")
    end
  end

  class RequestError < Error; end   # 400, 401, 402 — nie ponawiaj
  class RateLimited < Error; end    # 429 — odczekaj retry_after
  class ProviderError < Error; end  # 502 i inne 5xx — ponów z backoffem

  class Client
    BASE = "https://api.przypominamy.com/v1"

    def initialize(api_key, base: BASE)
      @api_key = api_key
      @base = base
    end

    # to: String lub Array (do 500 numerów). Zwraca hash wiadomości albo tablicę hashy.
    def send_sms(to:, text:, from: nil, send_at: nil, reference: nil, idempotency_key: nil)
      raise ArgumentError, "max 500 odbiorców" if to.is_a?(Array) && to.size > 500

      payload = { to: to, text: text, from: from, send_at: send_at, reference: reference }.compact
      request(:post, "/messages", body: payload, idempotency_key: idempotency_key)
    end

    def get(id)
      request(:get, "/messages/#{id}")
    end

    def list(**query)
      request(:get, "/messages", query: query)
    end

    def account
      request(:get, "/account")
    end

    def set_webhook(url)
      request(:put, "/account/webhook", body: { url: url })
    end

    private

    def request(method, path, body: nil, query: nil, idempotency_key: nil)
      uri = URI("#{@base}#{path}")
      uri.query = URI.encode_www_form(query) if query && !query.empty?

      headers = {
        "Authorization" => "Bearer #{@api_key}",
        "Accept" => "application/json",
        "Content-Type" => "application/json"
      }
      headers["Idempotency-Key"] = idempotency_key if idempotency_key

      req = case method
            when :post then Net::HTTP::Post.new(uri, headers)
            when :put  then Net::HTTP::Put.new(uri, headers)
            else            Net::HTTP::Get.new(uri, headers)
            end
      req.body = payload_json(body) if body

      res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
        http.request(req)
      end

      data = res.body.to_s.empty? ? {} : JSON.parse(res.body, symbolize_names: true)
      return data if res.is_a?(Net::HTTPSuccess)

      raise error_for(res, data)
    end

    def payload_json(body)
      JSON.generate(body)
    end

    def error_for(res, data)
      err = data[:error] || {}
      status = res.code.to_i
      attrs = {
        status: status,
        code: err[:code] || "http_#{status}",
        message: err[:message] || res.message,
        param: err[:param],
        request_id: data[:request_id],
        retry_after: res["Retry-After"]&.to_i
      }
      klass = if status == 429 then RateLimited
              elsif status >= 500 then ProviderError
              else RequestError
              end
      klass.new(**attrs)
    end
  end
end

I użycie: jeden SMS z przypomnieniem, identyfikator wizyty jako reference.

Wysyłka SMSsend.rb
require_relative "lib/przypominamy"

sms = Przypominamy::Client.new(ENV.fetch("PRZYPOMINAMY_API_KEY"))

msg = sms.send_sms(
  to: "+48600123456",
  text: "Przypominamy o wizycie jutro o 14:00.",
  reference: "wizyta-4521",
  idempotency_key: "wizyta-4521-przypomnienie"
)

puts [msg[:id], msg[:status], msg[:parts], msg[:cost_grosze]].join(" ") # msg_… queued 1 15

Odpowiedź 201 zawiera parts i cost_grosze: SMS bez polskich znaków to 160 znaków na część (153 przy sklejaniu, GSM-7), z polskimi znakami 70 (67) w UCS-2. Pole send_at w ISO 8601 (Time.now.iso8601) planuje wysyłkę, from ustawia nadpis z listy zatwierdzonych w panelu.

Wielu odbiorców

Ta sama treść do wielu numerów to jedno żądanie: pole to przyjmuje tablicę do 500 numerów. Liczy się jako jedno żądanie w limicie 120/min, a w odpowiedzi dostajesz tablicę obiektów wiadomości — po jednym na numer.

Wysyłka masowabatch.rb
numery = %w[+48600123456 +48600234567 +48600345678]

wyniki = sms.send_sms(
  to: numery,
  text: "Promocja -20% tylko do niedzieli. Kod: SMS20",
  from: "SKLEP",
  reference: "promo-2026-09",
  idempotency_key: "promo-2026-09-batch-1"
)

wyniki.each { |m| puts "#{m[:to]} -> #{m[:status]} #{m[:error]}" }
puts "Koszt: #{wyniki.sum { |m| m[:cost_grosze] } / 100.0} zł"

# Więcej niż 500 numerów: dziel na paczki, jedna paczka = jedno żądanie.
numery.each_slice(500).with_index(1) do |paczka, i|
  sms.send_sms(to: paczka, text: "…", idempotency_key: "promo-2026-09-batch-#{i}")
end

Spersonalizowane treści (imię, godzina) idą osobnymi żądaniami. W Rails najprościej zlecić każdą jako osobny job — kolejka sama ograniczy równoległość (w Sidekiq concurrency, w Solid Queue threads), a retry_on zajmie się 429 i 5xx. Nadawaj Idempotency-Key per wiadomość, żeby ponowienie joba nie zdublowało SMS-a.

ActiveJob (Rails 7.1+)app/jobs/send_sms_job.rb
class SendSmsJob < ApplicationJob
  queue_as :sms

  # Rails 7.1: :exponentially_longer zostało przemianowane na :polynomially_longer
  retry_on Przypominamy::RateLimited, Przypominamy::ProviderError,
           wait: :polynomially_longer, attempts: 5
  # 400/401/402 nie ma sensu ponawiać — job trafia do logu, nie do kolejki
  discard_on Przypominamy::RequestError do |job, error|
    Rails.logger.error("SMS #{job.arguments.first} odrzucony: #{error.code} #{error.request_id}")
  end

  def perform(wizyta_id)
    wizyta = Wizyta.find(wizyta_id)
    client = Przypominamy::Client.new(Rails.application.credentials.przypominamy[:api_key])

    msg = client.send_sms(
      to: wizyta.telefon,
      text: "Cześć #{wizyta.imie}, wizyta jutro o #{wizyta.godzina.strftime('%H:%M')}. Potwierdź odpisując TAK.",
      reference: "wizyta-#{wizyta.id}",
      idempotency_key: "wizyta-#{wizyta.id}-przypomnienie"
    )
    wizyta.update!(sms_id: msg[:id], sms_status: msg[:status])
  end
end

# Wywołanie, np. z crona dzień przed wizytą:
# Wizyta.jutro.find_each { |w| SendSmsJob.perform_later(w.id) }

Obsługa błędów i retry

Każdy błąd HTTP wraca jako podklasa Przypominamy::Error; decyzję podejmuj po klasie albo po code, nie po tekście komunikatu. Ponawiaj tylko to, co ma sens ponawiać — zawsze z tym samym Idempotency-Key.

HTTPerror.codeCo robić
400invalid_requestRequestError. Popraw payload; pole w param. Nie ponawiaj.
401unauthorizedRequestError. Sprawdź klucz w konfiguracji. Nie ponawiaj.
402insufficient_fundsRequestError. Doładuj saldo (GET /v1/account).
429rate_limitedRateLimited. Odczekaj retry_after sekund i ponów.
502provider_errorProviderError. Retry z rosnącym odstępem, ten sam Idempotency-Key.
Retry z Idempotency-Key (czysty Ruby)lib/przypominamy/retry.rb
module Przypominamy
  module Retry
    # Ponawia 429, 5xx i błędy sieci (max attempts prób), zawsze z tym samym idempotency_key,
    # więc timeout po stronie klienta nie skończy się podwójnym SMS-em.
    def self.send_sms(client, attempts: 3, **args)
      raise ArgumentError, "idempotency_key jest wymagany przy retry" unless args[:idempotency_key]

      attempt = 0
      begin
        client.send_sms(**args)
      rescue Przypominamy::RateLimited => e
        attempt += 1
        raise if attempt >= attempts
        sleep(e.retry_after || 2**attempt)
        retry
      rescue Przypominamy::ProviderError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET => e
        attempt += 1
        raise if attempt >= attempts
        sleep(2**attempt) # 2 s, 4 s, 8 s…
        retry
      end
    end
  end
end

# msg = Przypominamy::Retry.send_sms(sms, to: "+48600123456", text: "…", idempotency_key: "order-1234-confirm")

Po stronie wywołującego: rescue Przypominamy::Error => e i case e.code. Loguj e.request_id — to identyfikator, po którym support znajdzie żądanie. Przypominamy::RequestError podnoszony w jobie trafia do discard_on, a nie do kolejki retry, więc zły numer nie blokuje workera pięć razy z rzędu.

Webhook: weryfikacja podpisu

Adres ustawiasz przez PUT /v1/account/webhook, w odpowiedzi dostajesz webhook_secret. Każde zdarzenie message.sent, message.delivered, message.undelivered, message.failed i message.expired przychodzi jako POST z JSON-em { id, type, created_at, data: { message } } i nagłówkiem X-Przypominamy-Signature: t=<unix>,v1=<hex>, gdzie v1 = HMAC-SHA256(secret, "<t>.<body>"). Odrzucaj, gdy podpis się nie zgadza lub t różni się od zegara o więcej niż 300 s.

Kontroler webhooka (Rails)app/controllers/webhooks/sms_controller.rb
module Webhooks
  class SmsController < ActionController::API
    TOLERANCE = 300 # sekund

    def create
      raw = request.raw_post # surowe body — podpis liczony jest z bajtów, nie z params
      header = request.headers["X-Przypominamy-Signature"].to_s

      return head :forbidden unless valid_signature?(header, raw)

      event = JSON.parse(raw, symbolize_names: true)
      message = event.dig(:data, :message)

      # Odpowiedz 200 od razu; ciężką pracę oddaj do joba.
      UpdateSmsStatusJob.perform_later(
        event_id: event[:id], type: event[:type],
        message_id: message[:id], status: message[:status], reference: message[:reference]
      )
      head :ok
    rescue JSON::ParserError
      head :bad_request
    end

    private

    def valid_signature?(header, raw)
      parts = header.split(",").to_h { |kv| kv.strip.split("=", 2) }
      t = parts["t"]
      sig = parts["v1"]
      return false if t.nil? || sig.nil? || t !~ /\A\d+\z/
      return false if (Time.now.to_i - t.to_i).abs > TOLERANCE

      secret = Rails.application.credentials.przypominamy[:webhook_secret]
      expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw}")
      ActiveSupport::SecurityUtils.secure_compare(expected, sig) # porównanie w stałym czasie
    end
  end
end

# config/routes.rb
# post "/webhooks/sms", to: "webhooks/sms#create"

ActionController::API nie ma ochrony CSRF, więc nie trzeba jej wyłączać. Jeśli dziedziczysz po ApplicationController, dodaj skip_before_action :verify_authenticity_token tylko na tej akcji. Trzy rzeczy, które najczęściej psują weryfikację: liczenie HMAC na params.to_json zamiast request.raw_post, porównanie == zamiast secure_compare oraz brak tolerancji czasu. Jeśli endpoint nie odpowie 2xx, zdarzenie przyjdzie ponownie, więc obsłuż duplikaty po id zdarzenia.

Najlepsze praktyki

  • Timeouty zawsze jawnie. Net::HTTP domyślnie czeka 60 s na odczyt; open_timeout: 5, read_timeout: 10 chroni worker przed zawieszeniem na jednym żądaniu.
  • Wysyłka w jobie, nie w żądaniu HTTP. perform_later zwraca natychmiast, a retry_on obsługuje 429 i 5xx bez ręcznej pętli. Kontroler nie powinien czekać na bramkę SMS.
  • Idempotency-Key = identyfikator biznesowy (np. order-1234-confirm). Retry joba po timeoucie zwróci pierwotną odpowiedź zamiast wysłać drugi SMS.
  • reference do korelacji. Wraca w webhookach i w GET /v1/messages, więc łączysz status z rekordem w bazie bez osobnej tabeli mapującej.
  • Nie loguj treści ani numerów (RODO). Loguj id, status, request_id; dodaj :telefon do config.filter_parameters.
  • Sprawdzaj saldo przez GET /v1/account w cyklicznym jobie i alarmuj poniżej progu — 402 insufficient_funds w środku kampanii to najgorszy moment na doładowanie.
  • Testuj na koncie testowym. Klucz pk_test_… wysyła prawdziwe SMS-y na 2 zweryfikowane numery; w testach jednostkowych stubuj Net::HTTP przez webmock.

Częste pytania: SMS API i Ruby

Czy do wysyłki SMS w Ruby potrzebuję gema?

Nie. Net::HTTP, JSON i OpenSSL z biblioteki standardowej wystarczą: jeden POST na /v1/messages z nagłówkiem Authorization: Bearer. Klasa klienta z tej strony ma ok. 90 linii i nie ma zależności poza Ruby 3.2+. Jeśli używasz już Faraday albo HTTPX, możesz podmienić warstwę transportu.

Jak obsłużyć limit 120 żądań na minutę w Rails?

Przy HTTP 429 API zwraca error.code rate_limited i nagłówek Retry-After w sekundach. Klient podnosi Przypominamy::RateLimited z retry_after, a retry_on w ActiveJob ponawia job z rosnącym odstępem i tym samym Idempotency-Key. Tę samą treść do wielu numerów wysyłaj tablicą to (do 500 numerów) w jednym żądaniu.

Jak zweryfikować podpis webhooka w Rails?

Odczytaj surowe body przez request.raw_post, wyciągnij t i v1 z nagłówka X-Przypominamy-Signature, policz OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, "<t>.<body>") i porównaj przez ActiveSupport::SecurityUtils.secure_compare. Odrzuć, gdy t różni się od Time.now.to_i o więcej niż 300 sekund. Kontroler dziedziczący po ActionController::API nie wymaga wyłączania CSRF.

Czy Idempotency-Key jest wymagany?

Nie, jest opcjonalny, ale przy retry jest jedyną gwarancją, że SMS nie pójdzie dwa razy. Ustaw go na identyfikator biznesowy wiadomości, np. "order-1234-confirm", i używaj tej samej wartości przy każdej próbie — również gdy job ponawia ActiveJob.

Ile kosztuje wysyłka SMS przez API z Ruby?

Od 0,10 zł za część SMS (stawkę ustala kwota doładowania: 0,15 zł przy 50 zł, 0,10 zł od 500 zł, i zostaje na stałe), bez abonamentu i opłat za API. Wiadomość bez polskich znaków mieści 160 znaków w jednej części (153 przy wieloczęściowej), z polskimi znakami 70 (67). Liczbę części i koszt zwraca odpowiedź w polach parts i cost_grosze. Konto testowe ma 25 SMS-ów gratis. Szczegóły w cenniku.

Pierwszy SMS z Ruby w kwadrans

Rejestracja daje od razu klucz testowy i 25 darmowych SMS-ów na dwa własne numery. Bez karty, bez abonamentu: płacisz od 0,10 zł za część SMS, doładowanie od 50 zł.

Załóż konto

Inne języki: Go · Java · C# / .NET · Rust · Kotlin · Node.js, Python, PHP. Pytania: [email protected], +48 533 991 881.

Przypominamy.com – logo platformy SMS, MMS i IVR
Asystent Przypominamy ● Online. Odpowiada od razu
Cześć! 👋 Jestem asystentem platformy Przypominamy.com.

Pomogę Ci dobrać plan, wyjaśnię jak działa API lub odpowiem na pytania o SMS, MMS i IVR. Jak mogę pomóc?
teraz