sendly 1.0.5

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: 8ede8c7152df0e2261bf901af9012076aef1e8606d4c4ceb096620c6ade299aa
4
+ data.tar.gz: b898bece3e69f9f2181e33d0e5d9ba487430c0577a422147621a0be2658ea086
5
+ SHA512:
6
+ metadata.gz: 053a7b98c18a0f0ba8422f6e0282ef31b6f456927501f9047917b597006a92dc5b0573592d4f56935f12c402ed5c64aed7d1b3e58787e2a805d804e32b9d130f
7
+ data.tar.gz: 14cf12613830faacffb654404318e5f61c67767f719cdcc2421a6742c0e30bbdd49c53c7e0fd1773ab7dc335d112617a8d801dc665676bdabb72dbee7c69abeb
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.5
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,95 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ sendly (1.0.5)
5
+ faraday (~> 2.0)
6
+ faraday-retry (~> 2.0)
7
+
8
+ GEM
9
+ remote: https://rubygems.org/
10
+ specs:
11
+ addressable (2.8.8)
12
+ public_suffix (>= 2.0.2, < 8.0)
13
+ ast (2.4.3)
14
+ bigdecimal (3.3.1)
15
+ crack (1.0.1)
16
+ bigdecimal
17
+ rexml
18
+ diff-lcs (1.6.2)
19
+ faraday (2.14.0)
20
+ faraday-net_http (>= 2.0, < 3.5)
21
+ json
22
+ logger
23
+ faraday-net_http (3.4.2)
24
+ net-http (~> 0.5)
25
+ faraday-retry (2.3.2)
26
+ faraday (~> 2.0)
27
+ hashdiff (1.2.1)
28
+ json (2.18.0)
29
+ language_server-protocol (3.17.0.5)
30
+ lint_roller (1.1.0)
31
+ logger (1.7.0)
32
+ net-http (0.8.0)
33
+ uri (>= 0.11.1)
34
+ parallel (1.27.0)
35
+ parser (3.3.10.0)
36
+ ast (~> 2.4.1)
37
+ racc
38
+ prism (1.6.0)
39
+ public_suffix (7.0.0)
40
+ racc (1.8.1)
41
+ rainbow (3.1.1)
42
+ rake (13.3.1)
43
+ regexp_parser (2.11.3)
44
+ rexml (3.4.4)
45
+ rspec (3.13.2)
46
+ rspec-core (~> 3.13.0)
47
+ rspec-expectations (~> 3.13.0)
48
+ rspec-mocks (~> 3.13.0)
49
+ rspec-core (3.13.6)
50
+ rspec-support (~> 3.13.0)
51
+ rspec-expectations (3.13.5)
52
+ diff-lcs (>= 1.2.0, < 2.0)
53
+ rspec-support (~> 3.13.0)
54
+ rspec-mocks (3.13.7)
55
+ diff-lcs (>= 1.2.0, < 2.0)
56
+ rspec-support (~> 3.13.0)
57
+ rspec-support (3.13.6)
58
+ rubocop (1.81.7)
59
+ json (~> 2.3)
60
+ language_server-protocol (~> 3.17.0.2)
61
+ lint_roller (~> 1.1.0)
62
+ parallel (~> 1.10)
63
+ parser (>= 3.3.0.2)
64
+ rainbow (>= 2.2.2, < 4.0)
65
+ regexp_parser (>= 2.9.3, < 3.0)
66
+ rubocop-ast (>= 1.47.1, < 2.0)
67
+ ruby-progressbar (~> 1.7)
68
+ unicode-display_width (>= 2.4.0, < 4.0)
69
+ rubocop-ast (1.48.0)
70
+ parser (>= 3.3.7.2)
71
+ prism (~> 1.4)
72
+ ruby-progressbar (1.13.0)
73
+ unicode-display_width (3.2.0)
74
+ unicode-emoji (~> 4.1)
75
+ unicode-emoji (4.1.0)
76
+ uri (1.1.1)
77
+ webmock (3.26.1)
78
+ addressable (>= 2.8.0)
79
+ crack (>= 0.3.2)
80
+ hashdiff (>= 0.4.0, < 2.0.0)
81
+
82
+ PLATFORMS
83
+ arm64-darwin-24
84
+ ruby
85
+
86
+ DEPENDENCIES
87
+ bundler (~> 2.0)
88
+ rake (~> 13.0)
89
+ rspec (~> 3.0)
90
+ rubocop (~> 1.0)
91
+ sendly!
92
+ webmock (~> 3.0)
93
+
94
+ BUNDLED WITH
95
+ 2.6.9
data/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Sendly Ruby SDK
2
+
3
+ Official Ruby SDK for the Sendly SMS API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # gem
9
+ gem install sendly
10
+
11
+ # Bundler (add to Gemfile)
12
+ gem 'sendly'
13
+
14
+ # then run
15
+ bundle install
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```ruby
21
+ require 'sendly'
22
+
23
+ # Create a client
24
+ client = Sendly::Client.new("sk_live_v1_your_api_key")
25
+
26
+ # Send an SMS
27
+ message = client.messages.send(
28
+ to: "+15551234567",
29
+ text: "Hello from Sendly!"
30
+ )
31
+
32
+ puts message.id # => "msg_abc123"
33
+ puts message.status # => "queued"
34
+ ```
35
+
36
+ ## Prerequisites for Live Messaging
37
+
38
+ Before sending live SMS messages, you need:
39
+
40
+ 1. **Business Verification** - Complete verification in the [Sendly dashboard](https://sendly.live/dashboard)
41
+ - **International**: Instant approval (just provide Sender ID)
42
+ - **US/Canada**: Requires carrier approval (3-7 business days)
43
+
44
+ 2. **Credits** - Add credits to your account
45
+ - Test keys (`sk_test_*`) work without credits (sandbox mode)
46
+ - Live keys (`sk_live_*`) require credits for each message
47
+
48
+ 3. **Live API Key** - Generate after verification + credits
49
+ - Dashboard → API Keys → Create Live Key
50
+
51
+ ### Test vs Live Keys
52
+
53
+ | Key Type | Prefix | Credits Required | Verification Required | Use Case |
54
+ |----------|--------|------------------|----------------------|----------|
55
+ | Test | `sk_test_v1_*` | No | No | Development, testing |
56
+ | Live | `sk_live_v1_*` | Yes | Yes | Production messaging |
57
+
58
+ > **Note**: You can start development immediately with a test key. Messages to sandbox test numbers are free and don't require verification.
59
+
60
+ ## Configuration
61
+
62
+ ### Global Configuration
63
+
64
+ ```ruby
65
+ Sendly.configure do |config|
66
+ config.api_key = "sk_live_v1_xxx"
67
+ end
68
+
69
+ # Use the default client
70
+ Sendly.send_message(to: "+15551234567", text: "Hello!")
71
+ ```
72
+
73
+ ### Client Options
74
+
75
+ ```ruby
76
+ client = Sendly::Client.new(
77
+ "sk_live_v1_xxx",
78
+ base_url: "https://api.sendly.live/v1",
79
+ timeout: 60,
80
+ max_retries: 5
81
+ )
82
+ ```
83
+
84
+ ## Messages
85
+
86
+ ### Send an SMS
87
+
88
+ ```ruby
89
+ message = client.messages.send(
90
+ to: "+15551234567",
91
+ text: "Hello from Sendly!"
92
+ )
93
+
94
+ puts message.id
95
+ puts message.status
96
+ puts message.credits_used
97
+ ```
98
+
99
+ ### List Messages
100
+
101
+ ```ruby
102
+ # Basic listing
103
+ messages = client.messages.list(limit: 50)
104
+ messages.each { |m| puts m.to }
105
+
106
+ # With filters
107
+ messages = client.messages.list(
108
+ status: "delivered",
109
+ to: "+15551234567",
110
+ limit: 20,
111
+ offset: 0
112
+ )
113
+
114
+ # Pagination info
115
+ puts messages.total
116
+ puts messages.has_more
117
+ ```
118
+
119
+ ### Get a Message
120
+
121
+ ```ruby
122
+ message = client.messages.get("msg_abc123")
123
+
124
+ puts message.to
125
+ puts message.text
126
+ puts message.status
127
+ puts message.delivered_at
128
+ ```
129
+
130
+ ### Iterate All Messages
131
+
132
+ ```ruby
133
+ # Auto-pagination
134
+ client.messages.each do |message|
135
+ puts "#{message.id}: #{message.to}"
136
+ end
137
+
138
+ # With filters
139
+ client.messages.each(status: "delivered") do |message|
140
+ puts "Delivered: #{message.id}"
141
+ end
142
+ ```
143
+
144
+ ## Error Handling
145
+
146
+ ```ruby
147
+ begin
148
+ message = client.messages.send(
149
+ to: "+15551234567",
150
+ text: "Hello!"
151
+ )
152
+ rescue Sendly::AuthenticationError => e
153
+ puts "Invalid API key"
154
+ rescue Sendly::RateLimitError => e
155
+ puts "Rate limited, retry after #{e.retry_after} seconds"
156
+ rescue Sendly::InsufficientCreditsError => e
157
+ puts "Add more credits to your account"
158
+ rescue Sendly::ValidationError => e
159
+ puts "Invalid request: #{e.message}"
160
+ rescue Sendly::NotFoundError => e
161
+ puts "Resource not found"
162
+ rescue Sendly::NetworkError => e
163
+ puts "Network error: #{e.message}"
164
+ rescue Sendly::Error => e
165
+ puts "Error: #{e.message} (#{e.code})"
166
+ end
167
+ ```
168
+
169
+ ## Message Object
170
+
171
+ ```ruby
172
+ message.id # Unique identifier
173
+ message.to # Recipient phone number
174
+ message.text # Message content
175
+ message.status # queued, sending, sent, delivered, failed
176
+ message.credits_used # Credits consumed
177
+ message.created_at # Creation time
178
+ message.updated_at # Last update time
179
+ message.delivered_at # Delivery time (if delivered)
180
+ message.error_code # Error code (if failed)
181
+ message.error_message # Error message (if failed)
182
+
183
+ # Helper methods
184
+ message.delivered? # => true/false
185
+ message.failed? # => true/false
186
+ message.pending? # => true/false
187
+ ```
188
+
189
+ ## Message Status
190
+
191
+ | Status | Description |
192
+ |--------|-------------|
193
+ | `queued` | Message is queued for delivery |
194
+ | `sending` | Message is being sent |
195
+ | `sent` | Message was sent to carrier |
196
+ | `delivered` | Message was delivered |
197
+ | `failed` | Message delivery failed |
198
+
199
+ ## Pricing Tiers
200
+
201
+ | Tier | Countries | Credits per SMS |
202
+ |------|-----------|-----------------|
203
+ | Domestic | US, CA | 1 |
204
+ | Tier 1 | GB, PL, IN, etc. | 8 |
205
+ | Tier 2 | FR, JP, AU, etc. | 12 |
206
+ | Tier 3 | DE, IT, MX, etc. | 16 |
207
+
208
+ ## Sandbox Testing
209
+
210
+ Use test API keys (`sk_test_v1_xxx`) with these test numbers:
211
+
212
+ | Number | Behavior |
213
+ |--------|----------|
214
+ | +15550001234 | Success |
215
+ | +15550001001 | Invalid number |
216
+ | +15550001002 | Carrier rejected |
217
+ | +15550001003 | No credits |
218
+ | +15550001004 | Rate limited |
219
+
220
+ ## Requirements
221
+
222
+ - Ruby 3.0+
223
+ - Faraday 2.0+
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sendly"
5
+
6
+ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
7
+
8
+ # List recent messages
9
+ puts "=== Recent Messages ==="
10
+ messages = client.messages.list(limit: 10)
11
+ puts "Total: #{messages.total}"
12
+ puts "Has more: #{messages.has_more}"
13
+ puts
14
+
15
+ messages.each do |msg|
16
+ puts "#{msg.id}: #{msg.to} - #{msg.status}"
17
+ end
18
+
19
+ # List with filters
20
+ puts "\n=== Delivered Messages ==="
21
+ delivered = client.messages.list(status: "delivered", limit: 5)
22
+ delivered.each do |msg|
23
+ puts "#{msg.id}: Delivered at #{msg.delivered_at}"
24
+ end
25
+
26
+ # Iterate all with auto-pagination
27
+ puts "\n=== All Messages (paginated) ==="
28
+ count = 0
29
+ client.messages.each(batch_size: 50) do |msg|
30
+ puts "#{msg.id}: #{msg.to}"
31
+ count += 1
32
+ break if count >= 20 # Limit for demo
33
+ end
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "sendly"
5
+
6
+ # Configure with your API key
7
+ client = Sendly::Client.new(ENV["SENDLY_API_KEY"] || "sk_test_v1_example")
8
+
9
+ # Send an SMS
10
+ begin
11
+ message = client.messages.send(
12
+ to: "+15551234567",
13
+ text: "Hello from Sendly Ruby SDK!"
14
+ )
15
+
16
+ puts "Message sent successfully!"
17
+ puts " ID: #{message.id}"
18
+ puts " To: #{message.to}"
19
+ puts " Status: #{message.status}"
20
+ puts " Credits used: #{message.credits_used}"
21
+ rescue Sendly::AuthenticationError => e
22
+ puts "Authentication failed: #{e.message}"
23
+ rescue Sendly::InsufficientCreditsError => e
24
+ puts "Insufficient credits: #{e.message}"
25
+ rescue Sendly::ValidationError => e
26
+ puts "Validation error: #{e.message}"
27
+ rescue Sendly::RateLimitError => e
28
+ puts "Rate limited. Retry after: #{e.retry_after} seconds"
29
+ rescue Sendly::Error => e
30
+ puts "Error: #{e.message}"
31
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Sendly
8
+ # Main Sendly API client
9
+ class Client
10
+ # @return [String] API key
11
+ attr_reader :api_key
12
+
13
+ # @return [String] Base URL
14
+ attr_reader :base_url
15
+
16
+ # @return [Integer] Request timeout in seconds
17
+ attr_reader :timeout
18
+
19
+ # @return [Integer] Maximum retry attempts
20
+ attr_reader :max_retries
21
+
22
+ # Create a new Sendly client
23
+ #
24
+ # @param api_key [String] Your Sendly API key
25
+ # @param base_url [String] API base URL (optional)
26
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
27
+ # @param max_retries [Integer] Maximum retry attempts (default: 3)
28
+ #
29
+ # @example
30
+ # client = Sendly::Client.new("sk_live_v1_xxx")
31
+ # client = Sendly::Client.new("sk_live_v1_xxx", timeout: 60, max_retries: 5)
32
+ def initialize(api_key:, base_url: nil, timeout: 30, max_retries: 3)
33
+ @api_key = api_key
34
+ @base_url = (base_url || Sendly.base_url).chomp("/")
35
+ @timeout = timeout
36
+ @max_retries = max_retries
37
+
38
+ validate_api_key!
39
+ end
40
+
41
+ # Access the Messages resource
42
+ #
43
+ # @return [Sendly::Messages]
44
+ def messages
45
+ @messages ||= Messages.new(self)
46
+ end
47
+
48
+ # Make a GET request
49
+ #
50
+ # @param path [String] API path
51
+ # @param params [Hash] Query parameters
52
+ # @return [Hash] Response body
53
+ def get(path, params = {})
54
+ request(:get, path, params: params)
55
+ end
56
+
57
+ # Make a POST request
58
+ #
59
+ # @param path [String] API path
60
+ # @param body [Hash] Request body
61
+ # @return [Hash] Response body
62
+ def post(path, body = {})
63
+ request(:post, path, body: body)
64
+ end
65
+
66
+ # Make a DELETE request
67
+ #
68
+ # @param path [String] API path
69
+ # @return [Hash] Response body
70
+ def delete(path)
71
+ request(:delete, path)
72
+ end
73
+
74
+ private
75
+
76
+ def validate_api_key!
77
+ raise AuthenticationError, "API key is required" if api_key.nil? || api_key.empty?
78
+
79
+ unless api_key.match?(/^sk_(test|live)_v1_[a-zA-Z0-9_-]+$/)
80
+ raise AuthenticationError, "Invalid API key format. Expected sk_test_v1_xxx or sk_live_v1_xxx"
81
+ end
82
+ end
83
+
84
+ def request(method, path, params: {}, body: nil)
85
+ uri = build_uri(path, params)
86
+ http = build_http(uri)
87
+ req = build_request(method, uri, body)
88
+
89
+ attempt = 0
90
+ begin
91
+ response = http.request(req)
92
+ handle_response(response)
93
+ rescue Net::OpenTimeout, Net::ReadTimeout
94
+ raise TimeoutError, "Request timed out after #{timeout} seconds"
95
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
96
+ raise NetworkError, "Connection failed: #{e.message}"
97
+ rescue RateLimitError => e
98
+ attempt += 1
99
+ if attempt <= max_retries && e.retry_after
100
+ sleep(e.retry_after)
101
+ retry
102
+ end
103
+ raise
104
+ rescue ServerError => e
105
+ attempt += 1
106
+ if attempt <= max_retries
107
+ sleep(2 ** attempt) # Exponential backoff
108
+ retry
109
+ end
110
+ raise
111
+ end
112
+ end
113
+
114
+ def build_uri(path, params)
115
+ url = "#{base_url}#{path}"
116
+ uri = URI.parse(url)
117
+
118
+ if params.any?
119
+ query = params.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }.join("&")
120
+ uri.query = query
121
+ end
122
+
123
+ uri
124
+ end
125
+
126
+ def build_http(uri)
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.use_ssl = uri.scheme == "https"
129
+ http.open_timeout = 10
130
+ http.read_timeout = timeout
131
+ http
132
+ end
133
+
134
+ def build_request(method, uri, body)
135
+ req = case method
136
+ when :get
137
+ Net::HTTP::Get.new(uri)
138
+ when :post
139
+ Net::HTTP::Post.new(uri)
140
+ when :delete
141
+ Net::HTTP::Delete.new(uri)
142
+ else
143
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
144
+ end
145
+
146
+ req["Authorization"] = "Bearer #{api_key}"
147
+ req["Content-Type"] = "application/json"
148
+ req["Accept"] = "application/json"
149
+ req["User-Agent"] = "sendly-ruby/#{VERSION}"
150
+
151
+ req.body = body.to_json if body
152
+
153
+ req
154
+ end
155
+
156
+ def handle_response(response)
157
+ body = parse_body(response.body)
158
+ status = response.code.to_i
159
+
160
+ return body if status >= 200 && status < 300
161
+
162
+ raise ErrorFactory.from_response(status, body)
163
+ end
164
+
165
+ def parse_body(body)
166
+ return {} if body.nil? || body.empty?
167
+
168
+ JSON.parse(body)
169
+ rescue JSON::ParserError
170
+ { "message" => body }
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ # Base error class for all Sendly errors
5
+ class Error < StandardError
6
+ # @return [String, nil] Error code from the API
7
+ attr_reader :code
8
+
9
+ # @return [Hash, nil] Additional error details
10
+ attr_reader :details
11
+
12
+ # @return [Integer, nil] HTTP status code
13
+ attr_reader :status_code
14
+
15
+ def initialize(message = nil, code: nil, details: nil, status_code: nil)
16
+ @code = code
17
+ @details = details
18
+ @status_code = status_code
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # Raised when the API key is invalid or missing
24
+ class AuthenticationError < Error
25
+ def initialize(message = "Invalid or missing API key")
26
+ super(message, code: "AUTHENTICATION_ERROR", status_code: 401)
27
+ end
28
+ end
29
+
30
+ # Raised when the rate limit is exceeded
31
+ class RateLimitError < Error
32
+ # @return [Integer, nil] Seconds to wait before retrying
33
+ attr_reader :retry_after
34
+
35
+ def initialize(message = "Rate limit exceeded", retry_after: nil)
36
+ @retry_after = retry_after
37
+ super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429)
38
+ end
39
+ end
40
+
41
+ # Raised when the account has insufficient credits
42
+ class InsufficientCreditsError < Error
43
+ def initialize(message = "Insufficient credits")
44
+ super(message, code: "INSUFFICIENT_CREDITS", status_code: 402)
45
+ end
46
+ end
47
+
48
+ # Raised when the request contains invalid parameters
49
+ class ValidationError < Error
50
+ # @return [Hash, nil] Field-specific validation errors
51
+ attr_reader :field_errors
52
+
53
+ def initialize(message = "Validation failed", field_errors: nil, details: nil)
54
+ @field_errors = field_errors
55
+ super(message, code: "VALIDATION_ERROR", details: details, status_code: 400)
56
+ end
57
+ end
58
+
59
+ # Raised when the requested resource is not found
60
+ class NotFoundError < Error
61
+ def initialize(message = "Resource not found")
62
+ super(message, code: "NOT_FOUND", status_code: 404)
63
+ end
64
+ end
65
+
66
+ # Raised when a network error occurs
67
+ class NetworkError < Error
68
+ def initialize(message = "Network error occurred")
69
+ super(message, code: "NETWORK_ERROR")
70
+ end
71
+ end
72
+
73
+ # Raised when a timeout occurs
74
+ class TimeoutError < NetworkError
75
+ def initialize(message = "Request timed out")
76
+ super(message)
77
+ @code = "TIMEOUT_ERROR"
78
+ end
79
+ end
80
+
81
+ # Raised for unexpected API errors
82
+ class APIError < Error
83
+ def initialize(message = "An unexpected error occurred", status_code: nil, code: nil, details: nil)
84
+ super(message, code: code || "API_ERROR", status_code: status_code, details: details)
85
+ end
86
+ end
87
+
88
+ # Raised for server errors (5xx)
89
+ class ServerError < Error
90
+ def initialize(message = "Server error occurred", status_code: 500)
91
+ super(message, code: "SERVER_ERROR", status_code: status_code)
92
+ end
93
+ end
94
+
95
+ # Convert API response to appropriate error
96
+ class ErrorFactory
97
+ def self.from_response(status, body)
98
+ message = body["message"] || body["error"] || "Unknown error"
99
+ code = body["code"]
100
+ details = body["details"]
101
+
102
+ case status
103
+ when 400, 422
104
+ ValidationError.new(message, details: details)
105
+ when 401
106
+ AuthenticationError.new(message)
107
+ when 402
108
+ InsufficientCreditsError.new(message)
109
+ when 404
110
+ NotFoundError.new(message)
111
+ when 429
112
+ retry_after = body["retry_after"] || body["retryAfter"]
113
+ RateLimitError.new(message, retry_after: retry_after)
114
+ when 500..599
115
+ ServerError.new(message, status_code: status)
116
+ else
117
+ APIError.new(message, status_code: status, code: code, details: details)
118
+ end
119
+ end
120
+ end
121
+ end