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..data` (so `params` is a Hash in Rack/Rails, an Array in PHP) plus a `hash` field; acknowledge the webhook by replying with “100”.

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

return head(:forbidden) unless SmsRu::Webhook.valid?(params["data"], params["hash"], api_id)
SmsRu::Webhook.parse(params["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



63
64
65
# File 'lib/sms_ru/webhook.rb', line 63

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:



24
25
26
27
28
29
30
31
32
33
34
# File 'lib/sms_ru/webhook.rb', line 24

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.



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

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.



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

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



43
44
45
46
47
48
# File 'lib/sms_ru/webhook.rb', line 43

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