sendly 3.40.0 → 4.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91dd6ee6db6b506dea8d70b085fc6a069330e9ddca10b1de8e097b3db07889cd
4
- data.tar.gz: 3114725bc7d45361b625ba7c7cfc88b6a6eee0672154b3b3daf46237cb475b53
3
+ metadata.gz: 40344d4fa41e8c72544b0b5327b7bc4d717285c38d244397bf0f93ad21ce5b1a
4
+ data.tar.gz: d0bf2bd2c6256ccdbdea1f8c9319c5891b5a030b798a091e7afc59d33752b543
5
5
  SHA512:
6
- metadata.gz: a8728f247769f3bbd8ad1713a86814eb419c944d64f06797ed94f858b543185d4c1baced35ba9aa910871b04a15e0348aece222a4e88de691e717df9b5542c01
7
- data.tar.gz: ba2ad16077e383a0e37b7dee97ebada69cd3c1e815f6775afb677ab331a031da2f643666da3e95cc2b90e0dbff3478a6d35bc9a0364efd9b865c1ff5302f25fd
6
+ metadata.gz: 20aa959ad9bd7f630c1e26b0a511648aecbfb02d095a789d2d17500e2e035eb02b79fad1c9ae6dc9e0959c41dda68d73c97b1a3d1a795f2dedacd41f8ef03262
7
+ data.tar.gz: 70f56c72f0b628b86e5ea964de3653b7e16d00fa9c4a5214ef14038dbf3da30feedf84b3b489bbfdfd316c42d70f4116ff6b914fe358d7204498d41601a8213a
data/CHANGELOG.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # sendly (Ruby)
2
2
 
3
- ## Unreleased
3
+ ## 4.0.0
4
+
5
+ **Upgrading from 3.40.0:** that release already contained the breaking changes below, published by mistake as a minor version. 4.0.0 carries them under the correct major. Relative to 3.40.0, the only new changes are under **Security**.
4
6
 
5
7
  ### Breaking Changes
6
8
 
@@ -22,6 +24,120 @@
22
24
 
23
25
  - **`id` is no longer filled from an unrelated `id` key.** `contact.auto_flagged` carries the contact under `id` and the message that failed under `message_id`, so `event.data.message_id` returned the *contact* id — a handler that marked that message failed acted on the wrong row. Contact events have no message view at all now; read the message with `event.data[:message_id]`.
24
26
 
27
+ ### Migrating from 3.x
28
+
29
+ Nothing outside webhook handling changed. The client, every resource and every
30
+ `message.*` handler you already have keep working as written; the list below is
31
+ the whole edit.
32
+
33
+ **1. Reading a message field off a lifecycle event now raises.** 3.x decoded
34
+ every `data.object` into a `Sendly::WebhookMessageData`, so an `rcs_agent.live`
35
+ handler that asked for `event.data.from` was handed `""`, `event.data.segments`
36
+ `1` and `event.data.credits_used` `0` — values the event never carried, not
37
+ distinguishable from real ones, and no error was raised. The same calls raise
38
+ `NoMethodError` in 4.0, naming the keys that did arrive. That failure is the
39
+ point of this release rather than an accident of it: the value it replaces was
40
+ wrong, and silently so.
41
+
42
+ ```ruby
43
+ # 3.x — silently wrong, on every RCS event
44
+ event.data.from # => ""
45
+ event.data.segments # => 1
46
+
47
+ # 4.0 — the same call
48
+ event.data.from
49
+ # => NoMethodError: undefined method 'from' for Sendly::WebhookObject:
50
+ # this event's data.object carries agent_id, name, stage, organization_id
51
+
52
+ # 4.0 — the edit: read the object the event actually carries
53
+ event.data[:agent_id] # => "bb22cc33-dd44-4e55-9f66-001122334455"
54
+ event.data.stage # => "live"
55
+ event.raw_object # => the whole data.object Hash, untouched
56
+ ```
57
+
58
+ **2. `event.data` is a message view only on `message.*` events.** In 3.x it was
59
+ a `WebhookMessageData` whatever the event was, so `event.data.id` and
60
+ `event.data.status` answered for anything — with the payload's value when the
61
+ key happened to exist, and with `''` or `nil` when it did not. In 4.0 a
62
+ lifecycle event's `event.data` is a `Sendly::WebhookObject`: the keys the
63
+ payload carried are readable and nothing else is. `event.message` is new in 4.0
64
+ and is the message view; it is `nil` outside `message.*`, so guard it with
65
+ `event.message?` rather than calling into it unconditionally.
66
+
67
+ ```ruby
68
+ # 3.x — answered for every event type; "" when the payload had no id
69
+ mark_delivered(event.data.id)
70
+
71
+ # 4.0 — split the branches
72
+ case event.type
73
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
74
+ mark_delivered(event.message.id) # same value as 3.x
75
+ when Sendly::Webhooks::EVENT_NUMBER_ACTIVATED
76
+ number_active(event.data[:id]) # the number's id, as it always was
77
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
78
+ agent_went_live(event.data[:agent_id]) # unreachable in 3.x
79
+ end
80
+ ```
81
+
82
+ **3. `contact.auto_flagged` no longer reports the contact as the message.** The
83
+ payload carries the contact under `id` and the message that failed under
84
+ `message_id`; 3.x filled the message view's `id` from the contact, so a handler
85
+ that marked "the message" failed acted on the wrong record.
86
+
87
+ ```ruby
88
+ # 3.x — the CONTACT id, presented as a message id
89
+ event.data.message_id # => "ct_9"
90
+
91
+ # 4.0
92
+ event.data[:id] # => "ct_9" the contact
93
+ event.data[:message_id] # => "msg_77" the message that failed
94
+ event.message # => nil
95
+ ```
96
+
97
+ **4. Message fields the payload omitted are `nil`, not a default.** On a
98
+ `message.*` event the readers still exist, so this raises nothing — it changes
99
+ what you get. `key?` separates "absent" from "arrived as null".
100
+
101
+ ```ruby
102
+ # message.delivered, on a payload that carried no segments
103
+ event.data.segments # 3.x => 1 4.0 => nil
104
+ event.data.credits_used # 3.x => 0 4.0 => nil
105
+ event.data.direction # 3.x => "outbound" 4.0 => nil
106
+ event.data.key?(:segments) # => false when absent, true when it arrived as null
107
+ ```
108
+
109
+ **5. `event.data.to_h` is now `data.object` as it arrived.** It used to be a
110
+ compacted subset of the typed message fields, dropping `text`, `metadata`,
111
+ `media_urls`, `message_format` and `organization_id` and renaming `message_id`
112
+ to `id`. Code that persisted `to_h` will start seeing the full object.
113
+
114
+ ```ruby
115
+ event.data.to_h == event.raw_object # => true, for every event type
116
+ ```
117
+
118
+ **6. If you want a typed object for a lifecycle event, ask for one.**
119
+ `#object_as` fills a Struct or Data class from the members it declares and
120
+ ignores the rest of the payload, so a field added to the event later cannot
121
+ break the call.
122
+
123
+ ```ruby
124
+ AgentLive = Struct.new(:agent_id, :name, :stage)
125
+ agent = event.object_as(AgentLive)
126
+ agent.stage # => "live"
127
+ ```
128
+
129
+ ### Deprecations
130
+
131
+ - **`Sendly::Webhooks::EVENT_MESSAGE_QUEUED` is deprecated.** The API has never
132
+ emitted `message.queued`, and subscribing to it fails: `client.webhooks.create`
133
+ and `client.webhooks.update` reject any event outside the API's list with a
134
+ 400, raised here as `Sendly::ValidationError`. The constant stays exported in
135
+ 4.0 so existing code still loads, and will be removed in the next major.
136
+ `message.undelivered` is rejected on subscribe the same way and has never had
137
+ a constant in this SDK. Subscribe to `message.sent`, `message.failed` and
138
+ `message.bounced` instead. Every other `EVENT_*` constant matches the API's
139
+ list exactly.
140
+
25
141
  ### Minor Changes
26
142
 
27
143
  - **`Sendly::WebhookEvent#raw_object`** carries `data.object` exactly as it arrived, for every event type, and **`#object_as(klass)`** reads it as a type of your choosing (`event.object_as(AgentLive)`). `#object` is an alias for `raw_object`.
@@ -48,6 +164,11 @@
48
164
 
49
165
  - **`Sendly::ValidationError#field_errors` carries the API's `errors` list** (`[{ "path", "message" }, ...]`) when a 400 or 422 response includes one, instead of always being `nil`. RCS registration uses it to say which brand, agent, campaign or device field needs attention.
50
166
 
167
+
168
+ ### Security
169
+
170
+ - **Path parameters are percent-encoded.** Every id you pass is now encoded (`URI.encode_www_form_component`) before it goes into the request path. An id containing `/`, `?` or `#` used to change which endpoint the request reached: an id of `../../account/keys` left its collection and hit another endpoint carrying your API key. Ordinary ids are sent byte-for-byte as before.
171
+
51
172
  ## 3.38.0
52
173
 
53
174
  ### Minor Changes
data/Gemfile.lock CHANGED
@@ -1,14 +1,14 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.40.0)
4
+ sendly (4.0.0)
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.9.0)
11
+ addressable (2.8.8)
12
12
  public_suffix (>= 2.0.2, < 8.0)
13
13
  ast (2.4.3)
14
14
  bigdecimal (3.3.1)
@@ -74,7 +74,7 @@ GEM
74
74
  unicode-emoji (~> 4.1)
75
75
  unicode-emoji (4.1.0)
76
76
  uri (1.1.1)
77
- webmock (3.26.4)
77
+ webmock (3.26.1)
78
78
  addressable (>= 2.8.0)
79
79
  crack (>= 0.3.2)
80
80
  hashdiff (>= 0.4.0, < 2.0.0)
data/README.md CHANGED
@@ -342,6 +342,16 @@ rotation = client.webhooks.rotate_secret("whk_xxx")
342
342
  client.webhooks.delete("whk_xxx")
343
343
  ```
344
344
 
345
+ Subscribe with the `Sendly::Webhooks::EVENT_*` constants rather than string
346
+ literals — a typo then fails at load time instead of in a 400. Every constant
347
+ but one names an event the API accepts on subscribe. The exception is
348
+ `EVENT_MESSAGE_QUEUED`: `message.queued` has never been emitted and is
349
+ rejected on subscribe with a 400 (`Sendly::ValidationError`). It is
350
+ deprecated, kept only so existing code still loads, and will be removed in the
351
+ next major. `message.undelivered` is rejected in the same way and has never had
352
+ a constant here. Subscribe to `message.sent`, `message.failed` and
353
+ `message.bounced` instead.
354
+
345
355
  ### Receiving events
346
356
 
347
357
  `Sendly::Webhooks.parse_event` verifies the signature and returns a
@@ -403,6 +413,66 @@ Note that `contact.auto_flagged` carries the contact under `id` and the message
403
413
  that failed under `message_id`; read the message with
404
414
  `event.data[:message_id]`.
405
415
 
416
+ ### Handling a lifecycle event
417
+
418
+ Only `message.*` events carry a message. A lifecycle event — `rcs_*`,
419
+ `whatsapp_*`, `call.*`, `brand.*`, `campaign.*`, `assignment.*`, `number.*`,
420
+ `port*`, `contact*`, `conversation.*`, `draft.*` — carries a different object,
421
+ so `event.message` is `nil` and you read `data.object` off `event.data` or
422
+ `event.raw_object`.
423
+
424
+ ```ruby
425
+ require "sendly"
426
+
427
+ # Framework-neutral: hand it the raw request body and a headers Hash.
428
+ def handle_sendly_webhook(raw_body, headers)
429
+ event = Sendly::Webhooks.parse_event(
430
+ raw_body,
431
+ headers["X-Sendly-Signature"],
432
+ ENV.fetch("SENDLY_WEBHOOK_SECRET"),
433
+ timestamp: headers["X-Sendly-Timestamp"]
434
+ )
435
+
436
+ case event.type
437
+ when Sendly::Webhooks::EVENT_RCS_AGENT_LIVE
438
+ # data.object is the agent: agent_id, name, stage
439
+ puts "RCS agent #{event.data[:name]} is #{event.data.stage}"
440
+ puts event.data[:agent_id]
441
+ when Sendly::Webhooks::EVENT_NUMBER_ACTIVATED
442
+ # data.object is the number: id, phone, status, country_code, source
443
+ puts "#{event.data[:phone]} active in #{event.data[:country_code]}"
444
+ when Sendly::Webhooks::EVENT_CONTACT_AUTO_FLAGGED
445
+ # the contact is `id`; the message that failed is `message_id`
446
+ puts "flagged #{event.data[:id]} (#{event.data[:invalid_reason]})"
447
+ puts "from message #{event.data[:message_id]}"
448
+ when Sendly::Webhooks::EVENT_MESSAGE_DELIVERED
449
+ # message.* events, and only these, also get the typed message view
450
+ puts "#{event.message.id} delivered to #{event.message.to}"
451
+ else
452
+ # An event type this SDK predates still parses; raw_object holds all of it.
453
+ puts "unhandled #{event.type}: #{event.raw_object.inspect}"
454
+ end
455
+
456
+ :ok
457
+ rescue Sendly::WebhookSignatureError
458
+ :unauthorized
459
+ end
460
+ ```
461
+
462
+ Reading a message field off a lifecycle event fails loudly rather than
463
+ answering with a value the event never carried:
464
+
465
+ ```ruby
466
+ event.data.from
467
+ # => NoMethodError: undefined method 'from' for Sendly::WebhookObject:
468
+ # this event's data.object carries agent_id, name, stage, organization_id
469
+ event.message # => nil
470
+ event.message.id # => NoMethodError — nil has no #id
471
+ ```
472
+
473
+ Branch on `event.type`, or on `event.message?` / `event.verification?`, before
474
+ reaching for a typed view.
475
+
406
476
  ## Account & Credits
407
477
 
408
478
  ```ruby
@@ -55,7 +55,7 @@ module Sendly
55
55
  # @param key_id [String] API key ID
56
56
  # @return [Sendly::ApiKey]
57
57
  def api_key(key_id)
58
- response = @client.get("/account/keys/#{key_id}")
58
+ response = @client.get("/account/keys/#{URI.encode_www_form_component(key_id)}")
59
59
  ApiKey.new(response)
60
60
  end
61
61
 
@@ -64,7 +64,7 @@ module Sendly
64
64
  # @param key_id [String] API key ID
65
65
  # @return [Hash] Usage statistics
66
66
  def api_key_usage(key_id)
67
- @client.get("/account/keys/#{key_id}/usage")
67
+ @client.get("/account/keys/#{URI.encode_www_form_component(key_id)}/usage")
68
68
  end
69
69
 
70
70
  # Create a new API key
@@ -96,7 +96,7 @@ module Sendly
96
96
  body = {}
97
97
  body[:reason] = reason if reason
98
98
 
99
- @client.patch("/account/keys/#{key_id}/revoke", body)
99
+ @client.patch("/account/keys/#{URI.encode_www_form_component(key_id)}/revoke", body)
100
100
  end
101
101
 
102
102
  # Rotate an API key.
@@ -127,7 +127,7 @@ module Sendly
127
127
  body = {}
128
128
  body[:gracePeriodHours] = grace_period_hours unless grace_period_hours.nil?
129
129
 
130
- @client.post("/account/keys/#{key_id}/rotate", body)
130
+ @client.post("/account/keys/#{URI.encode_www_form_component(key_id)}/rotate", body)
131
131
  end
132
132
 
133
133
  def transfer_credits(target_organization_id:, amount:)
@@ -131,7 +131,7 @@ module Sendly
131
131
  end
132
132
 
133
133
  def get(id)
134
- response = @client.get("/campaigns/#{id}")
134
+ response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}")
135
135
  Campaign.new(response)
136
136
  end
137
137
 
@@ -142,21 +142,21 @@ module Sendly
142
142
  body[:templateId] = template_id unless template_id.nil?
143
143
  body[:contactListIds] = contact_list_ids if contact_list_ids
144
144
 
145
- response = @client.patch("/campaigns/#{id}", body)
145
+ response = @client.patch("/campaigns/#{URI.encode_www_form_component(id)}", body)
146
146
  Campaign.new(response)
147
147
  end
148
148
 
149
149
  def delete(id)
150
- @client.delete("/campaigns/#{id}")
150
+ @client.delete("/campaigns/#{URI.encode_www_form_component(id)}")
151
151
  end
152
152
 
153
153
  def preview(id)
154
- response = @client.get("/campaigns/#{id}/preview")
154
+ response = @client.get("/campaigns/#{URI.encode_www_form_component(id)}/preview")
155
155
  CampaignPreview.new(response)
156
156
  end
157
157
 
158
158
  def send_campaign(id)
159
- response = @client.post("/campaigns/#{id}/send")
159
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/send")
160
160
  Campaign.new(response)
161
161
  end
162
162
 
@@ -164,17 +164,17 @@ module Sendly
164
164
  body = { scheduledAt: scheduled_at }
165
165
  body[:timezone] = timezone if timezone
166
166
 
167
- response = @client.post("/campaigns/#{id}/schedule", body)
167
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/schedule", body)
168
168
  Campaign.new(response)
169
169
  end
170
170
 
171
171
  def cancel(id)
172
- response = @client.post("/campaigns/#{id}/cancel")
172
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/cancel")
173
173
  Campaign.new(response)
174
174
  end
175
175
 
176
176
  def clone(id)
177
- response = @client.post("/campaigns/#{id}/clone")
177
+ response = @client.post("/campaigns/#{URI.encode_www_form_component(id)}/clone")
178
178
  Campaign.new(response)
179
179
  end
180
180
  end
@@ -114,7 +114,7 @@ module Sendly
114
114
  params[:limit] = limit if limit
115
115
  params[:offset] = offset if offset
116
116
 
117
- response = @client.get("/contact-lists/#{id}", params)
117
+ response = @client.get("/contact-lists/#{URI.encode_www_form_component(id)}", params)
118
118
  ContactList.new(response)
119
119
  end
120
120
 
@@ -131,21 +131,21 @@ module Sendly
131
131
  body[:name] = name if name
132
132
  body[:description] = description unless description.nil?
133
133
 
134
- response = @client.patch("/contact-lists/#{id}", body)
134
+ response = @client.patch("/contact-lists/#{URI.encode_www_form_component(id)}", body)
135
135
  ContactList.new(response)
136
136
  end
137
137
 
138
138
  def delete(id)
139
- @client.delete("/contact-lists/#{id}")
139
+ @client.delete("/contact-lists/#{URI.encode_www_form_component(id)}")
140
140
  end
141
141
 
142
142
  def add_contacts(list_id, contact_ids)
143
- response = @client.post("/contact-lists/#{list_id}/contacts", { contact_ids: contact_ids })
143
+ response = @client.post("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts", { contact_ids: contact_ids })
144
144
  { added_count: response["added_count"] || response["addedCount"] }
145
145
  end
146
146
 
147
147
  def remove_contact(list_id, contact_id)
148
- @client.delete("/contact-lists/#{list_id}/contacts/#{contact_id}")
148
+ @client.delete("/contact-lists/#{URI.encode_www_form_component(list_id)}/contacts/#{URI.encode_www_form_component(contact_id)}")
149
149
  end
150
150
  end
151
151
 
@@ -175,7 +175,7 @@ module Sendly
175
175
  end
176
176
 
177
177
  def get(id)
178
- response = @client.get("/contacts/#{id}")
178
+ response = @client.get("/contacts/#{URI.encode_www_form_component(id)}")
179
179
  Contact.new(response)
180
180
  end
181
181
 
@@ -195,12 +195,12 @@ module Sendly
195
195
  body[:email] = email unless email.nil?
196
196
  body[:metadata] = metadata unless metadata.nil?
197
197
 
198
- response = @client.patch("/contacts/#{id}", body)
198
+ response = @client.patch("/contacts/#{URI.encode_www_form_component(id)}", body)
199
199
  Contact.new(response)
200
200
  end
201
201
 
202
202
  def delete(id)
203
- @client.delete("/contacts/#{id}")
203
+ @client.delete("/contacts/#{URI.encode_www_form_component(id)}")
204
204
  end
205
205
 
206
206
  # Clear the invalid flag on a contact so future campaigns include it again.
@@ -208,7 +208,7 @@ module Sendly
208
208
  # error (landline, invalid number) or when a carrier lookup reports they
209
209
  # can't receive SMS.
210
210
  def mark_valid(id)
211
- response = @client.post("/contacts/#{id}/mark-valid", {})
211
+ response = @client.post("/contacts/#{URI.encode_www_form_component(id)}/mark-valid", {})
212
212
  Contact.new(response)
213
213
  end
214
214
 
@@ -22,13 +22,13 @@ module Sendly
22
22
  def get(workspace_id)
23
23
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
24
24
 
25
- @client.get("/enterprise/workspaces/#{workspace_id}")
25
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}")
26
26
  end
27
27
 
28
28
  def delete(workspace_id)
29
29
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
30
30
 
31
- @client.delete("/enterprise/workspaces/#{workspace_id}")
31
+ @client.delete("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}")
32
32
  end
33
33
 
34
34
  # Submit (or resubmit) a verification for an enterprise workspace.
@@ -89,7 +89,7 @@ module Sendly
89
89
  body[:privacyUrl] = privacy_url unless privacy_url.nil?
90
90
  body[:termsUrl] = terms_url unless terms_url.nil?
91
91
 
92
- @client.post("/enterprise/workspaces/#{workspace_id}/verification/submit", body)
92
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/verification/submit", body)
93
93
  end
94
94
 
95
95
  # Convenience alias for resubmits. Identical to +submit_verification+
@@ -106,7 +106,7 @@ module Sendly
106
106
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
107
107
  raise ArgumentError, "Source workspace ID is required" if source_workspace_id.nil? || source_workspace_id.empty?
108
108
 
109
- @client.post("/enterprise/workspaces/#{workspace_id}/verification/inherit", {
109
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/verification/inherit", {
110
110
  source_workspace_id: source_workspace_id
111
111
  })
112
112
  end
@@ -114,7 +114,7 @@ module Sendly
114
114
  def get_verification(workspace_id)
115
115
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
116
116
 
117
- @client.get("/enterprise/workspaces/#{workspace_id}/verification")
117
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/verification")
118
118
  end
119
119
 
120
120
  def transfer_credits(workspace_id, source_workspace_id:, amount:)
@@ -122,7 +122,7 @@ module Sendly
122
122
  raise ArgumentError, "Source workspace ID is required" if source_workspace_id.nil? || source_workspace_id.empty?
123
123
  raise ArgumentError, "Amount must be a positive number" if !amount.is_a?(Integer) || amount <= 0
124
124
 
125
- @client.post("/enterprise/workspaces/#{workspace_id}/transfer-credits", {
125
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/transfer-credits", {
126
126
  source_workspace_id: source_workspace_id,
127
127
  amount: amount
128
128
  })
@@ -131,7 +131,7 @@ module Sendly
131
131
  def get_credits(workspace_id)
132
132
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
133
133
 
134
- @client.get("/enterprise/workspaces/#{workspace_id}/credits")
134
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/credits")
135
135
  end
136
136
 
137
137
  def create_key(workspace_id, name: nil, type: nil)
@@ -141,26 +141,26 @@ module Sendly
141
141
  body[:name] = name if name
142
142
  body[:type] = type if type
143
143
 
144
- @client.post("/enterprise/workspaces/#{workspace_id}/keys", body)
144
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/keys", body)
145
145
  end
146
146
 
147
147
  def list_keys(workspace_id)
148
148
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
149
149
 
150
- @client.get("/enterprise/workspaces/#{workspace_id}/keys")
150
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/keys")
151
151
  end
152
152
 
153
153
  def revoke_key(workspace_id, key_id)
154
154
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
155
155
  raise ArgumentError, "Key ID is required" if key_id.nil? || key_id.empty?
156
156
 
157
- @client.delete("/enterprise/workspaces/#{workspace_id}/keys/#{key_id}")
157
+ @client.delete("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/keys/#{URI.encode_www_form_component(key_id)}")
158
158
  end
159
159
 
160
160
  def list_opt_in_pages(workspace_id)
161
161
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
162
162
 
163
- @client.get("/enterprise/workspaces/#{workspace_id}/opt-in-pages")
163
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/opt-in-pages")
164
164
  end
165
165
 
166
166
  def create_opt_in_page(workspace_id, business_name:, use_case: nil, use_case_summary: nil, sample_messages: nil)
@@ -172,7 +172,7 @@ module Sendly
172
172
  body[:useCaseSummary] = use_case_summary if use_case_summary
173
173
  body[:sampleMessages] = sample_messages if sample_messages
174
174
 
175
- @client.post("/enterprise/workspaces/#{workspace_id}/opt-in-pages", body)
175
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/opt-in-pages", body)
176
176
  end
177
177
 
178
178
  def update_opt_in_page(workspace_id, page_id, logo_url: nil, header_color: nil, button_color: nil, custom_headline: nil, custom_benefits: nil)
@@ -186,14 +186,14 @@ module Sendly
186
186
  body[:customHeadline] = custom_headline unless custom_headline.nil?
187
187
  body[:customBenefits] = custom_benefits unless custom_benefits.nil?
188
188
 
189
- @client.patch("/enterprise/workspaces/#{workspace_id}/opt-in-pages/#{page_id}", body)
189
+ @client.patch("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/opt-in-pages/#{URI.encode_www_form_component(page_id)}", body)
190
190
  end
191
191
 
192
192
  def delete_opt_in_page(workspace_id, page_id)
193
193
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
194
194
  raise ArgumentError, "Page ID is required" if page_id.nil? || page_id.empty?
195
195
 
196
- @client.delete("/enterprise/workspaces/#{workspace_id}/opt-in-pages/#{page_id}")
196
+ @client.delete("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/opt-in-pages/#{URI.encode_www_form_component(page_id)}")
197
197
  end
198
198
 
199
199
  def set_webhook(workspace_id, url:, events: nil, description: nil)
@@ -204,19 +204,19 @@ module Sendly
204
204
  body[:events] = events if events
205
205
  body[:description] = description if description
206
206
 
207
- @client.put("/enterprise/workspaces/#{workspace_id}/webhooks", body)
207
+ @client.put("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/webhooks", body)
208
208
  end
209
209
 
210
210
  def list_webhooks(workspace_id)
211
211
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
212
212
 
213
- @client.get("/enterprise/workspaces/#{workspace_id}/webhooks")
213
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/webhooks")
214
214
  end
215
215
 
216
216
  def delete_webhooks(workspace_id, webhook_id: nil)
217
217
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
218
218
 
219
- path = "/enterprise/workspaces/#{workspace_id}/webhooks"
219
+ path = "/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/webhooks"
220
220
  path += "?webhookId=#{webhook_id}" if webhook_id
221
221
 
222
222
  @client.delete(path)
@@ -225,7 +225,7 @@ module Sendly
225
225
  def test_webhook(workspace_id)
226
226
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
227
227
 
228
- @client.post("/enterprise/workspaces/#{workspace_id}/webhooks/test")
228
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/webhooks/test")
229
229
  end
230
230
 
231
231
  def suspend(workspace_id, reason: nil)
@@ -234,13 +234,13 @@ module Sendly
234
234
  body = {}
235
235
  body[:reason] = reason if reason
236
236
 
237
- @client.post("/enterprise/workspaces/#{workspace_id}/suspend", body)
237
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/suspend", body)
238
238
  end
239
239
 
240
240
  def resume(workspace_id)
241
241
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
242
242
 
243
- @client.post("/enterprise/workspaces/#{workspace_id}/resume")
243
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/resume")
244
244
  end
245
245
 
246
246
  def provision_bulk(workspaces)
@@ -255,7 +255,7 @@ module Sendly
255
255
  raise ArgumentError, "Page ID is required" if page_id.nil? || page_id.empty?
256
256
  raise ArgumentError, "Domain is required" if domain.nil? || domain.empty?
257
257
 
258
- @client.put("/enterprise/workspaces/#{workspace_id}/pages/#{page_id}/domain", { domain: domain })
258
+ @client.put("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/pages/#{URI.encode_www_form_component(page_id)}/domain", { domain: domain })
259
259
  end
260
260
 
261
261
  def send_invitation(workspace_id, email:, role:)
@@ -263,7 +263,7 @@ module Sendly
263
263
  raise ArgumentError, "Email is required" if email.nil? || email.empty?
264
264
  raise ArgumentError, "Role is required" if role.nil? || role.empty?
265
265
 
266
- @client.post("/enterprise/workspaces/#{workspace_id}/invitations", {
266
+ @client.post("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/invitations", {
267
267
  email: email,
268
268
  role: role
269
269
  })
@@ -272,26 +272,26 @@ module Sendly
272
272
  def list_invitations(workspace_id)
273
273
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
274
274
 
275
- @client.get("/enterprise/workspaces/#{workspace_id}/invitations")
275
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/invitations")
276
276
  end
277
277
 
278
278
  def cancel_invitation(workspace_id, invite_id)
279
279
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
280
280
  raise ArgumentError, "Invite ID is required" if invite_id.nil? || invite_id.empty?
281
281
 
282
- @client.delete("/enterprise/workspaces/#{workspace_id}/invitations/#{invite_id}")
282
+ @client.delete("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/invitations/#{URI.encode_www_form_component(invite_id)}")
283
283
  end
284
284
 
285
285
  def get_quota(workspace_id)
286
286
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
287
287
 
288
- @client.get("/enterprise/workspaces/#{workspace_id}/quota")
288
+ @client.get("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/quota")
289
289
  end
290
290
 
291
291
  def set_quota(workspace_id, monthly_message_quota:)
292
292
  raise ArgumentError, "Workspace ID is required" if workspace_id.nil? || workspace_id.empty?
293
293
 
294
- @client.put("/enterprise/workspaces/#{workspace_id}/quota", {
294
+ @client.put("/enterprise/workspaces/#{URI.encode_www_form_component(workspace_id)}/quota", {
295
295
  monthlyMessageQuota: monthly_message_quota
296
296
  })
297
297
  end
@@ -129,7 +129,7 @@ module Sendly
129
129
  end
130
130
 
131
131
  def get(id)
132
- response = @client.get("/templates/#{id}")
132
+ response = @client.get("/templates/#{URI.encode_www_form_component(id)}")
133
133
  Template.new(response)
134
134
  end
135
135
 
@@ -198,16 +198,16 @@ module Sendly
198
198
  request_body[:name] = name if name
199
199
  request_body[:text] = content if content
200
200
 
201
- response = @client.patch("/templates/#{id}", request_body)
201
+ response = @client.patch("/templates/#{URI.encode_www_form_component(id)}", request_body)
202
202
  Template.new(response)
203
203
  end
204
204
 
205
205
  def delete(id)
206
- @client.delete("/templates/#{id}")
206
+ @client.delete("/templates/#{URI.encode_www_form_component(id)}")
207
207
  end
208
208
 
209
209
  def publish(id)
210
- response = @client.post("/templates/#{id}/publish")
210
+ response = @client.post("/templates/#{URI.encode_www_form_component(id)}/publish")
211
211
  Template.new(response)
212
212
  end
213
213
 
@@ -217,7 +217,7 @@ module Sendly
217
217
  # this call fails with a 404. To retire a published template today,
218
218
  # {#create} and {#publish} a replacement, then {#delete} this one.
219
219
  def unpublish(id)
220
- response = @client.post("/templates/#{id}/unpublish")
220
+ response = @client.post("/templates/#{URI.encode_www_form_component(id)}/unpublish")
221
221
  Template.new(response)
222
222
  end
223
223
 
@@ -229,7 +229,7 @@ module Sendly
229
229
  def clone(id, name: nil)
230
230
  body = {}
231
231
  body[:name] = name if name
232
- response = @client.post("/templates/#{id}/clone", body)
232
+ response = @client.post("/templates/#{URI.encode_www_form_component(id)}/clone", body)
233
233
  Template.new(response)
234
234
  end
235
235
 
data/lib/sendly/verify.rb CHANGED
@@ -181,17 +181,17 @@ module Sendly
181
181
  end
182
182
 
183
183
  def resend(id)
184
- response = @client.post("/verify/#{id}/resend")
184
+ response = @client.post("/verify/#{URI.encode_www_form_component(id)}/resend")
185
185
  SendVerificationResponse.new(response)
186
186
  end
187
187
 
188
188
  def check(id, code:)
189
- response = @client.post("/verify/#{id}/check", { code: code })
189
+ response = @client.post("/verify/#{URI.encode_www_form_component(id)}/check", { code: code })
190
190
  CheckVerificationResponse.new(response)
191
191
  end
192
192
 
193
193
  def get(id)
194
- response = @client.get("/verify/#{id}")
194
+ response = @client.get("/verify/#{URI.encode_www_form_component(id)}")
195
195
  Verification.new(response)
196
196
  end
197
197
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.40.0"
4
+ VERSION = "4.0.0"
5
5
  end
@@ -51,7 +51,7 @@ module Sendly
51
51
  # @return [Sendly::Webhook]
52
52
  def get(webhook_id)
53
53
  validate_webhook_id!(webhook_id)
54
- response = @client.get("/webhooks/#{webhook_id}")
54
+ response = @client.get("/webhooks/#{URI.encode_www_form_component(webhook_id)}")
55
55
  Webhook.new(response)
56
56
  end
57
57
 
@@ -77,7 +77,7 @@ module Sendly
77
77
  body[:mode] = mode unless mode.nil?
78
78
  body[:metadata] = metadata unless metadata.nil?
79
79
 
80
- response = @client.patch("/webhooks/#{webhook_id}", body)
80
+ response = @client.patch("/webhooks/#{URI.encode_www_form_component(webhook_id)}", body)
81
81
  Webhook.new(response)
82
82
  end
83
83
 
@@ -87,7 +87,7 @@ module Sendly
87
87
  # @return [void]
88
88
  def delete(webhook_id)
89
89
  validate_webhook_id!(webhook_id)
90
- @client.delete("/webhooks/#{webhook_id}")
90
+ @client.delete("/webhooks/#{URI.encode_www_form_component(webhook_id)}")
91
91
  nil
92
92
  end
93
93
 
@@ -97,7 +97,7 @@ module Sendly
97
97
  # @return [Sendly::WebhookTestResult]
98
98
  def test(webhook_id)
99
99
  validate_webhook_id!(webhook_id)
100
- response = @client.post("/webhooks/#{webhook_id}/test")
100
+ response = @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/test")
101
101
  WebhookTestResult.new(response)
102
102
  end
103
103
 
@@ -107,7 +107,7 @@ module Sendly
107
107
  # @return [Hash] Reset confirmation with updated webhook
108
108
  def reset_circuit(webhook_id)
109
109
  validate_webhook_id!(webhook_id)
110
- @client.post("/webhooks/#{webhook_id}/reset-circuit")
110
+ @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/reset-circuit")
111
111
  end
112
112
 
113
113
  # Replay failed or cancelled webhook deliveries from the audit log.
@@ -133,7 +133,7 @@ module Sendly
133
133
  body[:event_types] = event_types unless event_types.nil?
134
134
  body[:statuses] = statuses unless statuses.nil?
135
135
  body[:limit] = limit unless limit.nil?
136
- @client.post("/webhooks/#{webhook_id}/redeliver", body)
136
+ @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/redeliver", body)
137
137
  end
138
138
 
139
139
  # Backfill missed webhook events from the underlying message log.
@@ -157,7 +157,7 @@ module Sendly
157
157
  body[:until] = until_ unless until_.nil?
158
158
  body[:event_types] = event_types unless event_types.nil?
159
159
  body[:limit] = limit unless limit.nil?
160
- @client.post("/webhooks/#{webhook_id}/backfill", body)
160
+ @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/backfill", body)
161
161
  end
162
162
 
163
163
  # Rotate the webhook signing secret
@@ -166,7 +166,7 @@ module Sendly
166
166
  # @return [Sendly::WebhookSecretRotation]
167
167
  def rotate_secret(webhook_id)
168
168
  validate_webhook_id!(webhook_id)
169
- response = @client.post("/webhooks/#{webhook_id}/rotate-secret")
169
+ response = @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/rotate-secret")
170
170
  WebhookSecretRotation.new(response)
171
171
  end
172
172
 
@@ -176,7 +176,7 @@ module Sendly
176
176
  # @return [Array<Sendly::WebhookDelivery>]
177
177
  def deliveries(webhook_id)
178
178
  validate_webhook_id!(webhook_id)
179
- response = @client.get("/webhooks/#{webhook_id}/deliveries")
179
+ response = @client.get("/webhooks/#{URI.encode_www_form_component(webhook_id)}/deliveries")
180
180
  response.map { |data| WebhookDelivery.new(data) }
181
181
  end
182
182
 
@@ -188,7 +188,7 @@ module Sendly
188
188
  def retry_delivery(webhook_id, delivery_id)
189
189
  validate_webhook_id!(webhook_id)
190
190
  validate_delivery_id!(delivery_id)
191
- @client.post("/webhooks/#{webhook_id}/deliveries/#{delivery_id}/retry")
191
+ @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/deliveries/#{URI.encode_www_form_component(delivery_id)}/retry")
192
192
  nil
193
193
  end
194
194
 
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.40.0
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-09 00:00:00.000000000 Z
11
+ date: 2026-09-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday