sendly 4.2.0 → 4.3.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.
@@ -13,8 +13,11 @@ module Sendly
13
13
  # Send an SMS, WhatsApp, or RCS message
14
14
  #
15
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+
16
+ # the +sms:send+ scope (not +whatsapp:write+), a live API key and a
17
+ # +from+ number with an active WhatsApp connection (see
18
+ # +client.whatsapp.signup+). WhatsApp is enabled per person (the user who
19
+ # owns the API key, not the workspace); while it is off the API responds
20
+ # 403 +whatsapp_not_enabled+. Provide exactly one of +text+
18
21
  # (free-form, max 4096 bytes), +media_urls+ (a single attachment;
19
22
  # optional +text+ becomes its caption, max 1024 bytes), or +template+
20
23
  # (an approved template). Free-form text and media only deliver inside
@@ -71,7 +74,22 @@ module Sendly
71
74
  # response instead of executing again.
72
75
  # @return [Sendly::Message, Sendly::WhatsAppMessage, Sendly::RcsMessage] The sent message
73
76
  #
74
- # @raise [Sendly::ValidationError] If parameters are invalid
77
+ # @raise [Sendly::ValidationError] If parameters are invalid, or on
78
+ # WhatsApp the 422 +whatsapp_send_failed+ when WhatsApp refused the
79
+ # message (final, not retried, not charged; cached under the
80
+ # idempotency key and replayed for 24 hours)
81
+ # @raise [Sendly::ServerError] On WhatsApp, the 502 +whatsapp_send_failed+:
82
+ # the message provably never reached the carrier, so it was not sent
83
+ # and is safe to send again. It isn't charged and is never cached, so
84
+ # the client retries it like any 5xx first under the same idempotency
85
+ # key. No send returns 503 +whatsapp_unavailable+.
86
+ # @raise [Sendly::APIError] On WhatsApp, the 409
87
+ # +whatsapp_send_unconfirmed+ (+status_code+ 409): the outcome is
88
+ # unknown. The message was marked failed and refunded but may still be
89
+ # delivered, so check before sending it again (it could arrive twice).
90
+ # It is not retried automatically, and it is cached under the
91
+ # idempotency key, so repeating the request with the same key returns
92
+ # this answer again.
75
93
  # @raise [Sendly::InsufficientCreditsError] If account has no credits
76
94
  # @raise [Sendly::RateLimitError] If rate limit is exceeded
77
95
  #
@@ -327,7 +345,7 @@ module Sendly
327
345
  #
328
346
  # @param status [String] Filter by status
329
347
  # @param to [String] Filter by recipient
330
- # @param batch_size [Integer] Number of messages per request
348
+ # @param batch_size [Integer] Number of messages per request (the API returns at most 100)
331
349
  # @yield [Message] Each message
332
350
  # @return [Enumerator] If no block given
333
351
  #
@@ -343,9 +361,9 @@ module Sendly
343
361
  page = list(limit: batch_size, offset: offset, status: status, to: to)
344
362
  page.each(&block)
345
363
 
346
- break unless page.has_more
364
+ break if !page.has_more || page.count.zero?
347
365
 
348
- offset += batch_size
366
+ offset += page.count
349
367
  end
350
368
  end
351
369
 
@@ -446,7 +464,9 @@ module Sendly
446
464
  # @param metadata [Hash] Shared metadata for all messages (max 4KB). Each message can also have its own metadata hash which takes priority.
447
465
  # @param idempotency_key [String] Idempotency key for this operation
448
466
  # (1-255 printable ASCII characters, optional)
449
- # @return [Hash] Batch response with batch_id and status
467
+ # @return [Hash] Batch result: +batchId+, +status+ (processing, completed,
468
+ # partial_failure or failed), +total+, +sent+, +failed+, +creditsUsed+,
469
+ # +creditsRefunded+ and +messages+
450
470
  #
451
471
  # @raise [Sendly::ValidationError] If parameters are invalid
452
472
  # @raise [Sendly::InsufficientCreditsError] If account has insufficient credits
@@ -458,7 +478,7 @@ module Sendly
458
478
  # { to: "+15559876543", text: "Hello Bob!" }
459
479
  # ]
460
480
  # )
461
- # puts "Batch #{result['batchId']}: #{result['queued']} queued"
481
+ # puts "Batch #{result['batchId']}: #{result['total']} messages, #{result['status']}"
462
482
  def send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
463
483
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
464
484
 
@@ -505,12 +525,12 @@ module Sendly
505
525
  #
506
526
  # @param limit [Integer] Maximum batches to return (default: 20, max: 100)
507
527
  # @param offset [Integer] Number of batches to skip
508
- # @param status [String] Filter by status (processing, completed, failed)
528
+ # @param status [String] Filter by status (processing, completed, partial_failure, failed)
509
529
  # @return [Hash] Paginated list of batches
510
530
  #
511
531
  # @example
512
532
  # batches = client.messages.list_batches(limit: 10)
513
- # batches["data"].each { |b| puts "#{b['batchId']}: #{b['status']}" }
533
+ # batches["data"].each { |b| puts "#{b['id']}: #{b['status']}" }
514
534
  def list_batches(limit: 20, offset: 0, status: nil)
515
535
  params = {
516
536
  limit: [limit, 100].min,
@@ -537,7 +557,7 @@ module Sendly
537
557
  # { to: "+15559876543", text: "Hello Bob!" }
538
558
  # ]
539
559
  # )
540
- # puts "Can send: #{preview['canSend']}"
560
+ # puts "Sufficient credits: #{preview['hasSufficientCredits']}"
541
561
  # puts "Credits needed: #{preview['creditsNeeded']}"
542
562
  def preview_batch(messages:, from: nil, message_type: nil)
543
563
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
data/lib/sendly/types.rb CHANGED
@@ -63,6 +63,12 @@ module Sendly
63
63
  # @return [Hash, nil] AI classification metadata for inbound messages
64
64
  attr_reader :ai_metadata
65
65
 
66
+ # @return [Array<String>] Media attached to the message; empty when there is none
67
+ attr_reader :media_urls
68
+
69
+ # @return [String] "sms", "mms", "rcs" or "whatsapp"; "sms" when the response does not say
70
+ attr_reader :message_format
71
+
66
72
  # Message status constants (sending removed - doesn't exist in database)
67
73
  STATUSES = %w[queued sent delivered failed bounced retrying].freeze
68
74
 
@@ -90,6 +96,8 @@ module Sendly
90
96
  @retry_count = data["retryCount"] || 0
91
97
  @metadata = data["metadata"]
92
98
  @ai_metadata = data["aiMetadata"]
99
+ @media_urls = data["mediaUrls"] || data["media_urls"] || []
100
+ @message_format = data["messageFormat"] || data["message_format"] || "sms"
93
101
  end
94
102
 
95
103
  # Check if message was delivered
@@ -133,7 +141,9 @@ module Sendly
133
141
  error_code: error_code,
134
142
  retry_count: retry_count,
135
143
  metadata: metadata,
136
- ai_metadata: ai_metadata
144
+ ai_metadata: ai_metadata,
145
+ media_urls: media_urls,
146
+ message_format: message_format
137
147
  }.compact
138
148
  end
139
149
 
@@ -155,7 +165,7 @@ module Sendly
155
165
  # @return [Array<Message>] Messages in this page
156
166
  attr_reader :data
157
167
 
158
- # @return [Integer] Total number of messages
168
+ # @return [Integer] Total number of messages that match the query, across all pages
159
169
  attr_reader :total
160
170
 
161
171
  # @return [Integer] Current limit
@@ -168,11 +178,16 @@ module Sendly
168
178
  attr_reader :has_more
169
179
 
170
180
  def initialize(response)
181
+ pagination = response["pagination"] || {}
171
182
  @data = (response["data"] || []).map { |m| Message.new(m) }
172
- @total = response["count"] || @data.length
173
- @limit = response["limit"] || 20
174
- @offset = response["offset"] || 0
175
- @has_more = (@offset + @data.length) < @total
183
+ @total = pagination["total"] || response["total"] || response["count"] || @data.length
184
+ @limit = pagination["limit"] || response["limit"] || 20
185
+ @offset = pagination["offset"] || response["offset"] || 0
186
+ @has_more = if pagination.key?("hasMore")
187
+ pagination["hasMore"]
188
+ else
189
+ (@offset + @data.length) < @total
190
+ end
176
191
  end
177
192
 
178
193
  # Iterate over messages
@@ -433,14 +448,31 @@ module Sendly
433
448
  end
434
449
 
435
450
  # Result of testing a webhook
451
+ #
452
+ # Only a delivered test comes back as a result: when the test delivery
453
+ # fails, {Sendly::WebhooksResource#test} raises {Sendly::ValidationError}
454
+ # with the API's message.
436
455
  class WebhookTestResult
437
456
  attr_reader :success, :status_code, :response_time_ms, :error
438
457
 
458
+ # @return [String, nil] The API's summary of the test delivery
459
+ attr_reader :message
460
+
461
+ # @return [String, nil] ID of the test delivery
462
+ attr_reader :delivery_id
463
+
464
+ # @return [Hash] The raw parsed response
465
+ attr_reader :raw
466
+
439
467
  def initialize(data)
468
+ delivery = data["delivery"] || {}
469
+ @raw = data
440
470
  @success = data["success"]
441
- @status_code = data["status_code"] || data["statusCode"]
442
- @response_time_ms = data["response_time_ms"] || data["responseTimeMs"]
443
- @error = data["error"]
471
+ @status_code = data["status_code"] || data["statusCode"] || delivery["status_code"]
472
+ @response_time_ms = data["response_time_ms"] || data["responseTimeMs"] || delivery["response_time"]
473
+ @error = data["error"] || delivery["error"]
474
+ @message = data["message"]
475
+ @delivery_id = delivery["id"] || delivery["delivery_id"]
444
476
  end
445
477
 
446
478
  def success?
@@ -449,14 +481,49 @@ module Sendly
449
481
  end
450
482
 
451
483
  # Result of rotating webhook secret
484
+ #
485
+ # The API signs deliveries with the new secret from the moment the rotation
486
+ # returns and does not keep the old one, so {#new_secret} is the only secret
487
+ # that verifies deliveries from then on.
452
488
  class WebhookSecretRotation
453
- attr_reader :webhook, :new_secret, :old_secret_expires_at, :message
489
+ # @return [Sendly::Webhook, nil] Always +nil+: the rotation response does not include the webhook
490
+ attr_reader :webhook
491
+
492
+ # @return [String] The new signing secret. It is shown only once.
493
+ attr_reader :new_secret
494
+
495
+ # @return [Time, nil] Always +nil+: the API does not keep the old secret, so it has no expiry
496
+ attr_reader :old_secret_expires_at
497
+
498
+ # @return [String, nil] Confirmation message
499
+ attr_reader :message
500
+
501
+ # @return [String, nil] The webhook's ID
502
+ attr_reader :id
503
+
504
+ # @return [Integer, nil] The webhook's secret version as the API reports it
505
+ attr_reader :new_secret_version
506
+
507
+ # @return [Integer, nil] The grace period the API reports (24). The old
508
+ # secret is not kept, so it does not verify deliveries during it.
509
+ attr_reader :grace_period_hours
510
+
511
+ # @return [Time, nil] When the secret was rotated
512
+ attr_reader :rotated_at
513
+
514
+ # @return [Hash] The raw parsed response
515
+ attr_reader :raw
454
516
 
455
517
  def initialize(data)
456
- @webhook = Webhook.new(data["webhook"])
457
- @new_secret = data["new_secret"] || data["newSecret"]
518
+ @raw = data
519
+ @webhook = data["webhook"] ? Webhook.new(data["webhook"]) : nil
520
+ @new_secret = data["new_secret"] || data["newSecret"] || data["secret"]
458
521
  @old_secret_expires_at = parse_time(data["old_secret_expires_at"] || data["oldSecretExpiresAt"])
459
522
  @message = data["message"]
523
+ @id = data["id"]
524
+ @new_secret_version = data["new_secret_version"] || data["newSecretVersion"]
525
+ @grace_period_hours = data["grace_period_hours"] || data["gracePeriodHours"]
526
+ @rotated_at = parse_time(data["rotated_at"] || data["rotatedAt"])
460
527
  end
461
528
 
462
529
  private
@@ -475,13 +542,56 @@ module Sendly
475
542
 
476
543
  # Represents account information
477
544
  class Account
478
- attr_reader :id, :email, :name, :created_at
545
+ # @return [String, nil] The user's ID
546
+ attr_reader :id
547
+
548
+ # @return [String, nil] The user's email address
549
+ attr_reader :email
550
+
551
+ # @return [String, nil] The name of the workspace the API key belongs to,
552
+ # or +nil+ when the key is not bound to a workspace
553
+ attr_reader :name
554
+
555
+ # @return [Time, nil] When the user signed up
556
+ attr_reader :created_at
557
+
558
+ # @return [Hash, nil] The workspace the API key belongs to
559
+ # (+"id"+, +"name"+, +"isPersonal"+), or +nil+
560
+ attr_reader :organization
561
+
562
+ # @return [String, nil] The workspace's ID, the +organization_id+ in webhook payloads
563
+ attr_reader :organization_id
564
+
565
+ # @return [Hash, nil] Credit balances (+"balance"+, +"reservedBalance"+)
566
+ attr_reader :credits
567
+
568
+ # @return [Hash, nil] Business verification (+"status"+, +"type"+, +"region"+,
569
+ # +"submittedAt"+, +"updatedAt"+), or +nil+ when there is none
570
+ attr_reader :verification
571
+
572
+ # @return [Hash, nil] The API key making the call (+"id"+, +"name"+, +"type"+,
573
+ # +"scopes"+, +"createdAt"+, +"lastUsedAt"+)
574
+ attr_reader :api_key
575
+
576
+ # @return [Hash, nil] Sending limits (+"messagesPerMinute"+, +"messagesPerDay"+)
577
+ attr_reader :limits
578
+
579
+ # @return [Hash] The raw parsed response
580
+ attr_reader :raw
479
581
 
480
582
  def initialize(data)
481
- @id = data["id"]
482
- @email = data["email"]
483
- @name = data["name"]
484
- @created_at = parse_time(data["created_at"] || data["createdAt"])
583
+ user = data["user"] || {}
584
+ @raw = data
585
+ @organization = data["organization"]
586
+ @organization_id = @organization && @organization["id"]
587
+ @id = user["id"] || data["id"]
588
+ @email = user["email"] || data["email"]
589
+ @name = (@organization && @organization["name"]) || data["name"]
590
+ @created_at = parse_time(user["createdAt"] || user["created_at"] || data["created_at"] || data["createdAt"])
591
+ @credits = data["credits"]
592
+ @verification = data["verification"]
593
+ @api_key = data["apiKey"] || data["api_key"]
594
+ @limits = data["limits"]
485
595
  end
486
596
 
487
597
  private
@@ -509,8 +619,9 @@ module Sendly
509
619
  class CreditTransaction
510
620
  attr_reader :id, :type, :amount, :balance_after, :description, :message_id, :created_at
511
621
 
512
- # Transaction type constants
513
- TYPES = %w[purchase usage refund adjustment bonus].freeze
622
+ # Transaction type constants. Auto-recharges are recorded as +purchase+;
623
+ # +adjustment+ is never recorded.
624
+ TYPES = %w[purchase usage refund adjustment bonus transfer admin_grant admin_seed].freeze
514
625
 
515
626
  def initialize(data)
516
627
  @id = data["id"]
@@ -542,20 +653,41 @@ module Sendly
542
653
 
543
654
  # Represents an API key
544
655
  class ApiKey
545
- attr_reader :id, :name, :type, :prefix, :last_four, :permissions,
656
+ attr_reader :id, :name, :type, :prefix, :permissions,
546
657
  :created_at, :last_used_at, :expires_at, :is_revoked
547
658
 
659
+ # @return [nil] Always +nil+: no API response carries the last four characters
660
+ attr_reader :last_four
661
+
662
+ # @return [Array<String>] The key's scopes (the same list as {#permissions})
663
+ attr_reader :scopes
664
+
665
+ # @return [Boolean, nil] Whether the key is active, or +nil+ when the response does not say
666
+ attr_reader :is_active
667
+
668
+ # @return [Time, nil] When the key was revoked
669
+ attr_reader :revoked_at
670
+
548
671
  def initialize(data)
549
672
  @id = data["id"]
550
673
  @name = data["name"]
551
674
  @type = data["type"]
552
675
  @prefix = data["prefix"]
553
676
  @last_four = data["last_four"] || data["lastFour"]
554
- @permissions = data["permissions"] || []
677
+ @permissions = data["permissions"] || data["scopes"] || []
678
+ @scopes = data["scopes"] || data["permissions"] || []
555
679
  @created_at = parse_time(data["created_at"] || data["createdAt"])
556
680
  @last_used_at = parse_time(data["last_used_at"] || data["lastUsedAt"])
557
681
  @expires_at = parse_time(data["expires_at"] || data["expiresAt"])
558
- @is_revoked = data["is_revoked"] || data["isRevoked"] || false
682
+ @revoked_at = parse_time(data["revoked_at"] || data["revokedAt"])
683
+ @is_active = data.key?("isActive") ? data["isActive"] : data["is_active"]
684
+ @is_revoked = if data.key?("isRevoked") || data.key?("is_revoked")
685
+ data["isRevoked"] || data["is_revoked"] || false
686
+ elsif !@is_active.nil?
687
+ !@is_active
688
+ else
689
+ !@revoked_at.nil?
690
+ end
559
691
  end
560
692
 
561
693
  def test?
@@ -842,13 +974,20 @@ module Sendly
842
974
 
843
975
  attr_reader :data, :total, :limit, :offset, :has_more
844
976
 
845
- def initialize(response)
977
+ # @param response [Hash] The parsed list response
978
+ # @param limit [Integer, nil] The page size the API used, when the response does not say
979
+ # @param offset [Integer, nil] The offset the API used, when the response does not say
980
+ def initialize(response, limit = nil, offset = nil)
846
981
  @data = (response["data"] || []).map { |d| Draft.new(d) }
847
982
  pagination = response["pagination"] || {}
848
983
  @total = pagination["total"] || @data.length
849
- @limit = pagination["limit"] || 20
850
- @offset = pagination["offset"] || 0
851
- @has_more = pagination["hasMore"] || pagination["has_more"] || false
984
+ @limit = pagination["limit"] || limit || 20
985
+ @offset = pagination["offset"] || offset || 0
986
+ @has_more = if pagination.key?("hasMore") || pagination.key?("has_more")
987
+ pagination["hasMore"] || pagination["has_more"] || false
988
+ else
989
+ (@offset + @data.length) < @total
990
+ end
852
991
  end
853
992
 
854
993
  def each(&block)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "4.2.0"
4
+ VERSION = "4.3.0"
5
5
  end
@@ -16,6 +16,8 @@ module Sendly
16
16
  # @param mode [String, nil] Event mode filter: "all", "test", or "live" (live requires verification)
17
17
  # @param metadata [Hash, nil] Custom metadata
18
18
  # @return [Sendly::WebhookCreatedResponse]
19
+ # @raise [ArgumentError] If +url+ is not an https:// URL or +events+ is
20
+ # empty; no request is sent
19
21
  #
20
22
  # @example
21
23
  # webhook = client.webhooks.create(
@@ -65,6 +67,8 @@ module Sendly
65
67
  # @param mode [String, nil] Event mode filter: "all", "test", or "live"
66
68
  # @param metadata [Hash, nil] Custom metadata
67
69
  # @return [Sendly::Webhook]
70
+ # @raise [ArgumentError] If +webhook_id+ does not start with +whk_+ or
71
+ # +url+ is not an https:// URL; no request is sent
68
72
  def update(webhook_id, url: nil, events: nil, description: nil, is_active: nil, mode: nil, metadata: nil)
69
73
  validate_webhook_id!(webhook_id)
70
74
  raise ArgumentError, "Webhook URL must be HTTPS" if url && !url.start_with?("https://")
@@ -94,7 +98,9 @@ module Sendly
94
98
  # Test a webhook endpoint
95
99
  #
96
100
  # @param webhook_id [String] Webhook ID
97
- # @return [Sendly::WebhookTestResult]
101
+ # @return [Sendly::WebhookTestResult] The delivered test, with its status code and timing
102
+ # @raise [Sendly::ValidationError] If the test delivery fails; the message
103
+ # is the API's, e.g. "Test webhook failed: ..."
98
104
  def test(webhook_id)
99
105
  validate_webhook_id!(webhook_id)
100
106
  response = @client.post("/webhooks/#{URI.encode_www_form_component(webhook_id)}/test")
@@ -139,8 +145,10 @@ module Sendly
139
145
  # Backfill missed webhook events from the underlying message log.
140
146
  #
141
147
  # Use when a circuit-breaker outage left events with no audit row (the
142
- # case {#redeliver} cannot recover). Synthesized events have fresh IDs;
143
- # clients should dedupe by event.data.object.id (the message ID).
148
+ # case {#redeliver} cannot recover). Synthesized message events carry the
149
+ # same event id the original dispatch used, so dedupe on event.id. Do not
150
+ # dedupe on event.data.object.id: a message's sent and delivered events
151
+ # share it.
144
152
  # Rejects with HTTP 409 if the circuit is currently open — call
145
153
  # {#reset_circuit} first.
146
154
  #
@@ -162,6 +170,11 @@ module Sendly
162
170
 
163
171
  # Rotate the webhook signing secret
164
172
  #
173
+ # Deliveries are signed with the new secret as soon as this returns, and
174
+ # the old secret is not kept, so have your endpoint accept both secrets
175
+ # while you deploy the new one. The new secret is shown only once, on
176
+ # {Sendly::WebhookSecretRotation#new_secret}.
177
+ #
165
178
  # @param webhook_id [String] Webhook ID
166
179
  # @return [Sendly::WebhookSecretRotation]
167
180
  def rotate_secret(webhook_id)
@@ -170,14 +183,24 @@ module Sendly
170
183
  WebhookSecretRotation.new(response)
171
184
  end
172
185
 
173
- # Get delivery history for a webhook
186
+ # Get delivery history for a webhook, newest first
174
187
  #
175
188
  # @param webhook_id [String] Webhook ID
189
+ # @param limit [Integer, nil] Maximum deliveries to return (the API defaults to 50, max 100)
190
+ # @param offset [Integer, nil] Number of deliveries to skip
191
+ # @param status [String, nil] Only deliveries with this status
192
+ # (see {Sendly::WebhookDelivery::STATUSES})
176
193
  # @return [Array<Sendly::WebhookDelivery>]
177
- def deliveries(webhook_id)
194
+ def deliveries(webhook_id, limit: nil, offset: nil, status: nil)
178
195
  validate_webhook_id!(webhook_id)
179
- response = @client.get("/webhooks/#{URI.encode_www_form_component(webhook_id)}/deliveries")
180
- response.map { |data| WebhookDelivery.new(data) }
196
+ params = {}
197
+ params[:limit] = limit unless limit.nil?
198
+ params[:offset] = offset unless offset.nil?
199
+ params[:status] = status unless status.nil?
200
+
201
+ response = @client.get("/webhooks/#{URI.encode_www_form_component(webhook_id)}/deliveries", params)
202
+ items = response.is_a?(Hash) ? response["deliveries"] : response
203
+ (items || []).map { |data| WebhookDelivery.new(data) }
181
204
  end
182
205
 
183
206
  # Retry a failed delivery