sendly 3.37.1 → 3.39.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.
data/lib/sendly/client.rb CHANGED
@@ -178,6 +178,20 @@ module Sendly
178
178
  @links ||= LinksResource.new(self)
179
179
  end
180
180
 
181
+ # Access the WhatsApp resource
182
+ #
183
+ # @return [Sendly::WhatsAppResource]
184
+ def whatsapp
185
+ @whatsapp ||= WhatsAppResource.new(self)
186
+ end
187
+
188
+ # Access the RCS resource
189
+ #
190
+ # @return [Sendly::RcsResource]
191
+ def rcs
192
+ @rcs ||= RcsResource.new(self)
193
+ end
194
+
181
195
  # Make a GET request
182
196
  #
183
197
  # @param path [String] API path
@@ -189,29 +203,47 @@ module Sendly
189
203
 
190
204
  # Make a POST request
191
205
  #
206
+ # Every POST carries an Idempotency-Key header. By default the client
207
+ # generates one per logical request ("sendly-ruby-retry-<uuid>") so the
208
+ # server can dedupe the client's own retries; pass +idempotency_key+ to
209
+ # supply your own (1-255 printable ASCII characters) and extend that
210
+ # protection across process restarts, or +auto_idempotency_key: false+
211
+ # to skip auto-generation for endpoints that dedupe by other means.
212
+ #
192
213
  # @param path [String] API path
193
214
  # @param body [Hash] Request body
215
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
216
+ # @param auto_idempotency_key [Boolean] Auto-generate a key when none is supplied (default: true)
194
217
  # @return [Hash] Response body
195
- def post(path, body = {})
196
- request(:post, path, body: body)
218
+ def post(path, body = {}, idempotency_key: nil, auto_idempotency_key: true)
219
+ request(:post, path, body: body, idempotency_key: idempotency_key,
220
+ auto_idempotency_key: auto_idempotency_key)
197
221
  end
198
222
 
199
223
  # Make a PATCH request
200
224
  #
225
+ # No Idempotency-Key is generated for a PATCH; pass +idempotency_key+
226
+ # to send one (1-255 printable ASCII characters).
227
+ #
201
228
  # @param path [String] API path
202
229
  # @param body [Hash] Request body
230
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
203
231
  # @return [Hash] Response body
204
- def patch(path, body = {})
205
- request(:patch, path, body: body)
232
+ def patch(path, body = {}, idempotency_key: nil)
233
+ request(:patch, path, body: body, idempotency_key: idempotency_key)
206
234
  end
207
235
 
208
236
  # Make a PUT request
209
237
  #
238
+ # No Idempotency-Key is generated for a PUT; pass +idempotency_key+
239
+ # to send one (1-255 printable ASCII characters).
240
+ #
210
241
  # @param path [String] API path
211
242
  # @param body [Hash] Request body
243
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
212
244
  # @return [Hash] Response body
213
- def put(path, body = {})
214
- request(:put, path, body: body)
245
+ def put(path, body = {}, idempotency_key: nil)
246
+ request(:put, path, body: body, idempotency_key: idempotency_key)
215
247
  end
216
248
 
217
249
  # Make a DELETE request
@@ -257,11 +289,15 @@ module Sendly
257
289
  # @param file [String, IO] File path or IO object
258
290
  # @param content_type [String] MIME type of the file
259
291
  # @param filename [String] Name for the uploaded file
292
+ # @param idempotency_key [String, nil] Caller-supplied idempotency key (optional)
260
293
  # @return [Hash] Response body
261
- def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg")
294
+ def post_multipart(path, file, content_type: "image/jpeg", filename: "upload.jpg", idempotency_key: nil)
262
295
  uri = build_uri(path, {})
263
296
  http = build_http(uri)
264
297
 
298
+ explicit_key = normalize_idempotency_key(idempotency_key)
299
+ key = explicit_key || generate_idempotency_key
300
+
265
301
  boundary = "SendlyRuby#{SecureRandom.hex(16)}"
266
302
 
267
303
  file_data = file.is_a?(String) ? File.binread(file) : file.read
@@ -283,6 +319,7 @@ module Sendly
283
319
 
284
320
  attempt = 0
285
321
  begin
322
+ req["Idempotency-Key"] = key
286
323
  response = http.request(req)
287
324
  handle_response(response)
288
325
  rescue Net::OpenTimeout, Net::ReadTimeout
@@ -299,6 +336,10 @@ module Sendly
299
336
  rescue ServerError => e
300
337
  attempt += 1
301
338
  if attempt <= max_retries
339
+ # A 5xx response may be cached under the key server-side, so an
340
+ # auto-generated key is rotated to let the retry re-execute.
341
+ # Caller-supplied keys are never rotated.
342
+ key = generate_idempotency_key if explicit_key.nil?
302
343
  sleep(2 ** attempt)
303
344
  retry
304
345
  end
@@ -306,6 +347,13 @@ module Sendly
306
347
  end
307
348
  end
308
349
 
350
+ # Generate an idempotency key for a logical request. Reused across retry
351
+ # attempts so the server can recognize a retry of a POST that already
352
+ # reached it and return the original result instead of executing again.
353
+ def generate_idempotency_key
354
+ "sendly-ruby-retry-#{SecureRandom.uuid}"
355
+ end
356
+
309
357
  private
310
358
 
311
359
  def validate_api_key!
@@ -316,13 +364,19 @@ module Sendly
316
364
  end
317
365
  end
318
366
 
319
- def request(method, path, params: {}, body: nil, unversioned: false)
367
+ def request(method, path, params: {}, body: nil, unversioned: false, idempotency_key: nil,
368
+ auto_idempotency_key: true)
320
369
  uri = build_uri(path, params, unversioned: unversioned)
321
370
  http = build_http(uri)
322
371
  req = build_request(method, uri, body)
323
372
 
373
+ explicit_key = normalize_idempotency_key(idempotency_key)
374
+ key = explicit_key
375
+ key = generate_idempotency_key if key.nil? && method == :post && auto_idempotency_key
376
+
324
377
  attempt = 0
325
378
  begin
379
+ req["Idempotency-Key"] = key if key
326
380
  response = http.request(req)
327
381
  handle_response(response)
328
382
  rescue Net::OpenTimeout, Net::ReadTimeout
@@ -339,6 +393,10 @@ module Sendly
339
393
  rescue ServerError => e
340
394
  attempt += 1
341
395
  if attempt <= max_retries
396
+ # A 5xx response may be cached under the key server-side, so an
397
+ # auto-generated key is rotated to let the retry re-execute.
398
+ # Caller-supplied keys are never rotated.
399
+ key = generate_idempotency_key if key && explicit_key.nil?
342
400
  sleep(2 ** attempt) # Exponential backoff
343
401
  retry
344
402
  end
@@ -346,6 +404,23 @@ module Sendly
346
404
  end
347
405
  end
348
406
 
407
+ # Validate and normalize a caller-supplied idempotency key. Empty and
408
+ # whitespace-only values are treated as absent (auto-generation still
409
+ # applies); invalid values fail fast instead of surfacing later as an
410
+ # API error.
411
+ def normalize_idempotency_key(key)
412
+ return nil if key.nil?
413
+
414
+ trimmed = key.to_s.strip
415
+ return nil if trimmed.empty?
416
+
417
+ if trimmed.length > 255 || !trimmed.match?(/\A[\x20-\x7E]+\z/)
418
+ raise ValidationError, "Idempotency key must be 1-255 printable ASCII characters"
419
+ end
420
+
421
+ trimmed
422
+ end
423
+
349
424
  def build_uri(path, params, unversioned: false)
350
425
  base = unversioned ? api_origin : base_url
351
426
  url = "#{base}#{path}"
@@ -505,6 +505,8 @@ module Sendly
505
505
  req["User-Agent"] = "sendly-ruby/#{Sendly::VERSION}"
506
506
  req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
507
507
  req["X-Organization-Id"] = @client.organization_id if @client.organization_id
508
+ # Single-use auto key (this path has no retry loop).
509
+ req["Idempotency-Key"] = @client.generate_idempotency_key
508
510
  req.body = body_parts.join
509
511
 
510
512
  response = http.request(req)
data/lib/sendly/errors.rb CHANGED
@@ -47,7 +47,9 @@ module Sendly
47
47
 
48
48
  # Raised when the request contains invalid parameters
49
49
  class ValidationError < Error
50
- # @return [Hash, nil] Field-specific validation errors
50
+ # @return [Array<Hash>, Hash, nil] Field-specific validation errors. For
51
+ # API responses this is the +errors+ list from the body when present,
52
+ # e.g. +[{ "path" => "brand.ein", "message" => "Enter a 9-digit EIN" }]+
51
53
  attr_reader :field_errors
52
54
 
53
55
  def initialize(message = "Validation failed", field_errors: nil, details: nil)
@@ -101,7 +103,7 @@ module Sendly
101
103
 
102
104
  case status
103
105
  when 400, 422
104
- ValidationError.new(message, details: details)
106
+ ValidationError.new(message, details: details, field_errors: body["errors"])
105
107
  when 401
106
108
  AuthenticationError.new(message)
107
109
  when 402
@@ -10,14 +10,66 @@ module Sendly
10
10
  @client = client
11
11
  end
12
12
 
13
- # Send an SMS message
13
+ # Send an SMS, WhatsApp, or RCS message
14
+ #
15
+ # Pass +channel: "whatsapp"+ to send on WhatsApp. WhatsApp sends require
16
+ # a live API key and a +from+ number with an active WhatsApp connection
17
+ # (see +client.whatsapp.signup+). Provide exactly one of +text+
18
+ # (free-form, max 4096 bytes), +media_urls+ (a single attachment;
19
+ # optional +text+ becomes its caption, max 1024 bytes), or +template+
20
+ # (an approved template). Free-form text and media only deliver inside
21
+ # an open 24-hour customer-service window — outside it the API responds
22
+ # 422 +whatsapp_window_closed+; send a template instead (check with
23
+ # +client.whatsapp.window+).
24
+ #
25
+ # Pass +channel: "rcs"+ to send on RCS. RCS sends require a live API key
26
+ # and a sendable RCS agent on your workspace (see +client.rcs.agents+).
27
+ # Provide exactly one of +text+ (free-form, optionally with
28
+ # +suggestions+ — suggested replies and URL actions) or +card+ (a rich
29
+ # card). +agent_id+ picks the sending agent when your workspace has more
30
+ # than one. Recipients that can't receive RCS get +text+ delivered as
31
+ # plain SMS (billed as SMS) unless +fallback_to_sms+ is false — the
32
+ # returned {Sendly::RcsMessage} discloses which channel delivered.
14
33
  #
15
34
  # @param to [String] Recipient phone number in E.164 format
16
- # @param text [String] Message content (max 1600 characters)
17
- # @param from [String] Sender ID or phone number (optional)
18
- # @param message_type [String] Message type: "marketing" (default) or "transactional"
35
+ # @param text [String] Message content (max 1600 characters for SMS);
36
+ # for WhatsApp, optional free-form text or the media caption
37
+ # @param from [String] Sender ID or phone number (optional for SMS);
38
+ # required for WhatsApp — must be a WhatsApp-connected number
39
+ # @param message_type [String] Message type: "marketing" (default) or "transactional" (SMS only)
19
40
  # @param metadata [Hash] Custom JSON metadata to attach to the message (max 4KB)
20
- # @return [Sendly::Message] The sent message
41
+ # @param media_urls [Array<String>] Media URLs to attach (WhatsApp accepts exactly one)
42
+ # @param channel [String] Message channel: omit (or "sms") for SMS,
43
+ # "whatsapp" for WhatsApp, "rcs" for RCS
44
+ # @param template [Hash] WhatsApp only: approved template to send, with
45
+ # :name, :language, and optional :variables ({ "1" => "Acme" }) and
46
+ # :buttons ([{ index: 0, variables: { "1" => "4821" } }]). Works
47
+ # regardless of the 24-hour window.
48
+ # @param agent_id [String] RCS only: the agent to send from. Optional
49
+ # when your workspace has exactly one sendable agent; required (the
50
+ # API responds 400 +rcs_agent_ambiguous+) when it has more.
51
+ # @param card [Hash] RCS only: a rich card, with :title, :description,
52
+ # and optional :mediaUrl (a public JPEG, PNG, or GIF), :orientation
53
+ # ("vertical" or "horizontal"), and :suggestions. Passed through
54
+ # verbatim, so use the camelCase keys shown. Cards have no SMS form
55
+ # and only deliver to RCS-capable recipients.
56
+ # @param suggestions [Array<Hash>] RCS only, alongside +text+: suggested
57
+ # replies and actions, each either
58
+ # { reply: { text: ..., postbackData: ... } } or
59
+ # { action: { text: ..., postbackData: ..., url: ... } }. Passed
60
+ # through verbatim, so use the camelCase keys shown. Suggestions have
61
+ # no SMS form and are dropped on a fallback.
62
+ # @param fallback_to_sms [Boolean] RCS only: deliver +text+ as plain SMS
63
+ # when the recipient can't receive RCS (default true). Pass false to
64
+ # fail with 422 +rcs_not_supported_for_recipient+ instead.
65
+ # @param idempotency_key [String] Idempotency key for this operation
66
+ # (1-255 printable ASCII characters). The SDK already generates a key
67
+ # per logical request automatically, so the server can dedupe the
68
+ # SDK's own retries. Supply your own key when you need idempotency
69
+ # across process restarts or your own retry loops — repeating a
70
+ # request with the same key within 24 hours returns the original
71
+ # response instead of executing again.
72
+ # @return [Sendly::Message, Sendly::WhatsAppMessage, Sendly::RcsMessage] The sent message
21
73
  #
22
74
  # @raise [Sendly::ValidationError] If parameters are invalid
23
75
  # @raise [Sendly::InsufficientCreditsError] If account has no credits
@@ -37,8 +89,93 @@ module Sendly
37
89
  # text: "Your verification code is 123456",
38
90
  # message_type: "transactional"
39
91
  # )
40
- def send(to:, text:, from: nil, message_type: nil, metadata: nil, media_urls: nil)
92
+ #
93
+ # @example WhatsApp free-form reply inside an open 24h window
94
+ # message = client.messages.send(
95
+ # channel: "whatsapp",
96
+ # to: "+15551234567",
97
+ # from: "+15559876543",
98
+ # text: "Your table is ready!"
99
+ # )
100
+ #
101
+ # @example WhatsApp template send — works regardless of the window
102
+ # message = client.messages.send(
103
+ # channel: "whatsapp",
104
+ # to: "+15551234567",
105
+ # from: "+15559876543",
106
+ # template: {
107
+ # name: "order_shipped",
108
+ # language: "en_US",
109
+ # variables: { "1" => "Acme Inc", "2" => "#4821" }
110
+ # }
111
+ # )
112
+ # puts message.whatsapp.kind # "template"
113
+ # puts message.credits_used # priced by country + category
114
+ #
115
+ # @example RCS text with suggested replies and actions
116
+ # message = client.messages.send(
117
+ # channel: "rcs",
118
+ # to: "+15551234567",
119
+ # text: "Your order has shipped! Want live updates?",
120
+ # suggestions: [
121
+ # { reply: { text: "Yes, notify me", postbackData: "notify_yes" } },
122
+ # { action: { text: "Track order", postbackData: "track",
123
+ # url: "https://acme.example/orders/4821" } }
124
+ # ]
125
+ # )
126
+ # puts message.channel # "rcs", or "sms" when it fell back
127
+ # puts message.fell_back? # true when delivered as plain SMS
128
+ #
129
+ # @example RCS rich card (RCS-capable recipients only)
130
+ # client.messages.send(
131
+ # channel: "rcs",
132
+ # to: "+15551234567",
133
+ # card: {
134
+ # title: "Spring collection",
135
+ # description: "New arrivals are in - take a look.",
136
+ # mediaUrl: "https://example.com/spring.jpg",
137
+ # orientation: "vertical"
138
+ # }
139
+ # )
140
+ def send(to:, text: nil, from: nil, message_type: nil, metadata: nil, media_urls: nil,
141
+ channel: nil, template: nil, agent_id: nil, card: nil, suggestions: nil,
142
+ fallback_to_sms: nil, idempotency_key: nil)
41
143
  validate_phone!(to)
144
+
145
+ if channel.to_s == "rcs"
146
+ has_text = !(text.nil? || text.to_s.empty?)
147
+ has_card = !card.nil?
148
+ raise ValidationError, "Provide exactly one of 'text' or 'card'" if has_text == has_card
149
+
150
+ body = { channel: "rcs", to: to }
151
+ body[:agentId] = agent_id if agent_id
152
+ body[:text] = text if has_text
153
+ body[:card] = card if has_card
154
+ body[:suggestions] = suggestions if suggestions
155
+ body[:fallbackToSms] = fallback_to_sms unless fallback_to_sms.nil?
156
+ body[:metadata] = metadata if metadata
157
+
158
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
159
+ return RcsMessage.new(response)
160
+ end
161
+
162
+ if channel.to_s == "whatsapp"
163
+ validate_phone!(from)
164
+ has_media = media_urls.is_a?(Array) && !media_urls.empty?
165
+ if (text.nil? || text.empty?) && !has_media && template.nil?
166
+ raise ValidationError, "Provide 'text', 'media_urls', or 'template'"
167
+ end
168
+
169
+ body = { channel: "whatsapp", to: to, from: from }
170
+ body[:text] = text unless text.nil?
171
+ body[:mediaUrls] = media_urls if has_media
172
+ body[:template] = template if template
173
+ body[:metadata] = metadata if metadata
174
+
175
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
176
+ return WhatsAppMessage.new(response)
177
+ end
178
+
42
179
  validate_text!(text)
43
180
 
44
181
  body = { to: to, text: text }
@@ -47,7 +184,7 @@ module Sendly
47
184
  body[:metadata] = metadata if metadata
48
185
  body[:mediaUrls] = media_urls if media_urls
49
186
 
50
- response = client.post("/messages", body)
187
+ response = client.post("/messages", body, idempotency_key: idempotency_key)
51
188
  # API returns message directly at top level
52
189
  Message.new(response)
53
190
  end
@@ -65,6 +202,8 @@ module Sendly
65
202
  # @param from [String] Sender ID or phone number (optional)
66
203
  # @param media_urls [Array<String>] Media URLs to attach (required unless text is provided)
67
204
  # @param message_type [String] Message type: "transactional" (default) or "marketing"
205
+ # @param idempotency_key [String] Idempotency key for this operation
206
+ # (1-255 printable ASCII characters, optional)
68
207
  # @return [Sendly::GroupMessage] The sent group message, including a group_message_id
69
208
  #
70
209
  # @raise [Sendly::ValidationError] If fewer than 2 / more than 8 recipients, or no body
@@ -77,7 +216,7 @@ module Sendly
77
216
  # )
78
217
  # puts group.id
79
218
  # puts group.group_message_id
80
- def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil)
219
+ def send_group(to:, text: nil, from: nil, media_urls: nil, message_type: nil, idempotency_key: nil)
81
220
  unless to.is_a?(Array) && to.length >= 2
82
221
  raise ValidationError, "Group messaging requires at least 2 recipients in 'to'"
83
222
  end
@@ -94,7 +233,7 @@ module Sendly
94
233
  body[:mediaUrls] = media_urls if has_media
95
234
  body[:messageType] = message_type if message_type
96
235
 
97
- response = client.post("/messages/group", body)
236
+ response = client.post("/messages/group", body, idempotency_key: idempotency_key)
98
237
  GroupMessage.new(response)
99
238
  end
100
239
 
@@ -218,6 +357,8 @@ module Sendly
218
357
  # @param from [String] Sender ID or phone number (optional)
219
358
  # @param message_type [String] Message type: "marketing" (default) or "transactional"
220
359
  # @param metadata [Hash] Custom JSON metadata to attach to the message (max 4KB)
360
+ # @param idempotency_key [String] Idempotency key for this operation
361
+ # (1-255 printable ASCII characters, optional)
221
362
  # @return [Hash] The scheduled message
222
363
  #
223
364
  # @raise [Sendly::ValidationError] If parameters are invalid
@@ -229,7 +370,7 @@ module Sendly
229
370
  # scheduled_at: "2025-01-20T10:00:00Z"
230
371
  # )
231
372
  # puts scheduled["id"]
232
- def schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil)
373
+ def schedule(to:, text:, scheduled_at:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
233
374
  validate_phone!(to)
234
375
  validate_text!(text)
235
376
  raise ValidationError, "scheduled_at is required" if scheduled_at.nil? || scheduled_at.empty?
@@ -239,7 +380,7 @@ module Sendly
239
380
  body[:messageType] = message_type if message_type
240
381
  body[:metadata] = metadata if metadata
241
382
 
242
- client.post("/messages/schedule", body)
383
+ client.post("/messages/schedule", body, idempotency_key: idempotency_key)
243
384
  end
244
385
 
245
386
  # List scheduled messages
@@ -303,6 +444,8 @@ module Sendly
303
444
  # @param from [String] Sender ID or phone number (optional, applies to all)
304
445
  # @param message_type [String] Message type: "marketing" (default) or "transactional"
305
446
  # @param metadata [Hash] Shared metadata for all messages (max 4KB). Each message can also have its own metadata hash which takes priority.
447
+ # @param idempotency_key [String] Idempotency key for this operation
448
+ # (1-255 printable ASCII characters, optional)
306
449
  # @return [Hash] Batch response with batch_id and status
307
450
  #
308
451
  # @raise [Sendly::ValidationError] If parameters are invalid
@@ -316,7 +459,7 @@ module Sendly
316
459
  # ]
317
460
  # )
318
461
  # puts "Batch #{result['batchId']}: #{result['queued']} queued"
319
- def send_batch(messages:, from: nil, message_type: nil, metadata: nil)
462
+ def send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
320
463
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
321
464
 
322
465
  messages.each_with_index do |msg, i|
@@ -334,7 +477,11 @@ module Sendly
334
477
  body[:messageType] = message_type if message_type
335
478
  body[:metadata] = metadata if metadata
336
479
 
337
- client.post("/messages/batch", body)
480
+ # The batch endpoint dedupes header-less retries server-side by hashing
481
+ # the request content; an auto-generated key would bypass that net for
482
+ # identical cross-process re-runs, so only caller-supplied keys are sent.
483
+ client.post("/messages/batch", body, idempotency_key: idempotency_key,
484
+ auto_idempotency_key: false)
338
485
  end
339
486
 
340
487
  # Get batch status by ID