sendly 3.18.0 → 3.18.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b45492ba74b05f9d480cea6a2a911c8b6ec9b66c5e197230a0a10ea81e4692f9
4
- data.tar.gz: 981f6d67200e9725c9e0f546d65a9b575a3cfc4a1d5b935bd8f0c5c2034a1dd1
3
+ metadata.gz: bade33a147b49cd340671aaf86a187388360ba9c2e8d58fa0d51b6b486620bb8
4
+ data.tar.gz: eb35d212b0466da3fe3ae102e80a8314a0fd6081f8a8e1c89e680ad715c7fb7f
5
5
  SHA512:
6
- metadata.gz: 7724bb3862a1b21aab27d4470b41b468585bfbce1fc73eb4f62c851afd710b8e24b54362cd2da64d6e3833b859717510220f4ccf502786330d04950f056fbe8f
7
- data.tar.gz: e4319bb9b9a322d2b119e1dd1c6da32e68e4c178e2006a2987058a7a5af6112fde9a590aaeee18691ea7427b3b06f0e31be1a351d5e1d2b4ba51c9c89f12987f
6
+ metadata.gz: 321cb814654988946c7029a44233383a28caaae97702d374669ad77be6b9c277befc22166fd42f16018f90d723ab4ee1050c1dbccd4483d3e636ea3ccffbf8fa
7
+ data.tar.gz: 9f1244a84aa2c84848e83fc74eb15bdcf2aea5ecaebf1e3406796a6d64aeb4e6c081cf81555455e0be89a2cfbed20439225f1301d99bd61d74876236a261e9fb
data/CHANGELOG.md ADDED
@@ -0,0 +1,48 @@
1
+ # sendly (Ruby)
2
+
3
+ ## 3.18.1
4
+
5
+ ### Patch Changes
6
+
7
+ - fix: webhook signature verification and payload parsing now match server implementation
8
+ - `verify_signature()` accepts `timestamp:` keyword argument for HMAC on `timestamp.payload` format
9
+ - `parse_event()` handles `data[:object]` nesting (with flat `data` fallback for backwards compat)
10
+ - `WebhookEvent` adds `livemode` attr, `created` field, `created_at` alias
11
+ - `WebhookMessageData` renamed `message_id` to `id` (with `message_id` method alias)
12
+ - Added `direction`, `organization_id`, `text`, `message_format`, `media_urls` attrs
13
+ - `generate_signature()` accepts `timestamp:` keyword argument
14
+ - 5-minute timestamp tolerance check prevents replay attacks
15
+
16
+ ## 3.18.0
17
+
18
+ ### Minor Changes
19
+
20
+ - Add MMS support for US/CA domestic messaging
21
+
22
+ ## 3.17.0
23
+
24
+ ### Minor Changes
25
+
26
+ - Add structured error classification and automatic message retry
27
+ - New `error_code` field with 13 structured codes (E001-E013, E099)
28
+ - New `retry_count` field tracks retry attempts
29
+ - New `retrying` status and `message.retrying` webhook event
30
+
31
+ ## 3.16.0
32
+
33
+ ### Minor Changes
34
+
35
+ - Add `transfer_credits` for moving credits between workspaces
36
+
37
+ ## 3.15.2
38
+
39
+ ### Patch Changes
40
+
41
+ - Add metadata support to Message class
42
+
43
+ ## 3.13.0
44
+
45
+ ### Minor Changes
46
+
47
+ - Campaigns, Contacts & Contact Lists resources with full CRUD
48
+ - Template clone method
data/Gemfile.lock CHANGED
@@ -1,14 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.18.0)
4
+ sendly (3.18.2)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- addressable (2.8.8)
11
+ addressable (2.8.9)
12
12
  public_suffix (>= 2.0.2, < 8.0)
13
13
  ast (2.4.3)
14
14
  bigdecimal (3.3.1)
data/README.md CHANGED
@@ -372,6 +372,74 @@ Use test API keys (`sk_test_v1_xxx`) with these test numbers:
372
372
  | +15005550004 | Fails: rate_limit_exceeded |
373
373
  | +15005550006 | Fails: carrier_violation |
374
374
 
375
+ ## Enterprise
376
+
377
+ The Enterprise API lets you programmatically manage workspaces, verification, credits, and API keys for multi-tenant platforms. Requires an enterprise master key (`sk_live_v1_master_*`).
378
+
379
+ ### Quick Provision
380
+
381
+ Create a fully configured workspace in a single call:
382
+
383
+ ```ruby
384
+ client = Sendly::Client.new(api_key: "sk_live_v1_master_YOUR_KEY")
385
+
386
+ result = client.enterprise.provision(
387
+ name: "Acme Insurance - Austin",
388
+ source_workspace_id: "ws_verified",
389
+ credit_amount: 5000,
390
+ credit_source_workspace_id: "ws_pool",
391
+ key_name: "Production",
392
+ key_type: "live",
393
+ generate_opt_in_page: true
394
+ )
395
+
396
+ puts result["workspace"]["id"]
397
+ puts result["apiKey"]["rawKey"]
398
+ ```
399
+
400
+ Three provisioning modes:
401
+
402
+ | Mode | Params | Description |
403
+ |------|--------|-------------|
404
+ | **Inherit** | `source_workspace_id:` | Shares toll-free number from verified workspace |
405
+ | **Inherit + New Number** | `source_workspace_id:` + `inherit_with_new_number: true` | Copies business info, purchases new number |
406
+ | **Fresh** | `verification: { ... }` | Full business details, new number + carrier approval |
407
+
408
+ ### Workspace Management
409
+
410
+ ```ruby
411
+ ws = client.enterprise.workspaces.create("Acme Insurance")
412
+ list = client.enterprise.workspaces.list
413
+ detail = client.enterprise.workspaces.get("ws_xxx")
414
+ client.enterprise.workspaces.delete("ws_xxx")
415
+ ```
416
+
417
+ ### Credits & API Keys
418
+
419
+ ```ruby
420
+ client.enterprise.workspaces.transfer_credits("ws_dest",
421
+ source_workspace_id: "ws_source", amount: 5000)
422
+
423
+ key = client.enterprise.workspaces.create_key("ws_xxx",
424
+ name: "Production", type: "live")
425
+ puts key["rawKey"]
426
+
427
+ client.enterprise.workspaces.revoke_key("ws_xxx", "key_abc")
428
+ ```
429
+
430
+ ### Webhooks & Analytics
431
+
432
+ ```ruby
433
+ client.enterprise.webhooks.set("https://yourapp.com/webhooks")
434
+ overview = client.enterprise.analytics.overview
435
+ messages = client.enterprise.analytics.messages(period: "30d")
436
+ delivery = client.enterprise.analytics.delivery
437
+ ```
438
+
439
+ Full enterprise docs: [sendly.live/docs/enterprise](https://sendly.live/docs/enterprise)
440
+
441
+ ---
442
+
375
443
  ## Requirements
376
444
 
377
445
  - Ruby 3.0+
data/lib/sendly/client.rb CHANGED
@@ -95,6 +95,13 @@ module Sendly
95
95
  @contacts ||= ContactsResource.new(self)
96
96
  end
97
97
 
98
+ # Access the Enterprise resource
99
+ #
100
+ # @return [Sendly::EnterpriseResource]
101
+ def enterprise
102
+ @enterprise ||= EnterpriseResource.new(self)
103
+ end
104
+
98
105
  # Make a GET request
99
106
  #
100
107
  # @param path [String] API path
@@ -122,6 +129,15 @@ module Sendly
122
129
  request(:patch, path, body: body)
123
130
  end
124
131
 
132
+ # Make a PUT request
133
+ #
134
+ # @param path [String] API path
135
+ # @param body [Hash] Request body
136
+ # @return [Hash] Response body
137
+ def put(path, body = {})
138
+ request(:put, path, body: body)
139
+ end
140
+
125
141
  # Make a DELETE request
126
142
  #
127
143
  # @param path [String] API path
@@ -250,6 +266,8 @@ module Sendly
250
266
  Net::HTTP::Get.new(uri)
251
267
  when :post
252
268
  Net::HTTP::Post.new(uri)
269
+ when :put
270
+ Net::HTTP::Put.new(uri)
253
271
  when :patch
254
272
  Net::HTTP::Patch.new(uri)
255
273
  when :delete
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendly
4
+ class EnterpriseWorkspacesSubResource
5
+ def initialize(client)
6
+ @client = client
7
+ end
8
+
9
+ def create(name:, description: nil)
10
+ raise ArgumentError, "Workspace name is required" if name.nil? || name.strip.empty?
11
+
12
+ body = { name: name }
13
+ body[:description] = description if description
14
+
15
+ @client.post("/enterprise/workspaces", body)
16
+ end
17
+
18
+ def list
19
+ @client.get("/enterprise/workspaces")
20
+ end
21
+
22
+ def get(workspace_id)
23
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
24
+
25
+ @client.get("/enterprise/workspaces/#{workspace_id}")
26
+ end
27
+
28
+ def delete(workspace_id)
29
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
30
+
31
+ @client.delete("/enterprise/workspaces/#{workspace_id}")
32
+ end
33
+
34
+ def submit_verification(workspace_id, business_name:, business_type:, ein:, address:, city:, state:, zip:, use_case:, sample_messages:, monthly_volume: nil)
35
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
36
+
37
+ body = {
38
+ business_name: business_name,
39
+ business_type: business_type,
40
+ ein: ein,
41
+ address: address,
42
+ city: city,
43
+ state: state,
44
+ zip: zip,
45
+ use_case: use_case,
46
+ sample_messages: sample_messages
47
+ }
48
+ body[:monthly_volume] = monthly_volume if monthly_volume
49
+
50
+ @client.post("/enterprise/workspaces/#{workspace_id}/verification/submit", body)
51
+ end
52
+
53
+ def inherit_verification(workspace_id, source_workspace_id:)
54
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
55
+ raise ArgumentError, "Source workspace ID is required" if source_workspace_id.nil? || source_workspace_id.empty?
56
+
57
+ @client.post("/enterprise/workspaces/#{workspace_id}/verification/inherit", {
58
+ source_workspace_id: source_workspace_id
59
+ })
60
+ end
61
+
62
+ def get_verification(workspace_id)
63
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
64
+
65
+ @client.get("/enterprise/workspaces/#{workspace_id}/verification")
66
+ end
67
+
68
+ def transfer_credits(workspace_id, source_workspace_id:, amount:)
69
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
70
+ raise ArgumentError, "Source workspace ID is required" if source_workspace_id.nil? || source_workspace_id.empty?
71
+ raise ArgumentError, "Amount must be a positive number" if !amount.is_a?(Integer) || amount <= 0
72
+
73
+ @client.post("/enterprise/workspaces/#{workspace_id}/transfer-credits", {
74
+ source_workspace_id: source_workspace_id,
75
+ amount: amount
76
+ })
77
+ end
78
+
79
+ def get_credits(workspace_id)
80
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
81
+
82
+ @client.get("/enterprise/workspaces/#{workspace_id}/credits")
83
+ end
84
+
85
+ def create_key(workspace_id, name: nil, type: nil)
86
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
87
+
88
+ body = {}
89
+ body[:name] = name if name
90
+ body[:type] = type if type
91
+
92
+ @client.post("/enterprise/workspaces/#{workspace_id}/keys", body)
93
+ end
94
+
95
+ def list_keys(workspace_id)
96
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
97
+
98
+ @client.get("/enterprise/workspaces/#{workspace_id}/keys")
99
+ end
100
+
101
+ def revoke_key(workspace_id, key_id)
102
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
103
+ raise ArgumentError, "Key ID is required" if key_id.nil? || key_id.empty?
104
+
105
+ @client.delete("/enterprise/workspaces/#{workspace_id}/keys/#{key_id}")
106
+ end
107
+
108
+ def list_opt_in_pages(workspace_id)
109
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
110
+
111
+ @client.get("/enterprise/workspaces/#{workspace_id}/opt-in-pages")
112
+ end
113
+
114
+ def create_opt_in_page(workspace_id, business_name:, use_case: nil, use_case_summary: nil, sample_messages: nil)
115
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
116
+ raise ArgumentError, "Business name is required" if business_name.nil? || business_name.strip.empty?
117
+
118
+ body = { businessName: business_name }
119
+ body[:useCase] = use_case if use_case
120
+ body[:useCaseSummary] = use_case_summary if use_case_summary
121
+ body[:sampleMessages] = sample_messages if sample_messages
122
+
123
+ @client.post("/enterprise/workspaces/#{workspace_id}/opt-in-pages", body)
124
+ end
125
+
126
+ def update_opt_in_page(workspace_id, page_id, logo_url: nil, header_color: nil, button_color: nil, custom_headline: nil, custom_benefits: nil)
127
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
128
+ raise ArgumentError, "Page ID is required" if page_id.nil? || page_id.empty?
129
+
130
+ body = {}
131
+ body[:logoUrl] = logo_url unless logo_url.nil?
132
+ body[:headerColor] = header_color unless header_color.nil?
133
+ body[:buttonColor] = button_color unless button_color.nil?
134
+ body[:customHeadline] = custom_headline unless custom_headline.nil?
135
+ body[:customBenefits] = custom_benefits unless custom_benefits.nil?
136
+
137
+ @client.patch("/enterprise/workspaces/#{workspace_id}/opt-in-pages/#{page_id}", body)
138
+ end
139
+
140
+ def delete_opt_in_page(workspace_id, page_id)
141
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
142
+ raise ArgumentError, "Page ID is required" if page_id.nil? || page_id.empty?
143
+
144
+ @client.delete("/enterprise/workspaces/#{workspace_id}/opt-in-pages/#{page_id}")
145
+ end
146
+
147
+ def set_webhook(workspace_id, url:, events: nil, description: nil)
148
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
149
+ raise ArgumentError, "Webhook URL is required" if url.nil? || url.empty?
150
+
151
+ body = { url: url }
152
+ body[:events] = events if events
153
+ body[:description] = description if description
154
+
155
+ @client.put("/enterprise/workspaces/#{workspace_id}/webhooks", body)
156
+ end
157
+
158
+ def list_webhooks(workspace_id)
159
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
160
+
161
+ @client.get("/enterprise/workspaces/#{workspace_id}/webhooks")
162
+ end
163
+
164
+ def delete_webhooks(workspace_id, webhook_id: nil)
165
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
166
+
167
+ path = "/enterprise/workspaces/#{workspace_id}/webhooks"
168
+ path += "?webhookId=#{webhook_id}" if webhook_id
169
+
170
+ @client.delete(path)
171
+ end
172
+
173
+ def test_webhook(workspace_id)
174
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
175
+
176
+ @client.post("/enterprise/workspaces/#{workspace_id}/webhooks/test")
177
+ end
178
+
179
+ def suspend(workspace_id, reason: nil)
180
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
181
+
182
+ body = {}
183
+ body[:reason] = reason if reason
184
+
185
+ @client.post("/enterprise/workspaces/#{workspace_id}/suspend", body)
186
+ end
187
+
188
+ def resume(workspace_id)
189
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
190
+
191
+ @client.post("/enterprise/workspaces/#{workspace_id}/resume")
192
+ end
193
+
194
+ def provision_bulk(workspaces)
195
+ raise ArgumentError, "Workspaces array is required" if workspaces.nil? || !workspaces.is_a?(Array) || workspaces.empty?
196
+ raise ArgumentError, "Maximum 50 workspaces per bulk provision" if workspaces.length > 50
197
+
198
+ @client.post("/enterprise/workspaces/provision/bulk", { workspaces: workspaces })
199
+ end
200
+
201
+ def set_custom_domain(workspace_id, page_id, domain:)
202
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
203
+ raise ArgumentError, "Page ID is required" if page_id.nil? || page_id.empty?
204
+ raise ArgumentError, "Domain is required" if domain.nil? || domain.empty?
205
+
206
+ @client.put("/enterprise/workspaces/#{workspace_id}/pages/#{page_id}/domain", { domain: domain })
207
+ end
208
+
209
+ def send_invitation(workspace_id, email:, role:)
210
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
211
+ raise ArgumentError, "Email is required" if email.nil? || email.empty?
212
+ raise ArgumentError, "Role is required" if role.nil? || role.empty?
213
+
214
+ @client.post("/enterprise/workspaces/#{workspace_id}/invitations", {
215
+ email: email,
216
+ role: role
217
+ })
218
+ end
219
+
220
+ def list_invitations(workspace_id)
221
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
222
+
223
+ @client.get("/enterprise/workspaces/#{workspace_id}/invitations")
224
+ end
225
+
226
+ def cancel_invitation(workspace_id, invite_id)
227
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
228
+ raise ArgumentError, "Invite ID is required" if invite_id.nil? || invite_id.empty?
229
+
230
+ @client.delete("/enterprise/workspaces/#{workspace_id}/invitations/#{invite_id}")
231
+ end
232
+
233
+ def get_quota(workspace_id)
234
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
235
+
236
+ @client.get("/enterprise/workspaces/#{workspace_id}/quota")
237
+ end
238
+
239
+ def set_quota(workspace_id, monthly_message_quota:)
240
+ raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
241
+
242
+ @client.put("/enterprise/workspaces/#{workspace_id}/quota", {
243
+ monthlyMessageQuota: monthly_message_quota
244
+ })
245
+ end
246
+ end
247
+
248
+ class EnterpriseWebhooksSubResource
249
+ def initialize(client)
250
+ @client = client
251
+ end
252
+
253
+ def set(url:)
254
+ raise ArgumentError, "Webhook URL is required" if url.nil? || url.empty?
255
+
256
+ @client.put("/enterprise/webhooks", { url: url })
257
+ end
258
+
259
+ def get
260
+ @client.get("/enterprise/webhooks")
261
+ end
262
+
263
+ def delete
264
+ @client.delete("/enterprise/webhooks")
265
+ end
266
+
267
+ def test
268
+ @client.post("/enterprise/webhooks/test")
269
+ end
270
+ end
271
+
272
+ class EnterpriseAnalyticsSubResource
273
+ def initialize(client)
274
+ @client = client
275
+ end
276
+
277
+ def overview
278
+ @client.get("/enterprise/analytics/overview")
279
+ end
280
+
281
+ def messages(period: nil, workspace_id: nil)
282
+ params = {}
283
+ params[:period] = period if period
284
+ params[:workspaceId] = workspace_id if workspace_id
285
+
286
+ @client.get("/enterprise/analytics/messages", params)
287
+ end
288
+
289
+ def delivery
290
+ @client.get("/enterprise/analytics/delivery")
291
+ end
292
+
293
+ def credits(period: nil)
294
+ params = {}
295
+ params[:period] = period if period
296
+
297
+ @client.get("/enterprise/analytics/credits", params)
298
+ end
299
+ end
300
+
301
+ class EnterpriseSettingsSubResource
302
+ def initialize(client)
303
+ @client = client
304
+ end
305
+
306
+ def get_auto_top_up
307
+ @client.get("/enterprise/settings/auto-top-up")
308
+ end
309
+
310
+ def update_auto_top_up(enabled:, threshold:, amount:, source_workspace_id: nil)
311
+ body = {
312
+ enabled: enabled,
313
+ threshold: threshold,
314
+ amount: amount
315
+ }
316
+ body[:sourceWorkspaceId] = source_workspace_id if source_workspace_id
317
+
318
+ @client.put("/enterprise/settings/auto-top-up", body)
319
+ end
320
+ end
321
+
322
+ class EnterpriseBillingSubResource
323
+ def initialize(client)
324
+ @client = client
325
+ end
326
+
327
+ def get_breakdown(period: nil, page: nil, limit: nil)
328
+ params = {}
329
+ params[:period] = period if period
330
+ params[:page] = page if page
331
+ params[:limit] = limit if limit
332
+
333
+ @client.get("/enterprise/billing/workspace-breakdown", params)
334
+ end
335
+ end
336
+
337
+ class EnterpriseResource
338
+ attr_reader :workspaces, :webhooks, :analytics, :settings, :billing
339
+
340
+ def initialize(client)
341
+ @client = client
342
+ @workspaces = EnterpriseWorkspacesSubResource.new(client)
343
+ @webhooks = EnterpriseWebhooksSubResource.new(client)
344
+ @analytics = EnterpriseAnalyticsSubResource.new(client)
345
+ @settings = EnterpriseSettingsSubResource.new(client)
346
+ @billing = EnterpriseBillingSubResource.new(client)
347
+ end
348
+
349
+ def get_account
350
+ @client.get("/enterprise/account")
351
+ end
352
+
353
+ def provision(name:, source_workspace_id: nil, inherit_with_new_number: nil, verification: nil, credit_amount: nil, credit_source_workspace_id: nil, key_name: nil, key_type: nil, webhook_url: nil, generate_opt_in_page: nil)
354
+ raise ArgumentError, "Workspace name is required" if name.nil? || name.strip.empty?
355
+
356
+ body = { name: name }
357
+ body[:sourceWorkspaceId] = source_workspace_id if source_workspace_id
358
+ body[:inheritWithNewNumber] = true if inherit_with_new_number
359
+ body[:verification] = verification if verification
360
+ body[:creditAmount] = credit_amount if credit_amount
361
+ body[:creditSourceWorkspaceId] = credit_source_workspace_id if credit_source_workspace_id
362
+ body[:keyName] = key_name if key_name
363
+ body[:keyType] = key_type if key_type
364
+ body[:webhookUrl] = webhook_url if webhook_url
365
+ body[:generateOptInPage] = generate_opt_in_page unless generate_opt_in_page.nil?
366
+
367
+ @client.post("/enterprise/workspaces/provision", body)
368
+ end
369
+ end
370
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.18.0"
4
+ VERSION = "3.18.2"
5
5
  end
@@ -12,14 +12,15 @@ module Sendly
12
12
  #
13
13
  # def handle
14
14
  # signature = request.headers['X-Sendly-Signature']
15
+ # timestamp = request.headers['X-Sendly-Timestamp']
15
16
  # payload = request.raw_post
16
17
  #
17
18
  # begin
18
- # event = Sendly::Webhooks.parse_event(payload, signature, ENV['WEBHOOK_SECRET'])
19
+ # event = Sendly::Webhooks.parse_event(payload, signature, ENV['WEBHOOK_SECRET'], timestamp: timestamp)
19
20
  #
20
21
  # case event.type
21
22
  # when 'message.delivered'
22
- # puts "Message delivered: #{event.data.message_id}"
23
+ # puts "Message delivered: #{event.data.id}"
23
24
  # when 'message.failed'
24
25
  # puts "Message failed: #{event.data.error}"
25
26
  # end
@@ -31,21 +32,30 @@ module Sendly
31
32
  # end
32
33
  # end
33
34
  module Webhooks
35
+ SIGNATURE_TOLERANCE_SECONDS = 300
36
+
34
37
  class << self
35
38
  # Verify webhook signature from Sendly.
36
39
  #
37
40
  # @param payload [String] Raw request body as string
38
41
  # @param signature [String] X-Sendly-Signature header value
39
42
  # @param secret [String] Your webhook secret from dashboard
43
+ # @param timestamp [String, nil] X-Sendly-Timestamp header value (recommended)
40
44
  # @return [Boolean] True if signature is valid, false otherwise
41
- def verify_signature(payload, signature, secret)
45
+ def verify_signature(payload, signature, secret, timestamp: nil)
42
46
  return false if payload.nil? || payload.empty?
43
47
  return false if signature.nil? || signature.empty?
44
48
  return false if secret.nil? || secret.empty?
45
49
 
46
- expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
50
+ if timestamp
51
+ signed_payload = "#{timestamp}.#{payload}"
52
+ return false if (Time.now.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE_SECONDS
53
+ else
54
+ signed_payload = payload
55
+ end
56
+
57
+ expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
47
58
 
48
- # Timing-safe comparison
49
59
  secure_compare(expected, signature)
50
60
  end
51
61
 
@@ -54,14 +64,17 @@ module Sendly
54
64
  # @param payload [String] Raw request body as string
55
65
  # @param signature [String] X-Sendly-Signature header value
56
66
  # @param secret [String] Your webhook secret from dashboard
67
+ # @param timestamp [String, nil] X-Sendly-Timestamp header value (recommended)
57
68
  # @return [WebhookEvent] Parsed and validated event
58
69
  # @raise [WebhookSignatureError] If signature is invalid or payload is malformed
59
- def parse_event(payload, signature, secret)
60
- raise WebhookSignatureError, 'Invalid webhook signature' unless verify_signature(payload, signature, secret)
70
+ def parse_event(payload, signature, secret, timestamp: nil)
71
+ unless verify_signature(payload, signature, secret, timestamp: timestamp)
72
+ raise WebhookSignatureError, 'Invalid webhook signature'
73
+ end
61
74
 
62
75
  data = JSON.parse(payload, symbolize_names: true)
63
76
 
64
- unless data[:id] && data[:type] && data[:data] && data[:created_at]
77
+ unless data[:id] && data[:type] && data[:data]
65
78
  raise WebhookSignatureError, 'Invalid event structure'
66
79
  end
67
80
 
@@ -74,14 +87,15 @@ module Sendly
74
87
  #
75
88
  # @param payload [String] The payload to sign
76
89
  # @param secret [String] The secret to use for signing
90
+ # @param timestamp [String, nil] Optional timestamp to include in signature
77
91
  # @return [String] The signature in the format "sha256=..."
78
- def generate_signature(payload, secret)
79
- 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, payload)
92
+ def generate_signature(payload, secret, timestamp: nil)
93
+ signed_payload = timestamp ? "#{timestamp}.#{payload}" : payload
94
+ 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
80
95
  end
81
96
 
82
97
  private
83
98
 
84
- # Timing-safe string comparison
85
99
  def secure_compare(a, b)
86
100
  return false unless a.bytesize == b.bytesize
87
101
 
@@ -93,23 +107,27 @@ module Sendly
93
107
  end
94
108
  end
95
109
 
96
- # Webhook signature verification error
97
110
  class WebhookSignatureError < Error
98
111
  def initialize(message = 'Invalid webhook signature')
99
112
  super(message, code: 'WEBHOOK_SIGNATURE_ERROR')
100
113
  end
101
114
  end
102
115
 
103
- # Webhook event from Sendly
104
116
  class WebhookEvent
105
- attr_reader :id, :type, :data, :created_at, :api_version
117
+ attr_reader :id, :type, :data, :created, :api_version, :livemode
106
118
 
107
119
  def initialize(data)
108
120
  @id = data[:id]
109
121
  @type = data[:type]
110
- @data = WebhookMessageData.new(data[:data])
111
- @created_at = data[:created_at]
112
- @api_version = data[:api_version] || '2024-01-01'
122
+ obj = data[:data][:object] || data[:data]
123
+ @data = WebhookMessageData.new(obj)
124
+ @created = data[:created] || data[:created_at] || 0
125
+ @api_version = data[:api_version] || '2024-01'
126
+ @livemode = data[:livemode] || false
127
+ end
128
+
129
+ def created_at
130
+ @created
113
131
  end
114
132
 
115
133
  def to_h
@@ -117,36 +135,48 @@ module Sendly
117
135
  id: @id,
118
136
  type: @type,
119
137
  data: @data.to_h,
120
- created_at: @created_at,
121
- api_version: @api_version
138
+ created: @created,
139
+ api_version: @api_version,
140
+ livemode: @livemode
122
141
  }
123
142
  end
124
143
  end
125
144
 
126
- # Webhook message data
127
145
  class WebhookMessageData
128
- attr_reader :message_id, :status, :to, :from, :error, :error_code,
129
- :delivered_at, :failed_at, :segments, :credits_used
146
+ attr_reader :id, :status, :to, :from, :direction, :organization_id,
147
+ :text, :error, :error_code, :delivered_at, :failed_at,
148
+ :created_at, :segments, :credits_used, :message_format, :media_urls
130
149
 
131
150
  def initialize(data)
132
- @message_id = data[:message_id]
151
+ @id = data[:id] || data[:message_id] || ''
133
152
  @status = data[:status]
134
153
  @to = data[:to]
135
154
  @from = data[:from] || ''
155
+ @direction = data[:direction] || 'outbound'
156
+ @organization_id = data[:organization_id]
157
+ @text = data[:text]
136
158
  @error = data[:error]
137
159
  @error_code = data[:error_code]
138
160
  @delivered_at = data[:delivered_at]
139
161
  @failed_at = data[:failed_at]
162
+ @created_at = data[:created_at]
140
163
  @segments = data[:segments] || 1
141
164
  @credits_used = data[:credits_used] || 0
165
+ @message_format = data[:message_format]
166
+ @media_urls = data[:media_urls]
167
+ end
168
+
169
+ def message_id
170
+ @id
142
171
  end
143
172
 
144
173
  def to_h
145
174
  {
146
- message_id: @message_id,
175
+ id: @id,
147
176
  status: @status,
148
177
  to: @to,
149
178
  from: @from,
179
+ direction: @direction,
150
180
  error: @error,
151
181
  error_code: @error_code,
152
182
  delivered_at: @delivered_at,
data/lib/sendly.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "sendly/verify"
16
16
  require_relative "sendly/templates_resource"
17
17
  require_relative "sendly/campaigns_resource"
18
18
  require_relative "sendly/contacts_resource"
19
+ require_relative "sendly/enterprise"
19
20
 
20
21
  # Sendly Ruby SDK
21
22
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendly
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.18.0
4
+ version: 3.18.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-26 00:00:00.000000000 Z
11
+ date: 2026-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -117,6 +117,7 @@ extensions: []
117
117
  extra_rdoc_files: []
118
118
  files:
119
119
  - ".ruby-version"
120
+ - CHANGELOG.md
120
121
  - Gemfile
121
122
  - Gemfile.lock
122
123
  - README.md
@@ -127,6 +128,7 @@ files:
127
128
  - lib/sendly/campaigns_resource.rb
128
129
  - lib/sendly/client.rb
129
130
  - lib/sendly/contacts_resource.rb
131
+ - lib/sendly/enterprise.rb
130
132
  - lib/sendly/errors.rb
131
133
  - lib/sendly/media.rb
132
134
  - lib/sendly/messages.rb