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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -1
- data/Gemfile.lock +2 -2
- data/README.md +305 -11
- data/lib/sendly/account_resource.rb +12 -8
- data/lib/sendly/business_upgrade_resource.rb +2 -0
- data/lib/sendly/client.rb +83 -8
- data/lib/sendly/enterprise.rb +2 -0
- data/lib/sendly/errors.rb +4 -2
- data/lib/sendly/messages.rb +160 -13
- data/lib/sendly/rcs_resource.rb +1155 -0
- data/lib/sendly/templates_resource.rb +169 -37
- data/lib/sendly/version.rb +1 -1
- data/lib/sendly/whatsapp_resource.rb +655 -0
- data/lib/sendly.rb +2 -0
- data/sendly.gemspec +51 -0
- metadata +5 -2
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}"
|
data/lib/sendly/enterprise.rb
CHANGED
|
@@ -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
|
data/lib/sendly/messages.rb
CHANGED
|
@@ -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
|
-
#
|
|
18
|
-
# @param
|
|
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
|
-
# @
|
|
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
|
-
|
|
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
|
-
|
|
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
|