Module: SmsRu::Webhook

Defined in:
lib/sms_ru/webhook.rb

Overview

Parses the inbound webhook payload SMS.ru POSTs to your callback URL and verifies its signature. SMS.ru sends the records as POST fields data[0]..data[N] plus a hash field; acknowledge the webhook by replying with "100".

The data param arrives as a Hash in bare Rack and an Array in PHP-style clients. In Rails it is an ActionController::Parameters, which is not a Hash — convert it with .to_unsafe_h first, or the numeric-key ordering the signature depends on is skipped and Webhook.valid? rejects the payload. The payload is signature-verified, so to_unsafe_h is safe here (.to_h would drop unpermitted keys).

Webhook.parse returns one typed event per record — a Events::SmsStatus, Events::CallcheckStatus, Events::Test, or Events::Unknown — best handled with a case match:

data = params[:data].to_unsafe_h # Rails; pass params["data"] as-is in bare Rack
return head(:forbidden) unless SmsRu::Webhook.valid?(data, params[:hash], api_id)
SmsRu::Webhook.parse(data).each do |event|
case event
when SmsRu::Events::SmsStatus       then update_delivery(event.id, event.status_code)
when SmsRu::Events::CallcheckStatus then confirm(event.id) if event.confirmed?
end
end

Class Method Summary collapse

Class Method Details

.entries(data) ⇒ Array<String>

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Normalizes the data param to an ordered Array of record strings. SMS.ru numbers the fields data; Rack delivers them as a Hash, so sort by the numeric key to preserve SMS.ru's order (the signature depends on it).

Parameters:

  • data (Hash, Array<String>, String, nil)

    the POST "data" parameter

Returns:

  • (Array<String>)

    the records in the order SMS.ru sent them



71
72
73
# File 'lib/sms_ru/webhook.rb', line 71

def self.entries(data)
  data.is_a?(Hash) ? data.sort_by { |k, _| Coerce.integer(k) }.map(&:last) : Array(data)
end

.parse(data) ⇒ Array<SmsRu::Events::SmsStatus, SmsRu::Events::CallcheckStatus, SmsRu::Events::Test, SmsRu::Events::Unknown>

Returns one event per record.

Parameters:

  • data (Hash, Array<String>, String, nil)

    the POST "data" parameter

Returns:



32
33
34
35
36
37
38
39
40
41
42
# File 'lib/sms_ru/webhook.rb', line 32

def self.parse(data)
  entries(data).map do |entry|
    lines = entry.to_s.split("\n")
    case lines[0]
    when "sms_status"       then Events::SmsStatus.new(**status_fields(lines))
    when "callcheck_status" then Events::CallcheckStatus.new(**status_fields(lines))
    when "test"             then Events::Test.new(created_at: time(lines[1]), raw: lines)
    else                         Events::Unknown.new(type: lines[0].to_s, raw: lines)
    end
  end
end

.status_fields(lines) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Common fields of the "type / id / status / timestamp" status records.



60
61
62
# File 'lib/sms_ru/webhook.rb', line 60

def self.status_fields(lines)
  { id: lines[1].to_s, status_code: Coerce.integer?(lines[2]), created_at: time(lines[3]), raw: lines }
end

.time(str) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Converts a unix-timestamp line into a Time, or nil when absent.



77
78
79
80
# File 'lib/sms_ru/webhook.rb', line 77

def self.time(str)
  unix = Coerce.integer?(str)
  unix && Time.at(unix)
end

.valid?(data, hash, api_id) ⇒ Boolean

Verifies the payload genuinely came from SMS.ru (constant-time compare of SMS.ru's hash against sha256(api_id + concatenated data entries)).

Parameters:

  • data (Hash, Array<String>, String, nil)

    the POST "data" parameter

  • hash (String, nil)

    the POST "hash" parameter

  • api_id (String)

    your SMS.ru API id

Returns:

  • (Boolean)

    true when the signature matches



51
52
53
54
55
56
# File 'lib/sms_ru/webhook.rb', line 51

def self.valid?(data, hash, api_id)
  return false unless hash.is_a?(String)

  expected = OpenSSL::Digest::SHA256.hexdigest("#{api_id}#{entries(data).join}")
  expected.bytesize == hash.bytesize && OpenSSL.fixed_length_secure_compare(expected, hash)
end