sendly 3.27.1 → 3.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68163da427953fe0e6323698c1f535ff209375de0d3ef39636a6baa058ec2e73
4
- data.tar.gz: 51e7b707e397c788fa7d93360ff20970df48bd9c89510c3acc8c55ecf68e3f9a
3
+ metadata.gz: 0aa8097776c931f504cc3f006783ed8232691fc48c35c93b8930437c9f5480b5
4
+ data.tar.gz: 74cfe38cea2edefae5a4173dbbae2ff4df2a3364e69de2329191ef95fd26195b
5
5
  SHA512:
6
- metadata.gz: 4885dadab414fb8f67f61eb2bd333294081ccb7c2db7c26be3d6e2ebfa283c6318fae433e23d1de126d87108bc1d0970c680607df6d025ef4fa473eb64a463e8
7
- data.tar.gz: 992233c97e17dcb0af07df5caf61e4031fb1bf378fb653e6dba158ea831e4d5ed18917a4429683daf77cda6783893e78486b01fde6dd7d51303d8feaa8d79951
6
+ metadata.gz: 40d6bb9e8df8757fa585253b39b49e85973f7959f1278b8354c803b293047bb0dbf279dafd5c5550e61d4b6a26ed78ada5295ab686a312dc32f026d46ff7203f
7
+ data.tar.gz: b4ee727e07b7fc3fa75326e36ae073c62d50e6d3c98b5905c745444c80d6dd358ab19a5ed36c0853af3c07eba7fbba50d6db7389f469c3555abf15d6960b5742
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # sendly (Ruby)
2
2
 
3
+ ## 3.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - `contacts.bulk_mark_valid(ids: ..., list_id: ...)`: clear the invalid flag on many contacts at once (up to 10,000 per call). Escape hatch for when auto-mark misclassifies at scale.
8
+ - Four new list-health webhook event constants in `Sendly::Webhooks`: `EVENT_CONTACT_AUTO_FLAGGED`, `EVENT_CONTACT_MARKED_VALID`, `EVENT_CONTACTS_LOOKUP_COMPLETED`, `EVENT_CONTACTS_BULK_MARKED_VALID`.
9
+ - New `Sendly::Webhooks::ListHealthEventSource` module with frozen constants (`SEND_FAILURE | CARRIER_LOOKUP | USER_ACTION | BULK_MARK_VALID`) for the `source` field on auto-flag and mark-valid webhooks.
10
+ - `Contact` gains `user_marked_valid_at` — when a user manually cleared an auto-flag. Carrier re-checks respect this timestamp and leave the contact clean.
11
+
12
+ ## 3.28.0
13
+
14
+ ### Minor Changes
15
+
16
+ - `contacts.mark_valid(id)`: clear the auto-exclusion flag on a contact.
17
+ - `contacts.check_numbers(list_id: nil, force: false)`: trigger a background carrier lookup.
18
+ - `Contact` gains `line_type`, `carrier_name`, `line_type_checked_at`, `invalid_reason`, `invalidated_at` plus `invalid?` helper.
19
+
3
20
  ## 3.18.1
4
21
 
5
22
  ### Patch Changes
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sendly (3.27.1)
4
+ sendly (3.30.0)
5
5
  faraday (~> 2.0)
6
6
  faraday-retry (~> 2.0)
7
7
 
data/README.md CHANGED
@@ -354,7 +354,7 @@ message.pending? # => true/false
354
354
 
355
355
  | Tier | Countries | Credits per SMS |
356
356
  |------|-----------|-----------------|
357
- | Domestic | US, CA | 1 |
357
+ | Domestic | US, CA | 2 |
358
358
  | Tier 1 | GB, PL, IN, etc. | 8 |
359
359
  | Tier 2 | FR, JP, AU, etc. | 12 |
360
360
  | Tier 3 | DE, IT, MX, etc. | 16 |
@@ -3,6 +3,8 @@
3
3
  module Sendly
4
4
  class Contact
5
5
  attr_reader :id, :phone_number, :name, :email, :metadata, :opted_out,
6
+ :line_type, :carrier_name, :line_type_checked_at,
7
+ :invalid_reason, :invalidated_at, :user_marked_valid_at,
6
8
  :created_at, :updated_at, :lists
7
9
 
8
10
  def initialize(data)
@@ -12,6 +14,12 @@ module Sendly
12
14
  @email = data["email"]
13
15
  @metadata = data["metadata"] || {}
14
16
  @opted_out = data["opted_out"] || data["optedOut"] || false
17
+ @line_type = data["line_type"] || data["lineType"]
18
+ @carrier_name = data["carrier_name"] || data["carrierName"]
19
+ @line_type_checked_at = parse_time(data["line_type_checked_at"] || data["lineTypeCheckedAt"])
20
+ @invalid_reason = data["invalid_reason"] || data["invalidReason"]
21
+ @invalidated_at = parse_time(data["invalidated_at"] || data["invalidatedAt"])
22
+ @user_marked_valid_at = parse_time(data["user_marked_valid_at"] || data["userMarkedValidAt"])
15
23
  @created_at = parse_time(data["created_at"] || data["createdAt"])
16
24
  @updated_at = parse_time(data["updated_at"] || data["updatedAt"])
17
25
  @lists = data["lists"]&.map { |l| { id: l["id"], name: l["name"] } }
@@ -21,10 +29,19 @@ module Sendly
21
29
  opted_out
22
30
  end
23
31
 
32
+ def invalid?
33
+ !invalid_reason.nil?
34
+ end
35
+
24
36
  def to_h
25
37
  {
26
38
  id: id, phone_number: phone_number, name: name, email: email,
27
39
  metadata: metadata, opted_out: opted_out,
40
+ line_type: line_type, carrier_name: carrier_name,
41
+ line_type_checked_at: line_type_checked_at&.iso8601,
42
+ invalid_reason: invalid_reason,
43
+ invalidated_at: invalidated_at&.iso8601,
44
+ user_marked_valid_at: user_marked_valid_at&.iso8601,
28
45
  created_at: created_at&.iso8601, updated_at: updated_at&.iso8601,
29
46
  lists: lists
30
47
  }.compact
@@ -186,6 +203,45 @@ module Sendly
186
203
  @client.delete("/contacts/#{id}")
187
204
  end
188
205
 
206
+ # Clear the invalid flag on a contact so future campaigns include it again.
207
+ # Contacts get auto-flagged when a send fails with a terminal bad-number
208
+ # error (landline, invalid number) or when a carrier lookup reports they
209
+ # can't receive SMS.
210
+ def mark_valid(id)
211
+ response = @client.post("/contacts/#{id}/mark-valid", {})
212
+ Contact.new(response)
213
+ end
214
+
215
+ # Clear the invalid flag on many contacts at once — the escape hatch for
216
+ # when auto-flag misclassifies at scale. Pass either an explicit id array
217
+ # (up to 10,000 per call) OR a list_id, not both. Foreign ids silently
218
+ # no-op via the per-organization filter.
219
+ #
220
+ # @param ids [Array<String>, nil] Explicit contact ids to clear
221
+ # @param list_id [String, nil] Clear every flagged member of this list
222
+ # @return [Hash] `{ cleared: Integer }` — number of contacts whose flag was
223
+ # actually cleared. Already-clean contacts and foreign ids don't count.
224
+ def bulk_mark_valid(ids: nil, list_id: nil)
225
+ if ids.nil? && list_id.nil?
226
+ raise ArgumentError, "bulk_mark_valid requires either :ids or :list_id"
227
+ end
228
+ if ids && list_id
229
+ raise ArgumentError, "bulk_mark_valid accepts :ids OR :list_id, not both"
230
+ end
231
+
232
+ body = ids ? { ids: ids } : { listId: list_id }
233
+ response = @client.post("/contacts/bulk-mark-valid", body)
234
+ { cleared: response["cleared"] || 0 }
235
+ end
236
+
237
+ # Trigger a background carrier lookup across your contacts. Landlines
238
+ # and other non-SMS-capable numbers are auto-excluded from future
239
+ # campaigns. The lookup runs asynchronously (1-5 minutes).
240
+ # Options: list_id (scope to a single list), force (re-check already-looked-up)
241
+ def check_numbers(list_id: nil, force: false)
242
+ @client.post("/contacts/lookup", { listId: list_id, force: force })
243
+ end
244
+
189
245
  def import_contacts(contacts, list_id: nil, opted_in_at: nil)
190
246
  body = {
191
247
  contacts: contacts.map { |c|
data/lib/sendly/verify.rb CHANGED
@@ -3,28 +3,25 @@
3
3
  module Sendly
4
4
  class Verification
5
5
  attr_reader :id, :status, :phone, :delivery_status, :attempts, :max_attempts,
6
- :channel, :expires_at, :verified_at, :created_at, :sandbox,
7
- :app_name, :template_id, :profile_id, :metadata
6
+ :expires_at, :verified_at, :created_at, :sandbox,
7
+ :app_name, :template_id, :profile_id
8
8
 
9
9
  STATUSES = %w[pending verified expired failed].freeze
10
- CHANNELS = %w[sms whatsapp email].freeze
11
10
 
12
11
  def initialize(data)
13
12
  @id = data["id"]
14
13
  @status = data["status"]
15
14
  @phone = data["phone"]
16
- @delivery_status = data["deliveryStatus"] || data["delivery_status"]
15
+ @delivery_status = data["delivery_status"]
17
16
  @attempts = data["attempts"] || 0
18
- @max_attempts = data["maxAttempts"] || data["max_attempts"] || 3
19
- @channel = data["channel"] || "sms"
20
- @expires_at = parse_time(data["expiresAt"] || data["expires_at"])
21
- @verified_at = parse_time(data["verifiedAt"] || data["verified_at"])
22
- @created_at = parse_time(data["createdAt"] || data["created_at"])
17
+ @max_attempts = data["max_attempts"] || 3
18
+ @expires_at = parse_time(data["expires_at"])
19
+ @verified_at = parse_time(data["verified_at"])
20
+ @created_at = parse_time(data["created_at"])
23
21
  @sandbox = data["sandbox"] || false
24
- @app_name = data["appName"] || data["app_name"]
25
- @template_id = data["templateId"] || data["template_id"]
26
- @profile_id = data["profileId"] || data["profile_id"]
27
- @metadata = data["metadata"] || {}
22
+ @app_name = data["app_name"]
23
+ @template_id = data["template_id"]
24
+ @profile_id = data["profile_id"]
28
25
  end
29
26
 
30
27
  def pending?
@@ -46,10 +43,10 @@ module Sendly
46
43
  def to_h
47
44
  {
48
45
  id: id, status: status, phone: phone, delivery_status: delivery_status,
49
- attempts: attempts, max_attempts: max_attempts, channel: channel,
46
+ attempts: attempts, max_attempts: max_attempts,
50
47
  expires_at: expires_at&.iso8601, verified_at: verified_at&.iso8601,
51
48
  created_at: created_at&.iso8601, sandbox: sandbox, app_name: app_name,
52
- template_id: template_id, profile_id: profile_id, metadata: metadata
49
+ template_id: template_id, profile_id: profile_id
53
50
  }.compact
54
51
  end
55
52
 
@@ -64,25 +61,32 @@ module Sendly
64
61
  end
65
62
 
66
63
  class SendVerificationResponse
67
- attr_reader :verification, :code
64
+ attr_reader :id, :status, :phone, :expires_at, :sandbox, :sandbox_code, :message
68
65
 
69
66
  def initialize(data)
70
- @verification = Verification.new(data["verification"])
71
- @code = data["code"]
67
+ @id = data["id"]
68
+ @status = data["status"]
69
+ @phone = data["phone"]
70
+ @expires_at = data["expires_at"]
71
+ @sandbox = data["sandbox"] || false
72
+ @sandbox_code = data["sandbox_code"]
73
+ @message = data["message"]
72
74
  end
73
75
  end
74
76
 
75
77
  class CheckVerificationResponse
76
- attr_reader :valid, :status, :verification
78
+ attr_reader :id, :status, :phone, :verified_at, :remaining_attempts
77
79
 
78
80
  def initialize(data)
79
- @valid = data["valid"]
81
+ @id = data["id"]
80
82
  @status = data["status"]
81
- @verification = data["verification"] ? Verification.new(data["verification"]) : nil
83
+ @phone = data["phone"]
84
+ @verified_at = data["verified_at"]
85
+ @remaining_attempts = data["remaining_attempts"]
82
86
  end
83
87
 
84
- def valid?
85
- valid
88
+ def verified?
89
+ status == "verified"
86
90
  end
87
91
  end
88
92
 
@@ -163,18 +167,14 @@ module Sendly
163
167
  @sessions = SessionsResource.new(client)
164
168
  end
165
169
 
166
- def send(phone:, channel: nil, code_length: nil, expires_in: nil, max_attempts: nil,
167
- template_id: nil, profile_id: nil, app_name: nil, locale: nil, metadata: nil)
168
- body = { to: phone }
169
- body[:channel] = channel if channel
170
- body[:codeLength] = code_length if code_length
171
- body[:expiresIn] = expires_in if expires_in
172
- body[:maxAttempts] = max_attempts if max_attempts
173
- body[:templateId] = template_id if template_id
174
- body[:profileId] = profile_id if profile_id
175
- body[:appName] = app_name if app_name
176
- body[:locale] = locale if locale
177
- body[:metadata] = metadata if metadata
170
+ def send(to:, template_id: nil, profile_id: nil, app_name: nil,
171
+ timeout_secs: nil, code_length: nil)
172
+ body = { to: to }
173
+ body[:template_id] = template_id if template_id
174
+ body[:profile_id] = profile_id if profile_id
175
+ body[:app_name] = app_name if app_name
176
+ body[:timeout_secs] = timeout_secs if timeout_secs
177
+ body[:code_length] = code_length if code_length
178
178
 
179
179
  response = @client.post("/verify", body)
180
180
  SendVerificationResponse.new(response)
@@ -195,11 +195,10 @@ module Sendly
195
195
  Verification.new(response)
196
196
  end
197
197
 
198
- def list(limit: nil, status: nil, phone: nil)
198
+ def list(limit: nil, status: nil)
199
199
  params = {}
200
200
  params[:limit] = limit if limit
201
201
  params[:status] = status if status
202
- params[:phone] = phone if phone
203
202
 
204
203
  response = @client.get("/verify", params)
205
204
  verifications = (response["verifications"] || []).map { |v| Verification.new(v) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendly
4
- VERSION = "3.27.0"
4
+ VERSION = "3.30.0"
5
5
  end
@@ -34,6 +34,40 @@ module Sendly
34
34
  module Webhooks
35
35
  SIGNATURE_TOLERANCE_SECONDS = 300
36
36
 
37
+ # Webhook event type string constants. Use these when subscribing
38
+ # instead of string literals so you catch typos at load time.
39
+ EVENT_MESSAGE_QUEUED = "message.queued"
40
+ EVENT_MESSAGE_SENT = "message.sent"
41
+ EVENT_MESSAGE_DELIVERED = "message.delivered"
42
+ EVENT_MESSAGE_FAILED = "message.failed"
43
+ EVENT_MESSAGE_BOUNCED = "message.bounced"
44
+ EVENT_MESSAGE_RETRYING = "message.retrying"
45
+ EVENT_MESSAGE_RECEIVED = "message.received"
46
+ EVENT_MESSAGE_OPT_OUT = "message.opt_out"
47
+ EVENT_MESSAGE_OPT_IN = "message.opt_in"
48
+ EVENT_VERIFICATION_CREATED = "verification.created"
49
+ EVENT_VERIFICATION_DELIVERED = "verification.delivered"
50
+ EVENT_VERIFICATION_VERIFIED = "verification.verified"
51
+ EVENT_VERIFICATION_EXPIRED = "verification.expired"
52
+ EVENT_VERIFICATION_FAILED = "verification.failed"
53
+ EVENT_VERIFICATION_RESENT = "verification.resent"
54
+ EVENT_VERIFICATION_DELIVERY_FAILED = "verification.delivery_failed"
55
+ EVENT_CONTACT_AUTO_FLAGGED = "contact.auto_flagged"
56
+ EVENT_CONTACT_MARKED_VALID = "contact.marked_valid"
57
+ EVENT_CONTACTS_LOOKUP_COMPLETED = "contacts.lookup_completed"
58
+ EVENT_CONTACTS_BULK_MARKED_VALID = "contacts.bulk_marked_valid"
59
+
60
+ # Source of a list-health event. Frozen enum — new values will be
61
+ # added in minor SDK versions, never removed.
62
+ module ListHealthEventSource
63
+ SEND_FAILURE = "send_failure"
64
+ CARRIER_LOOKUP = "carrier_lookup"
65
+ USER_ACTION = "user_action"
66
+ BULK_MARK_VALID = "bulk_mark_valid"
67
+
68
+ ALL = [SEND_FAILURE, CARRIER_LOOKUP, USER_ACTION, BULK_MARK_VALID].freeze
69
+ end
70
+
37
71
  class << self
38
72
  # Verify webhook signature from Sendly.
39
73
  #
@@ -110,6 +110,56 @@ module Sendly
110
110
  @client.post("/webhooks/#{webhook_id}/reset-circuit")
111
111
  end
112
112
 
113
+ # Replay failed or cancelled webhook deliveries from the audit log.
114
+ #
115
+ # Use after a customer endpoint has recovered from an outage to re-fire
116
+ # deliveries we recorded but couldn't deliver. Each replay creates a new
117
+ # delivery row preserving the original event_id so customers can dedupe.
118
+ # Rejects with HTTP 409 if the circuit is currently open — call
119
+ # {#reset_circuit} first.
120
+ #
121
+ # @param webhook_id [String] Webhook ID
122
+ # @param since [String, nil] ISO-8601, default now − 24h
123
+ # @param until_ [String, nil] ISO-8601, default now
124
+ # @param event_types [Array<String>, nil] Filter by event type
125
+ # @param statuses [Array<String>, nil] Default ["failed", "cancelled"]
126
+ # @param limit [Integer, nil] Max deliveries to requeue (default 1000, max 10000)
127
+ # @return [Hash] Counts of requeued deliveries plus delivery IDs
128
+ def redeliver(webhook_id, since: nil, until_: nil, event_types: nil, statuses: nil, limit: nil)
129
+ validate_webhook_id!(webhook_id)
130
+ body = {}
131
+ body[:since] = since unless since.nil?
132
+ body[:until] = until_ unless until_.nil?
133
+ body[:event_types] = event_types unless event_types.nil?
134
+ body[:statuses] = statuses unless statuses.nil?
135
+ body[:limit] = limit unless limit.nil?
136
+ @client.post("/webhooks/#{webhook_id}/redeliver", body)
137
+ end
138
+
139
+ # Backfill missed webhook events from the underlying message log.
140
+ #
141
+ # 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).
144
+ # Rejects with HTTP 409 if the circuit is currently open — call
145
+ # {#reset_circuit} first.
146
+ #
147
+ # @param webhook_id [String] Webhook ID
148
+ # @param since [String, nil] ISO-8601, default now − 24h
149
+ # @param until_ [String, nil] ISO-8601, default now
150
+ # @param event_types [Array<String>, nil] Filter by event type
151
+ # @param limit [Integer, nil] Max events to synthesize (default 1000, max 10000)
152
+ # @return [Hash] Counts grouped by event type plus delivery IDs
153
+ def backfill(webhook_id, since: nil, until_: nil, event_types: nil, limit: nil)
154
+ validate_webhook_id!(webhook_id)
155
+ body = {}
156
+ body[:since] = since unless since.nil?
157
+ body[:until] = until_ unless until_.nil?
158
+ body[:event_types] = event_types unless event_types.nil?
159
+ body[:limit] = limit unless limit.nil?
160
+ @client.post("/webhooks/#{webhook_id}/backfill", body)
161
+ end
162
+
113
163
  # Rotate the webhook signing secret
114
164
  #
115
165
  # @param webhook_id [String] Webhook ID
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendly
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.27.1
4
+ version: 3.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sendly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-06 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday