siel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +51 -0
- data/lib/siel/client.rb +147 -0
- data/lib/siel/error.rb +23 -0
- data/lib/siel/resources.rb +152 -0
- data/lib/siel/version.rb +5 -0
- data/lib/siel/webhook.rb +23 -0
- data/lib/siel.rb +18 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 30da03ac501823e026013c4b68357b532666f49450dfb01dbce7c565515dd360
|
|
4
|
+
data.tar.gz: 56fa3b33cac95b9f97d5e61485c06d26729ee9f11fc3e19ded6d7b983f6a3895
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4bf5eb85f8520035f050dc7018c120cb398e6ce9dd44af3f62cb7bd76d8568d0c8856b0b9e3de943da3796fa4c506f6acf9bc59287fd83d95515c23a1803bded
|
|
7
|
+
data.tar.gz: 2042f27f6c608034f60738bcfe87c4edbf9de85ac30871e6a5de7ad81e4bf0ed2b38c215bd1011b095df2f4957715d81993a95f84c29d13a556756f94cff93d0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Siel Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Siel for Ruby
|
|
2
|
+
|
|
3
|
+
The Siel SMS API from Ruby 3.0 or later: messages, batches, OTP codes,
|
|
4
|
+
templates, prices and webhook signatures. No dependencies.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
gem install siel
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or in your Gemfile: `gem "siel"`.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "siel"
|
|
14
|
+
|
|
15
|
+
siel = Siel::Client.new(ENV.fetch("SIEL_API_KEY"))
|
|
16
|
+
|
|
17
|
+
message = siel.messages.send(
|
|
18
|
+
to: "+447700900123",
|
|
19
|
+
from: "YourBrand",
|
|
20
|
+
body: "Your order #4821 has shipped",
|
|
21
|
+
client_reference: "order-4821"
|
|
22
|
+
)
|
|
23
|
+
puts message["id"], message["status"] # "01a1…", "queued"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Fields are keyword arguments with the API's names (pass a hash of them as
|
|
27
|
+
`**fields`); answers are hashes with string keys.
|
|
28
|
+
|
|
29
|
+
- **Retries are safe.** A send gets an `Idempotency-Key` of its own unless you
|
|
30
|
+
pass `idempotency_key:`, so a retry after a timeout never sends twice. `429`
|
|
31
|
+
is retried after `Retry-After`; network errors and `5xx` only where
|
|
32
|
+
repeating can't do anything twice.
|
|
33
|
+
- **Errors** are `Siel::Error` with `status`, `code` (branch on it:
|
|
34
|
+
`invalid_to`, `insufficient_balance`…), `message` and `retry_after`.
|
|
35
|
+
- **Webhooks:** `Siel.verify_webhook(secret, timestamp: …, signature: …, body: raw)`
|
|
36
|
+
checks the `X-Smsgw-Timestamp` and `X-Smsgw-Signature` headers against the
|
|
37
|
+
raw body (`request.body.read`).
|
|
38
|
+
|
|
39
|
+
| | |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `messages` | `send`, `send_batch`, `get`, `list` |
|
|
42
|
+
| `otp` | `start`, `check`, `check_number`, `get`, `list` |
|
|
43
|
+
| `templates`, `webhooks` | `list`, `get`, `create`, `update`, `delete` |
|
|
44
|
+
| `senders` | `list`, `get` |
|
|
45
|
+
| | `balance`, `prices(country:)` |
|
|
46
|
+
|
|
47
|
+
Docs: https://docs.siellabs.com · The API as OpenAPI: https://docs.siellabs.com/openapi.yaml
|
|
48
|
+
|
|
49
|
+
Tests: `ruby -Ilib test/test_client.rb` (against a stand-in server).
|
|
50
|
+
`test/live.rb` runs against a real gateway, given `SIEL_BASE_URL`, `SIEL_API_KEY`
|
|
51
|
+
and `SIEL_FROM`.
|
data/lib/siel/client.rb
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Siel
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.siellabs.com"
|
|
10
|
+
|
|
11
|
+
# A client for one API key. Fields go in as keyword arguments with the API's
|
|
12
|
+
# names; answers come back as the API's JSON, parsed into hashes with
|
|
13
|
+
# string keys.
|
|
14
|
+
class Client
|
|
15
|
+
# What can go wrong before any answer comes.
|
|
16
|
+
NETWORK_ERRORS = [
|
|
17
|
+
IOError, SocketError, SystemCallError, Timeout::Error,
|
|
18
|
+
OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::ProtocolError
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :base_url, :timeout, :max_retries, :messages, :otp, :templates, :webhooks, :senders
|
|
22
|
+
|
|
23
|
+
# api_key is one of your API keys, smsgw_…. timeout is in seconds, per
|
|
24
|
+
# request. max_retries counts the retries of refused (429) and, when it's
|
|
25
|
+
# safe, failed requests.
|
|
26
|
+
def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: 30, max_retries: 2)
|
|
27
|
+
raise ArgumentError, "Siel: pass one of your API keys" if api_key.nil? || api_key.to_s.empty?
|
|
28
|
+
|
|
29
|
+
@api_key = api_key.to_s
|
|
30
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
31
|
+
@timeout = timeout
|
|
32
|
+
@max_retries = max_retries
|
|
33
|
+
@messages = Messages.new(self)
|
|
34
|
+
@otp = OTP.new(self)
|
|
35
|
+
@templates = Resource.new(self, "/v1/templates")
|
|
36
|
+
@webhooks = Resource.new(self, "/v1/webhooks")
|
|
37
|
+
@senders = Senders.new(self)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Your balance, credit limit and what you can still spend, in micros.
|
|
41
|
+
def balance
|
|
42
|
+
request("GET", "/v1/balance")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# What you pay per part, by country and network.
|
|
46
|
+
def prices(country: nil)
|
|
47
|
+
request("GET", "/v1/prices", query: { country: country })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Leaves the API key out of logs and consoles.
|
|
51
|
+
def inspect
|
|
52
|
+
"#<#{self.class} base_url=#{@base_url.inspect}>"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Calls the API; the resources use this. Retried: what the API refused
|
|
56
|
+
# before doing anything (429), and, when the request is safe to repeat (a
|
|
57
|
+
# GET or DELETE, or a send with its key), network errors and 5xx.
|
|
58
|
+
def request(method, path, body: nil, query: nil, idempotency_key: nil)
|
|
59
|
+
uri = URI(@base_url + path)
|
|
60
|
+
params = (query || {}).reject { |_, v| v.nil? }
|
|
61
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
62
|
+
headers = {
|
|
63
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
64
|
+
"Accept" => "application/json",
|
|
65
|
+
"User-Agent" => "siel-ruby/#{VERSION}"
|
|
66
|
+
}
|
|
67
|
+
payload = nil
|
|
68
|
+
unless body.nil?
|
|
69
|
+
payload = JSON.generate(body)
|
|
70
|
+
headers["Content-Type"] = "application/json"
|
|
71
|
+
end
|
|
72
|
+
key = idempotency_key.to_s
|
|
73
|
+
headers["Idempotency-Key"] = key unless key.empty?
|
|
74
|
+
repeatable = %w[GET DELETE].include?(method) || !key.empty?
|
|
75
|
+
|
|
76
|
+
attempt = 0
|
|
77
|
+
loop do
|
|
78
|
+
begin
|
|
79
|
+
res = perform(method, uri, headers, payload)
|
|
80
|
+
rescue *NETWORK_ERRORS => e
|
|
81
|
+
if repeatable && attempt < @max_retries
|
|
82
|
+
sleep(backoff(attempt))
|
|
83
|
+
attempt += 1
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
raise Error, "request failed: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
status = res.code.to_i
|
|
90
|
+
raw = res.body.to_s
|
|
91
|
+
if status >= 200 && status < 300
|
|
92
|
+
return nil if status == 204 || raw.empty?
|
|
93
|
+
|
|
94
|
+
return parse(raw)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
data = begin
|
|
98
|
+
parse(raw)
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
err = data.is_a?(Hash) && data["error"].is_a?(Hash) ? data["error"] : {}
|
|
103
|
+
retry_after = seconds(res["Retry-After"]) || seconds(err["retry_after"])
|
|
104
|
+
if (status == 429 || (repeatable && status >= 500)) && attempt < @max_retries
|
|
105
|
+
sleep(retry_after&.between?(0, 30) ? retry_after : backoff(attempt))
|
|
106
|
+
attempt += 1
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
raise Error.new(err["message"] || "HTTP #{status}", status: status, code: err["code"] || "http_error",
|
|
110
|
+
retry_after: retry_after, body: data)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def perform(method, uri, headers, payload)
|
|
117
|
+
req = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
|
|
118
|
+
req.body = payload unless payload.nil?
|
|
119
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
120
|
+
http.use_ssl = uri.scheme == "https"
|
|
121
|
+
http.open_timeout = @timeout
|
|
122
|
+
http.read_timeout = @timeout
|
|
123
|
+
http.write_timeout = @timeout
|
|
124
|
+
# Net::HTTP would quietly repeat a GET, PUT or DELETE once; the retries
|
|
125
|
+
# here are the only ones.
|
|
126
|
+
http.max_retries = 0
|
|
127
|
+
http.start { |conn| conn.request(req) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse(raw)
|
|
131
|
+
JSON.parse(raw.dup.force_encoding(Encoding::UTF_8))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Whole seconds from a header or the error, or nil.
|
|
135
|
+
def seconds(value)
|
|
136
|
+
case value
|
|
137
|
+
when Integer then value
|
|
138
|
+
when String then Integer(value.strip, 10, exception: false)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 0.5 s, 1 s, 2 s…, with jitter so a burst of clients doesn't retry in step.
|
|
143
|
+
def backoff(attempt)
|
|
144
|
+
[0.5 * (2**attempt), 8.0].min * (0.8 + (0.4 * rand))
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/siel/error.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Siel
|
|
4
|
+
# An error the API answered with, or a request that never got an answer.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# The HTTP status; 0 when no answer came.
|
|
7
|
+
attr_reader :status
|
|
8
|
+
# The API's stable error code, such as "invalid_to": branch on this.
|
|
9
|
+
attr_reader :code
|
|
10
|
+
# Seconds until the same request can work, when the API says.
|
|
11
|
+
attr_reader :retry_after
|
|
12
|
+
# The whole answer, parsed: some errors carry more (an OTP's verification).
|
|
13
|
+
attr_reader :body
|
|
14
|
+
|
|
15
|
+
def initialize(message, status: 0, code: "connection_error", retry_after: nil, body: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
@code = code
|
|
19
|
+
@retry_after = retry_after
|
|
20
|
+
@body = body
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Siel
|
|
6
|
+
# Helpers the resources share.
|
|
7
|
+
module Util # :nodoc:
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Drops fields left as nil.
|
|
11
|
+
def clean(fields)
|
|
12
|
+
fields.reject { |_, v| v.nil? }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Escapes an ID for a URL path: anything but letters, digits and -._~
|
|
16
|
+
def escape(id)
|
|
17
|
+
id.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The key you gave, or a new one, so a retry never sends twice.
|
|
21
|
+
def key(given)
|
|
22
|
+
given.nil? || given.to_s.empty? ? SecureRandom.uuid : given.to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Messages: send, send_batch, get, list.
|
|
27
|
+
class Messages
|
|
28
|
+
def initialize(client)
|
|
29
|
+
@client = client
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Sends one message: to:, from:, and body: or template_id: with
|
|
33
|
+
# variables:. A retry after a timeout never sends it twice: an
|
|
34
|
+
# Idempotency-Key is made unless you pass idempotency_key:.
|
|
35
|
+
#
|
|
36
|
+
# Given a method name, as Object#send is, it calls that method.
|
|
37
|
+
def send(*args, **fields)
|
|
38
|
+
return super if args.first.is_a?(Symbol) || args.first.is_a?(String)
|
|
39
|
+
|
|
40
|
+
send_one(*args, **fields)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One text or template to up to 100 numbers; each message answers for
|
|
44
|
+
# itself in "data".
|
|
45
|
+
def send_batch(from:, messages:, body: nil, template_id: nil, variables: nil, callback_url: nil,
|
|
46
|
+
smart_encoding: nil, shorten_links: nil, validity_period: nil, idempotency_key: nil)
|
|
47
|
+
fields = Util.clean(from: from, messages: messages, body: body, template_id: template_id,
|
|
48
|
+
variables: variables, callback_url: callback_url, smart_encoding: smart_encoding,
|
|
49
|
+
shorten_links: shorten_links, validity_period: validity_period)
|
|
50
|
+
@client.request("POST", "/v1/messages/batch", body: fields, idempotency_key: Util.key(idempotency_key))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get(id)
|
|
54
|
+
@client.request("GET", "/v1/messages/#{Util.escape(id)}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Your latest messages, newest first.
|
|
58
|
+
def list(limit: nil)
|
|
59
|
+
@client.request("GET", "/v1/messages", query: { limit: limit })["data"]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def send_one(to:, from:, body: nil, template_id: nil, variables: nil, callback_url: nil, smart_encoding: nil,
|
|
65
|
+
shorten_links: nil, validity_period: nil, client_reference: nil, idempotency_key: nil)
|
|
66
|
+
fields = Util.clean(to: to, from: from, body: body, template_id: template_id, variables: variables,
|
|
67
|
+
callback_url: callback_url, smart_encoding: smart_encoding, shorten_links: shorten_links,
|
|
68
|
+
validity_period: validity_period, client_reference: client_reference)
|
|
69
|
+
@client.request("POST", "/v1/messages", body: fields, idempotency_key: Util.key(idempotency_key))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# OTP codes: start, check, check_number, get, list.
|
|
74
|
+
class OTP
|
|
75
|
+
def initialize(client)
|
|
76
|
+
@client = client
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Sends a code by Telegram or SMS.
|
|
80
|
+
def start(to:, channels: nil, code_length: nil, code: nil, ttl_seconds: nil, from: nil, sms_template: nil,
|
|
81
|
+
template_id: nil, payload: nil, callback_url: nil)
|
|
82
|
+
fields = Util.clean(to: to, channels: channels, code_length: code_length, code: code, ttl_seconds: ttl_seconds,
|
|
83
|
+
from: from, sms_template: sms_template, template_id: template_id, payload: payload,
|
|
84
|
+
callback_url: callback_url)
|
|
85
|
+
@client.request("POST", "/v1/verifications", body: fields)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Checks what the person typed. A wrong code isn't an error: look at
|
|
89
|
+
# "valid" and "result".
|
|
90
|
+
def check(id, code)
|
|
91
|
+
@client.request("POST", "/v1/verifications/#{Util.escape(id)}/check", body: { code: code })
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Checks the latest code sent to a number.
|
|
95
|
+
def check_number(to, code)
|
|
96
|
+
@client.request("POST", "/v1/verifications/check", body: { to: to, code: code })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def get(id)
|
|
100
|
+
@client.request("GET", "/v1/verifications/#{Util.escape(id)}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def list(to: nil, status: nil, channel: nil, limit: nil)
|
|
104
|
+
query = { to: to, status: status, channel: channel, limit: limit }
|
|
105
|
+
@client.request("GET", "/v1/verifications", query: query)["data"]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Templates and webhooks: list, get, create, update, delete.
|
|
110
|
+
class Resource
|
|
111
|
+
def initialize(client, base)
|
|
112
|
+
@client = client
|
|
113
|
+
@base = base
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def list(**query)
|
|
117
|
+
@client.request("GET", @base, query: query)["data"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def get(id)
|
|
121
|
+
@client.request("GET", "#{@base}/#{Util.escape(id)}")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create(**fields)
|
|
125
|
+
@client.request("POST", @base, body: Util.clean(fields))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def update(id, **fields)
|
|
129
|
+
@client.request("PUT", "#{@base}/#{Util.escape(id)}", body: Util.clean(fields))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def delete(id)
|
|
133
|
+
@client.request("DELETE", "#{@base}/#{Util.escape(id)}")
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Your sender IDs; registering one is in the web app.
|
|
139
|
+
class Senders
|
|
140
|
+
def initialize(client)
|
|
141
|
+
@client = client
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def list(status: nil)
|
|
145
|
+
@client.request("GET", "/v1/senders", query: { status: status })["data"]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get(id)
|
|
149
|
+
@client.request("GET", "/v1/senders/#{Util.escape(id)}")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/siel/version.rb
ADDED
data/lib/siel/webhook.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Siel
|
|
6
|
+
# True when a webhook is signed with your webhook secret and recent.
|
|
7
|
+
#
|
|
8
|
+
# timestamp and signature are the X-Smsgw-Timestamp and X-Smsgw-Signature
|
|
9
|
+
# headers. Pass the raw body exactly as received (request.body.read), not
|
|
10
|
+
# JSON parsed and written again.
|
|
11
|
+
def self.verify_webhook(secret, timestamp:, signature:, body:, tolerance_seconds: 300, now: nil)
|
|
12
|
+
timestamp = timestamp.to_s
|
|
13
|
+
signature = signature.to_s
|
|
14
|
+
return false if timestamp.empty? || signature.empty?
|
|
15
|
+
|
|
16
|
+
ts = Integer(timestamp, 10, exception: false)
|
|
17
|
+
return false if ts.nil? || ((now || Time.now.to_f) - ts).abs > tolerance_seconds
|
|
18
|
+
|
|
19
|
+
signed = timestamp.b << "." << body.to_s.b
|
|
20
|
+
mac = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, signed)
|
|
21
|
+
OpenSSL.secure_compare("v1=#{mac}", signature)
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/siel.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Siel SMS API for Ruby 3.0+, with no dependencies.
|
|
4
|
+
#
|
|
5
|
+
# require "siel"
|
|
6
|
+
#
|
|
7
|
+
# siel = Siel::Client.new(ENV.fetch("SIEL_API_KEY"))
|
|
8
|
+
# message = siel.messages.send(to: "+447700900123", from: "YourBrand", body: "Your order has shipped")
|
|
9
|
+
#
|
|
10
|
+
# Docs: https://docs.siellabs.com
|
|
11
|
+
module Siel
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require_relative "siel/version"
|
|
15
|
+
require_relative "siel/error"
|
|
16
|
+
require_relative "siel/client"
|
|
17
|
+
require_relative "siel/resources"
|
|
18
|
+
require_relative "siel/webhook"
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: siel
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Siel Labs
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: 'The Siel SMS API from Ruby 3.0 or later: messages, batches, OTP codes,
|
|
14
|
+
templates, prices and webhook signatures. No dependencies.'
|
|
15
|
+
email: support@siellabs.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/siel.rb
|
|
23
|
+
- lib/siel/client.rb
|
|
24
|
+
- lib/siel/error.rb
|
|
25
|
+
- lib/siel/resources.rb
|
|
26
|
+
- lib/siel/version.rb
|
|
27
|
+
- lib/siel/webhook.rb
|
|
28
|
+
homepage: https://docs.siellabs.com/sdks
|
|
29
|
+
licenses:
|
|
30
|
+
- MIT
|
|
31
|
+
metadata:
|
|
32
|
+
source_code_uri: https://siellabs.com/git/siel-ruby.git
|
|
33
|
+
documentation_uri: https://docs.siellabs.com/sdks
|
|
34
|
+
rubygems_mfa_required: 'true'
|
|
35
|
+
post_install_message:
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '3.0'
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.5.22
|
|
51
|
+
signing_key:
|
|
52
|
+
specification_version: 4
|
|
53
|
+
summary: 'The Siel SMS API for Ruby: messages, batches, OTP codes, prices and webhook
|
|
54
|
+
signatures.'
|
|
55
|
+
test_files: []
|