sendly 4.2.0 → 4.4.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/errors.rb CHANGED
@@ -3,7 +3,10 @@
3
3
  module Sendly
4
4
  # Base error class for all Sendly errors
5
5
  class Error < StandardError
6
- # @return [String, nil] Error code from the API
6
+ # @return [String, nil] The API's error code, such as
7
+ # +"whatsapp_window_closed"+ or +"sms_fallback_unavailable"+, for an
8
+ # error built from an API response that names one. Otherwise the
9
+ # class's own code, such as +"VALIDATION_ERROR"+.
7
10
  attr_reader :code
8
11
 
9
12
  # @return [Hash, nil] Additional error details
@@ -40,8 +43,8 @@ module Sendly
40
43
 
41
44
  # Raised when the API key is invalid or missing
42
45
  class AuthenticationError < Error
43
- def initialize(message = "Invalid or missing API key")
44
- super(message, code: "AUTHENTICATION_ERROR", status_code: 401)
46
+ def initialize(message = "Invalid or missing API key", code: nil)
47
+ super(message, code: code || "AUTHENTICATION_ERROR", status_code: 401)
45
48
  end
46
49
  end
47
50
 
@@ -50,16 +53,16 @@ module Sendly
50
53
  # @return [Integer, nil] Seconds to wait before retrying
51
54
  attr_reader :retry_after
52
55
 
53
- def initialize(message = "Rate limit exceeded", retry_after: nil)
56
+ def initialize(message = "Rate limit exceeded", retry_after: nil, code: nil)
54
57
  @retry_after = retry_after
55
- super(message, code: "RATE_LIMIT_EXCEEDED", status_code: 429)
58
+ super(message, code: code || "RATE_LIMIT_EXCEEDED", status_code: 429)
56
59
  end
57
60
  end
58
61
 
59
62
  # Raised when the account has insufficient credits
60
63
  class InsufficientCreditsError < Error
61
- def initialize(message = "Insufficient credits")
62
- super(message, code: "INSUFFICIENT_CREDITS", status_code: 402)
64
+ def initialize(message = "Insufficient credits", code: nil)
65
+ super(message, code: code || "INSUFFICIENT_CREDITS", status_code: 402)
63
66
  end
64
67
  end
65
68
 
@@ -70,16 +73,16 @@ module Sendly
70
73
  # e.g. +[{ "path" => "brand.ein", "message" => "Enter a 9-digit EIN" }]+
71
74
  attr_reader :field_errors
72
75
 
73
- def initialize(message = "Validation failed", field_errors: nil, details: nil)
76
+ def initialize(message = "Validation failed", field_errors: nil, details: nil, code: nil)
74
77
  @field_errors = field_errors
75
- super(message, code: "VALIDATION_ERROR", details: details, status_code: 400)
78
+ super(message, code: code || "VALIDATION_ERROR", details: details, status_code: 400)
76
79
  end
77
80
  end
78
81
 
79
82
  # Raised when the requested resource is not found
80
83
  class NotFoundError < Error
81
- def initialize(message = "Resource not found")
82
- super(message, code: "NOT_FOUND", status_code: 404)
84
+ def initialize(message = "Resource not found", code: nil)
85
+ super(message, code: code || "NOT_FOUND", status_code: 404)
83
86
  end
84
87
  end
85
88
 
@@ -107,36 +110,57 @@ module Sendly
107
110
 
108
111
  # Raised for server errors (5xx)
109
112
  class ServerError < Error
110
- def initialize(message = "Server error occurred", status_code: 500)
111
- super(message, code: "SERVER_ERROR", status_code: status_code)
113
+ def initialize(message = "Server error occurred", status_code: 500, code: nil)
114
+ super(message, code: code || "SERVER_ERROR", status_code: status_code)
112
115
  end
113
116
  end
114
117
 
115
118
  # Convert API response to appropriate error
116
119
  class ErrorFactory
117
- def self.from_response(status, body)
120
+ API_ERROR_CODE = /\A[a-z][a-z0-9_]*\z/
121
+ private_constant :API_ERROR_CODE
122
+
123
+ def self.from_response(status, body, retry_after_header: nil)
118
124
  message = body["message"] || body["error"] || "Unknown error"
119
- code = body["code"]
125
+ code = api_error_code(body)
120
126
  details = body["details"]
121
127
 
122
128
  error = case status
123
129
  when 400, 422
124
- ValidationError.new(message, details: details, field_errors: body["errors"])
130
+ ValidationError.new(message, details: details, field_errors: body["errors"], code: code)
125
131
  when 401
126
- AuthenticationError.new(message)
132
+ AuthenticationError.new(message, code: code)
127
133
  when 402
128
- InsufficientCreditsError.new(message)
134
+ InsufficientCreditsError.new(message, code: code)
129
135
  when 404
130
- NotFoundError.new(message)
136
+ NotFoundError.new(message, code: code)
131
137
  when 429
132
- retry_after = body["retry_after"] || body["retryAfter"]
133
- RateLimitError.new(message, retry_after: retry_after)
138
+ retry_after = parse_retry_after(retry_after_header) || body["retry_after"] || body["retryAfter"]
139
+ RateLimitError.new(message, retry_after: retry_after, code: code)
134
140
  when 500..599
135
- ServerError.new(message, status_code: status)
141
+ ServerError.new(message, status_code: status, code: code)
136
142
  else
137
143
  APIError.new(message, status_code: status, code: code, details: details)
138
144
  end
139
145
  error.with_response_body(body)
140
146
  end
147
+
148
+ def self.api_error_code(body)
149
+ error = body["error"]
150
+ return error.strip if error.is_a?(String) && error.strip.match?(API_ERROR_CODE)
151
+
152
+ code = body["code"]
153
+ code.is_a?(String) && !code.strip.empty? ? code : nil
154
+ end
155
+ private_class_method :api_error_code
156
+
157
+ def self.parse_retry_after(value)
158
+ text = value.to_s.strip
159
+ return text.to_i if text.match?(/\A\d+\z/)
160
+ return text.to_f if text.match?(/\A\d+\.\d+\z/)
161
+
162
+ nil
163
+ end
164
+ private_class_method :parse_retry_after
141
165
  end
142
166
  end
@@ -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
@@ -59,9 +62,17 @@ module Sendly
59
62
  # { action: { text: ..., postbackData: ..., url: ... } }. Passed
60
63
  # through verbatim, so use the camelCase keys shown. Suggestions have
61
64
  # no SMS form and are dropped on a fallback.
62
- # @param fallback_to_sms [Boolean] RCS only: deliver +text+ as plain SMS
65
+ # @param fallback_to_sms [Boolean] RCS: deliver +text+ as plain SMS
63
66
  # when the recipient can't receive RCS (default true). Pass false to
64
- # fail with 422 +rcs_not_supported_for_recipient+ instead.
67
+ # fail with 422 +rcs_not_supported_for_recipient+ instead. WhatsApp:
68
+ # default false. With true, a message WhatsApp can't deliver (the
69
+ # 24-hour window is closed, WhatsApp refuses it, or reports it
70
+ # undeliverable later) goes as an SMS from the same number, billed as
71
+ # an SMS; +message.fallback+ ({Sendly::WhatsAppSmsFallback}) says what
72
+ # happened. The number must already be able to send SMS to the
73
+ # recipient (US and Canada), or the API answers 403
74
+ # +sms_fallback_unavailable+; marketing and authentication templates,
75
+ # and media without a caption, get 422 +sms_fallback_not_supported+.
65
76
  # @param idempotency_key [String] Idempotency key for this operation
66
77
  # (1-255 printable ASCII characters). The SDK already generates a key
67
78
  # per logical request automatically, so the server can dedupe the
@@ -71,7 +82,22 @@ module Sendly
71
82
  # response instead of executing again.
72
83
  # @return [Sendly::Message, Sendly::WhatsAppMessage, Sendly::RcsMessage] The sent message
73
84
  #
74
- # @raise [Sendly::ValidationError] If parameters are invalid
85
+ # @raise [Sendly::ValidationError] If parameters are invalid, or on
86
+ # WhatsApp the 422 +whatsapp_send_failed+ when WhatsApp refused the
87
+ # message (final, not retried, not charged; cached under the
88
+ # idempotency key and replayed for 24 hours)
89
+ # @raise [Sendly::ServerError] On WhatsApp, the 502 +whatsapp_send_failed+:
90
+ # the message provably never reached the carrier, so it was not sent
91
+ # and is safe to send again. It isn't charged and is never cached, so
92
+ # the client retries it like any 5xx first under the same idempotency
93
+ # key. No send returns 503 +whatsapp_unavailable+.
94
+ # @raise [Sendly::APIError] On WhatsApp, the 409
95
+ # +whatsapp_send_unconfirmed+ (+status_code+ 409): the outcome is
96
+ # unknown. The message was marked failed and refunded but may still be
97
+ # delivered, so check before sending it again (it could arrive twice).
98
+ # It is not retried automatically, and it is cached under the
99
+ # idempotency key, so repeating the request with the same key returns
100
+ # this answer again.
75
101
  # @raise [Sendly::InsufficientCreditsError] If account has no credits
76
102
  # @raise [Sendly::RateLimitError] If rate limit is exceeded
77
103
  #
@@ -112,6 +138,17 @@ module Sendly
112
138
  # puts message.whatsapp.kind # "template"
113
139
  # puts message.credits_used # priced by country + category
114
140
  #
141
+ # @example WhatsApp with an SMS fallback
142
+ # message = client.messages.send(
143
+ # channel: "whatsapp",
144
+ # to: "+15551234567",
145
+ # from: "+15559876543",
146
+ # text: "Your table is ready!",
147
+ # fallback_to_sms: true
148
+ # )
149
+ # puts message.fallback&.status # "sent" when it went as an SMS
150
+ # puts message.fell_back?
151
+ #
115
152
  # @example RCS text with suggested replies and actions
116
153
  # message = client.messages.send(
117
154
  # channel: "rcs",
@@ -171,6 +208,7 @@ module Sendly
171
208
  body[:mediaUrls] = media_urls if has_media
172
209
  body[:template] = template if template
173
210
  body[:metadata] = metadata if metadata
211
+ body[:fallbackToSms] = fallback_to_sms unless fallback_to_sms.nil?
174
212
 
175
213
  response = client.post("/messages", body, idempotency_key: idempotency_key)
176
214
  return WhatsAppMessage.new(response)
@@ -327,7 +365,7 @@ module Sendly
327
365
  #
328
366
  # @param status [String] Filter by status
329
367
  # @param to [String] Filter by recipient
330
- # @param batch_size [Integer] Number of messages per request
368
+ # @param batch_size [Integer] Number of messages per request (the API returns at most 100)
331
369
  # @yield [Message] Each message
332
370
  # @return [Enumerator] If no block given
333
371
  #
@@ -343,9 +381,9 @@ module Sendly
343
381
  page = list(limit: batch_size, offset: offset, status: status, to: to)
344
382
  page.each(&block)
345
383
 
346
- break unless page.has_more
384
+ break if !page.has_more || page.count.zero?
347
385
 
348
- offset += batch_size
386
+ offset += page.count
349
387
  end
350
388
  end
351
389
 
@@ -446,7 +484,9 @@ module Sendly
446
484
  # @param metadata [Hash] Shared metadata for all messages (max 4KB). Each message can also have its own metadata hash which takes priority.
447
485
  # @param idempotency_key [String] Idempotency key for this operation
448
486
  # (1-255 printable ASCII characters, optional)
449
- # @return [Hash] Batch response with batch_id and status
487
+ # @return [Hash] Batch result: +batchId+, +status+ (processing, completed,
488
+ # partial_failure or failed), +total+, +sent+, +failed+, +creditsUsed+,
489
+ # +creditsRefunded+ and +messages+
450
490
  #
451
491
  # @raise [Sendly::ValidationError] If parameters are invalid
452
492
  # @raise [Sendly::InsufficientCreditsError] If account has insufficient credits
@@ -458,7 +498,7 @@ module Sendly
458
498
  # { to: "+15559876543", text: "Hello Bob!" }
459
499
  # ]
460
500
  # )
461
- # puts "Batch #{result['batchId']}: #{result['queued']} queued"
501
+ # puts "Batch #{result['batchId']}: #{result['total']} messages, #{result['status']}"
462
502
  def send_batch(messages:, from: nil, message_type: nil, metadata: nil, idempotency_key: nil)
463
503
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
464
504
 
@@ -505,12 +545,12 @@ module Sendly
505
545
  #
506
546
  # @param limit [Integer] Maximum batches to return (default: 20, max: 100)
507
547
  # @param offset [Integer] Number of batches to skip
508
- # @param status [String] Filter by status (processing, completed, failed)
548
+ # @param status [String] Filter by status (processing, completed, partial_failure, failed)
509
549
  # @return [Hash] Paginated list of batches
510
550
  #
511
551
  # @example
512
552
  # batches = client.messages.list_batches(limit: 10)
513
- # batches["data"].each { |b| puts "#{b['batchId']}: #{b['status']}" }
553
+ # batches["data"].each { |b| puts "#{b['id']}: #{b['status']}" }
514
554
  def list_batches(limit: 20, offset: 0, status: nil)
515
555
  params = {
516
556
  limit: [limit, 100].min,
@@ -537,7 +577,7 @@ module Sendly
537
577
  # { to: "+15559876543", text: "Hello Bob!" }
538
578
  # ]
539
579
  # )
540
- # puts "Can send: #{preview['canSend']}"
580
+ # puts "Sufficient credits: #{preview['hasSufficientCredits']}"
541
581
  # puts "Credits needed: #{preview['creditsNeeded']}"
542
582
  def preview_batch(messages:, from: nil, message_type: nil)
543
583
  raise ValidationError, "Messages array is required" if messages.nil? || messages.empty?
data/lib/sendly/types.rb CHANGED
@@ -63,6 +63,20 @@ 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
+
72
+ # @return [Sendly::WhatsAppSmsFallback, nil] On a WhatsApp message sent
73
+ # with +fallback_to_sms+, what happened to its SMS fallback
74
+ attr_reader :fallback
75
+
76
+ # @return [String, nil] On an SMS fallback, the id of the WhatsApp
77
+ # message it replaced
78
+ attr_reader :fallback_of
79
+
66
80
  # Message status constants (sending removed - doesn't exist in database)
67
81
  STATUSES = %w[queued sent delivered failed bounced retrying].freeze
68
82
 
@@ -90,6 +104,10 @@ module Sendly
90
104
  @retry_count = data["retryCount"] || 0
91
105
  @metadata = data["metadata"]
92
106
  @ai_metadata = data["aiMetadata"]
107
+ @media_urls = data["mediaUrls"] || data["media_urls"] || []
108
+ @message_format = data["messageFormat"] || data["message_format"] || "sms"
109
+ @fallback = data["fallback"].is_a?(Hash) ? WhatsAppSmsFallback.new(data["fallback"]) : nil
110
+ @fallback_of = data["fallbackOf"] || data["fallback_of"]
93
111
  end
94
112
 
95
113
  # Check if message was delivered
@@ -133,7 +151,11 @@ module Sendly
133
151
  error_code: error_code,
134
152
  retry_count: retry_count,
135
153
  metadata: metadata,
136
- ai_metadata: ai_metadata
154
+ ai_metadata: ai_metadata,
155
+ media_urls: media_urls,
156
+ message_format: message_format,
157
+ fallback: fallback&.to_h,
158
+ fallback_of: fallback_of
137
159
  }.compact
138
160
  end
139
161
 
@@ -155,7 +177,7 @@ module Sendly
155
177
  # @return [Array<Message>] Messages in this page
156
178
  attr_reader :data
157
179
 
158
- # @return [Integer] Total number of messages
180
+ # @return [Integer] Total number of messages that match the query, across all pages
159
181
  attr_reader :total
160
182
 
161
183
  # @return [Integer] Current limit
@@ -168,11 +190,16 @@ module Sendly
168
190
  attr_reader :has_more
169
191
 
170
192
  def initialize(response)
193
+ pagination = response["pagination"] || {}
171
194
  @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
195
+ @total = pagination["total"] || response["total"] || response["count"] || @data.length
196
+ @limit = pagination["limit"] || response["limit"] || 20
197
+ @offset = pagination["offset"] || response["offset"] || 0
198
+ @has_more = if pagination.key?("hasMore")
199
+ pagination["hasMore"]
200
+ else
201
+ (@offset + @data.length) < @total
202
+ end
176
203
  end
177
204
 
178
205
  # Iterate over messages
@@ -433,14 +460,31 @@ module Sendly
433
460
  end
434
461
 
435
462
  # Result of testing a webhook
463
+ #
464
+ # Only a delivered test comes back as a result: when the test delivery
465
+ # fails, {Sendly::WebhooksResource#test} raises {Sendly::ValidationError}
466
+ # with the API's message.
436
467
  class WebhookTestResult
437
468
  attr_reader :success, :status_code, :response_time_ms, :error
438
469
 
470
+ # @return [String, nil] The API's summary of the test delivery
471
+ attr_reader :message
472
+
473
+ # @return [String, nil] ID of the test delivery
474
+ attr_reader :delivery_id
475
+
476
+ # @return [Hash] The raw parsed response
477
+ attr_reader :raw
478
+
439
479
  def initialize(data)
480
+ delivery = data["delivery"] || {}
481
+ @raw = data
440
482
  @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"]
483
+ @status_code = data["status_code"] || data["statusCode"] || delivery["status_code"]
484
+ @response_time_ms = data["response_time_ms"] || data["responseTimeMs"] || delivery["response_time"]
485
+ @error = data["error"] || delivery["error"]
486
+ @message = data["message"]
487
+ @delivery_id = delivery["id"] || delivery["delivery_id"]
444
488
  end
445
489
 
446
490
  def success?
@@ -449,14 +493,49 @@ module Sendly
449
493
  end
450
494
 
451
495
  # Result of rotating webhook secret
496
+ #
497
+ # The API signs deliveries with the new secret from the moment the rotation
498
+ # returns and does not keep the old one, so {#new_secret} is the only secret
499
+ # that verifies deliveries from then on.
452
500
  class WebhookSecretRotation
453
- attr_reader :webhook, :new_secret, :old_secret_expires_at, :message
501
+ # @return [Sendly::Webhook, nil] Always +nil+: the rotation response does not include the webhook
502
+ attr_reader :webhook
503
+
504
+ # @return [String] The new signing secret. It is shown only once.
505
+ attr_reader :new_secret
506
+
507
+ # @return [Time, nil] Always +nil+: the API does not keep the old secret, so it has no expiry
508
+ attr_reader :old_secret_expires_at
509
+
510
+ # @return [String, nil] Confirmation message
511
+ attr_reader :message
512
+
513
+ # @return [String, nil] The webhook's ID
514
+ attr_reader :id
515
+
516
+ # @return [Integer, nil] The webhook's secret version as the API reports it
517
+ attr_reader :new_secret_version
518
+
519
+ # @return [Integer, nil] The grace period the API reports (24). The old
520
+ # secret is not kept, so it does not verify deliveries during it.
521
+ attr_reader :grace_period_hours
522
+
523
+ # @return [Time, nil] When the secret was rotated
524
+ attr_reader :rotated_at
525
+
526
+ # @return [Hash] The raw parsed response
527
+ attr_reader :raw
454
528
 
455
529
  def initialize(data)
456
- @webhook = Webhook.new(data["webhook"])
457
- @new_secret = data["new_secret"] || data["newSecret"]
530
+ @raw = data
531
+ @webhook = data["webhook"] ? Webhook.new(data["webhook"]) : nil
532
+ @new_secret = data["new_secret"] || data["newSecret"] || data["secret"]
458
533
  @old_secret_expires_at = parse_time(data["old_secret_expires_at"] || data["oldSecretExpiresAt"])
459
534
  @message = data["message"]
535
+ @id = data["id"]
536
+ @new_secret_version = data["new_secret_version"] || data["newSecretVersion"]
537
+ @grace_period_hours = data["grace_period_hours"] || data["gracePeriodHours"]
538
+ @rotated_at = parse_time(data["rotated_at"] || data["rotatedAt"])
460
539
  end
461
540
 
462
541
  private
@@ -475,13 +554,56 @@ module Sendly
475
554
 
476
555
  # Represents account information
477
556
  class Account
478
- attr_reader :id, :email, :name, :created_at
557
+ # @return [String, nil] The user's ID
558
+ attr_reader :id
559
+
560
+ # @return [String, nil] The user's email address
561
+ attr_reader :email
562
+
563
+ # @return [String, nil] The name of the workspace the API key belongs to,
564
+ # or +nil+ when the key is not bound to a workspace
565
+ attr_reader :name
566
+
567
+ # @return [Time, nil] When the user signed up
568
+ attr_reader :created_at
569
+
570
+ # @return [Hash, nil] The workspace the API key belongs to
571
+ # (+"id"+, +"name"+, +"isPersonal"+), or +nil+
572
+ attr_reader :organization
573
+
574
+ # @return [String, nil] The workspace's ID, the +organization_id+ in webhook payloads
575
+ attr_reader :organization_id
576
+
577
+ # @return [Hash, nil] Credit balances (+"balance"+, +"reservedBalance"+)
578
+ attr_reader :credits
579
+
580
+ # @return [Hash, nil] Business verification (+"status"+, +"type"+, +"region"+,
581
+ # +"submittedAt"+, +"updatedAt"+), or +nil+ when there is none
582
+ attr_reader :verification
583
+
584
+ # @return [Hash, nil] The API key making the call (+"id"+, +"name"+, +"type"+,
585
+ # +"scopes"+, +"createdAt"+, +"lastUsedAt"+)
586
+ attr_reader :api_key
587
+
588
+ # @return [Hash, nil] Sending limits (+"messagesPerMinute"+, +"messagesPerDay"+)
589
+ attr_reader :limits
590
+
591
+ # @return [Hash] The raw parsed response
592
+ attr_reader :raw
479
593
 
480
594
  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"])
595
+ user = data["user"] || {}
596
+ @raw = data
597
+ @organization = data["organization"]
598
+ @organization_id = @organization && @organization["id"]
599
+ @id = user["id"] || data["id"]
600
+ @email = user["email"] || data["email"]
601
+ @name = (@organization && @organization["name"]) || data["name"]
602
+ @created_at = parse_time(user["createdAt"] || user["created_at"] || data["created_at"] || data["createdAt"])
603
+ @credits = data["credits"]
604
+ @verification = data["verification"]
605
+ @api_key = data["apiKey"] || data["api_key"]
606
+ @limits = data["limits"]
485
607
  end
486
608
 
487
609
  private
@@ -509,8 +631,9 @@ module Sendly
509
631
  class CreditTransaction
510
632
  attr_reader :id, :type, :amount, :balance_after, :description, :message_id, :created_at
511
633
 
512
- # Transaction type constants
513
- TYPES = %w[purchase usage refund adjustment bonus].freeze
634
+ # Transaction type constants. Auto-recharges are recorded as +purchase+;
635
+ # +adjustment+ is never recorded.
636
+ TYPES = %w[purchase usage refund adjustment bonus transfer admin_grant admin_seed].freeze
514
637
 
515
638
  def initialize(data)
516
639
  @id = data["id"]
@@ -542,20 +665,41 @@ module Sendly
542
665
 
543
666
  # Represents an API key
544
667
  class ApiKey
545
- attr_reader :id, :name, :type, :prefix, :last_four, :permissions,
668
+ attr_reader :id, :name, :type, :prefix, :permissions,
546
669
  :created_at, :last_used_at, :expires_at, :is_revoked
547
670
 
671
+ # @return [nil] Always +nil+: no API response carries the last four characters
672
+ attr_reader :last_four
673
+
674
+ # @return [Array<String>] The key's scopes (the same list as {#permissions})
675
+ attr_reader :scopes
676
+
677
+ # @return [Boolean, nil] Whether the key is active, or +nil+ when the response does not say
678
+ attr_reader :is_active
679
+
680
+ # @return [Time, nil] When the key was revoked
681
+ attr_reader :revoked_at
682
+
548
683
  def initialize(data)
549
684
  @id = data["id"]
550
685
  @name = data["name"]
551
686
  @type = data["type"]
552
687
  @prefix = data["prefix"]
553
688
  @last_four = data["last_four"] || data["lastFour"]
554
- @permissions = data["permissions"] || []
689
+ @permissions = data["permissions"] || data["scopes"] || []
690
+ @scopes = data["scopes"] || data["permissions"] || []
555
691
  @created_at = parse_time(data["created_at"] || data["createdAt"])
556
692
  @last_used_at = parse_time(data["last_used_at"] || data["lastUsedAt"])
557
693
  @expires_at = parse_time(data["expires_at"] || data["expiresAt"])
558
- @is_revoked = data["is_revoked"] || data["isRevoked"] || false
694
+ @revoked_at = parse_time(data["revoked_at"] || data["revokedAt"])
695
+ @is_active = data.key?("isActive") ? data["isActive"] : data["is_active"]
696
+ @is_revoked = if data.key?("isRevoked") || data.key?("is_revoked")
697
+ data["isRevoked"] || data["is_revoked"] || false
698
+ elsif !@is_active.nil?
699
+ !@is_active
700
+ else
701
+ !@revoked_at.nil?
702
+ end
559
703
  end
560
704
 
561
705
  def test?
@@ -842,13 +986,20 @@ module Sendly
842
986
 
843
987
  attr_reader :data, :total, :limit, :offset, :has_more
844
988
 
845
- def initialize(response)
989
+ # @param response [Hash] The parsed list response
990
+ # @param limit [Integer, nil] The page size the API used, when the response does not say
991
+ # @param offset [Integer, nil] The offset the API used, when the response does not say
992
+ def initialize(response, limit = nil, offset = nil)
846
993
  @data = (response["data"] || []).map { |d| Draft.new(d) }
847
994
  pagination = response["pagination"] || {}
848
995
  @total = pagination["total"] || @data.length
849
- @limit = pagination["limit"] || 20
850
- @offset = pagination["offset"] || 0
851
- @has_more = pagination["hasMore"] || pagination["has_more"] || false
996
+ @limit = pagination["limit"] || limit || 20
997
+ @offset = pagination["offset"] || offset || 0
998
+ @has_more = if pagination.key?("hasMore") || pagination.key?("has_more")
999
+ pagination["hasMore"] || pagination["has_more"] || false
1000
+ else
1001
+ (@offset + @data.length) < @total
1002
+ end
852
1003
  end
853
1004
 
854
1005
  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.4.0"
5
5
  end
@@ -103,6 +103,11 @@ module Sendly
103
103
  EVENT_SHORT_CODE_REJECTED = "short_code.rejected"
104
104
  EVENT_SHORT_CODE_FILED = "short_code.filed"
105
105
  EVENT_SHORT_CODE_LIVE = "short_code.live"
106
+ EVENT_SHORT_CODE_SUSPENDED = "short_code.suspended"
107
+ EVENT_SHORT_CODE_REACTIVATED = "short_code.reactivated"
108
+ EVENT_SHORT_CODE_CANCELLED = "short_code.cancelled"
109
+ EVENT_SHORT_CODE_PAYMENT_SUCCEEDED = "short_code.payment_succeeded"
110
+ EVENT_SHORT_CODE_PAYMENT_FAILED = "short_code.payment_failed"
106
111
 
107
112
  # Source of a list-health event. Frozen enum — new values will be
108
113
  # added in minor SDK versions, never removed.