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 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`.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Siel
4
+ VERSION = "0.1.0"
5
+ end
@@ -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: []