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 +4 -4
- data/CHANGELOG.md +48 -0
- data/Gemfile.lock +2 -2
- data/README.md +68 -0
- data/lib/sendly/client.rb +18 -0
- data/lib/sendly/enterprise.rb +370 -0
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/webhooks.rb +54 -24
- data/lib/sendly.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bade33a147b49cd340671aaf86a187388360ba9c2e8d58fa0d51b6b486620bb8
|
|
4
|
+
data.tar.gz: eb35d212b0466da3fe3ae102e80a8314a0fd6081f8a8e1c89e680ad715c7fb7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
data/lib/sendly/version.rb
CHANGED
data/lib/sendly/webhooks.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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, :
|
|
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
|
-
|
|
111
|
-
@
|
|
112
|
-
@
|
|
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
|
-
|
|
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 :
|
|
129
|
-
:
|
|
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
|
-
@
|
|
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
|
-
|
|
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
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.
|
|
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-
|
|
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
|