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.
@@ -4,16 +4,38 @@ module Sendly
4
4
  # A newly started WhatsApp signup. Hand +connect_url+ to a human — they
5
5
  # open it in a browser and log in with Facebook to link their WhatsApp
6
6
  # Business Account. Poll {WhatsAppSignupResource#get} with +id+ until the
7
- # status is +"active"+.
7
+ # status is +"active"+. +STATUSES+ keeps +"expired"+ for compatibility;
8
+ # the API does not send it.
9
+ #
10
+ # A number added to an already-connected WhatsApp Business Account (a
11
+ # +business_account_id+ passed to {WhatsAppSignupResource#create}) has no
12
+ # +connect_url+: its status is +"verifying"+ while Meta sends the number a
13
+ # code, and +phone_number+, +business_account_id+, +verification_method+
14
+ # (+"sms"+ or +"voice"+) and +verification_attempts_remaining+ are set.
15
+ # +updated_at+ is when the session last changed. The status can also be
16
+ # +"failed"+, with the reason in +failure_reasons+ (for example
17
+ # +["verification_start_failed"]+), when a concurrent request for the same
18
+ # number failed the session before this call returned it. Submit the code
19
+ # with {WhatsAppSignupResource#verify}. These readers are nil on a
20
+ # Facebook signup.
8
21
  class WhatsAppSignupSession
9
- attr_reader :id, :connect_url, :status
22
+ attr_reader :id, :connect_url, :status, :phone_number, :business_account_id,
23
+ :failure_reasons, :updated_at, :verification_method,
24
+ :verification_attempts_remaining
10
25
 
11
- STATUSES = %w[initiated registering active failed expired].freeze
26
+ STATUSES = %w[initiated registering verifying active failed expired].freeze
12
27
 
13
28
  def initialize(data)
14
29
  @id = data["id"]
15
30
  @connect_url = data["connectUrl"] || data["connect_url"]
16
31
  @status = data["status"]
32
+ @phone_number = data["phoneNumber"] || data["phone_number"]
33
+ @business_account_id = data["businessAccountId"] || data["business_account_id"]
34
+ @failure_reasons = data["failureReasons"] || data["failure_reasons"]
35
+ @updated_at = data["updatedAt"] || data["updated_at"]
36
+ @verification_method = data["verificationMethod"] || data["verification_method"]
37
+ @verification_attempts_remaining =
38
+ data["verificationAttemptsRemaining"] || data["verification_attempts_remaining"]
17
39
  end
18
40
 
19
41
  def active?
@@ -24,19 +46,60 @@ module Sendly
24
46
  status == "failed"
25
47
  end
26
48
 
49
+ def verifying?
50
+ status == "verifying"
51
+ end
52
+
27
53
  def to_h
28
- { id: id, connect_url: connect_url, status: status }.compact
54
+ {
55
+ id: id, connect_url: connect_url, status: status, phone_number: phone_number,
56
+ business_account_id: business_account_id, failure_reasons: failure_reasons,
57
+ updated_at: updated_at, verification_method: verification_method,
58
+ verification_attempts_remaining: verification_attempts_remaining
59
+ }.compact
29
60
  end
30
61
  end
31
62
 
32
- # The status of a WhatsApp signup. +business_account_id+ is nil until the
33
- # human completes the connect step; +failure_reasons+ is set when the
34
- # status is +"failed"+.
63
+ # The status of a WhatsApp signup. +status+ is +"initiated"+,
64
+ # +"registering"+ (WhatsApp is activating the number; activation usually
65
+ # takes a few minutes but can take hours, and a session that hasn't
66
+ # finished about 6 hours after it began fails with +registration_timeout+
67
+ # and the fee is refunded), +"verifying"+ (a number being added to an
68
+ # already-connected account, waiting for its code), +"active"+ or
69
+ # +"failed"+. The API does not send +"expired"+,
70
+ # so {#expired?} is never true; it stays for compatibility.
71
+ # +business_account_id+ is set only while the status is +"verifying"+ or
72
+ # +"active"+ (from the start for a number added by code); it is nil while
73
+ # +"initiated"+ or +"registering"+ and after a failure.
74
+ # +failure_reasons+ is set when the status is +"failed"+, with one code:
75
+ # +setup_fee_payment_failed+, +signup_abandoned+, +meta_exchange_failed+,
76
+ # +registration_failed+, +waba_already_connected+, +waba_mismatch+ (the
77
+ # WhatsApp Business Account chosen in the Facebook step doesn't hold the
78
+ # verified number), +registration_timeout+ (activation hadn't finished
79
+ # about 6 hours after the session began), +phone_number_mismatch+,
80
+ # +verification_start_failed+ (WhatsApp couldn't start verifying an added
81
+ # number), +verification_failed+ (too many wrong codes) or
82
+ # +verification_expired+ (a verifying session left untouched for an hour).
83
+ # If the connection fails, the $19 fee is refunded automatically.
84
+ #
85
+ # While the status is +"verifying"+, +verification_method+ (+"sms"+ or
86
+ # +"voice"+) and +verification_attempts_remaining+ are set, and on
87
+ # {WhatsAppSignupResource#get} +verification_code+ is the code once Meta's
88
+ # text has arrived on the number (nil until then). Until a code has been
89
+ # submitted, it is the newest code that has arrived since the signup
90
+ # started, so after a resend it still shows the earlier code until the new
91
+ # one arrives. Once WhatsApp has checked a code, only a code that arrived
92
+ # after the last submission or resend is returned. A submission answered
93
+ # with 502 +whatsapp_verification_unavailable+ is not counted, so the same
94
+ # unchecked code can come back, and submitting it again with
95
+ # {WhatsAppSignupResource#verify} is safe. All three are nil in any other
96
+ # status.
35
97
  class WhatsAppSignup
36
98
  attr_reader :id, :status, :phone_number, :business_account_id,
37
- :failure_reasons, :updated_at
99
+ :failure_reasons, :updated_at, :verification_method,
100
+ :verification_attempts_remaining, :verification_code
38
101
 
39
- STATUSES = %w[initiated registering active failed expired].freeze
102
+ STATUSES = %w[initiated registering verifying active failed expired].freeze
40
103
 
41
104
  def initialize(data)
42
105
  @id = data["id"]
@@ -45,6 +108,10 @@ module Sendly
45
108
  @business_account_id = data["businessAccountId"] || data["business_account_id"]
46
109
  @failure_reasons = data["failureReasons"] || data["failure_reasons"]
47
110
  @updated_at = data["updatedAt"] || data["updated_at"]
111
+ @verification_method = data["verificationMethod"] || data["verification_method"]
112
+ @verification_attempts_remaining =
113
+ data["verificationAttemptsRemaining"] || data["verification_attempts_remaining"]
114
+ @verification_code = data["verificationCode"] || data["verification_code"]
48
115
  end
49
116
 
50
117
  def active?
@@ -55,6 +122,10 @@ module Sendly
55
122
  status == "failed"
56
123
  end
57
124
 
125
+ def verifying?
126
+ status == "verifying"
127
+ end
128
+
58
129
  def expired?
59
130
  status == "expired"
60
131
  end
@@ -63,7 +134,10 @@ module Sendly
63
134
  {
64
135
  id: id, status: status, phone_number: phone_number,
65
136
  business_account_id: business_account_id,
66
- failure_reasons: failure_reasons, updated_at: updated_at
137
+ failure_reasons: failure_reasons, updated_at: updated_at,
138
+ verification_method: verification_method,
139
+ verification_attempts_remaining: verification_attempts_remaining,
140
+ verification_code: verification_code
67
141
  }.compact
68
142
  end
69
143
  end
@@ -71,10 +145,20 @@ module Sendly
71
145
  # A number connected (or connecting) to WhatsApp. +display_name+ is the
72
146
  # name recipients see — chosen during the connect flow and reviewed by
73
147
  # Meta; nil until set. +quality_rating+ is Meta's rating (e.g. "GREEN"),
74
- # nil before the first rating.
148
+ # nil before the first rating. +business_account_id+ is the WhatsApp
149
+ # Business Account the number belongs to (pass it to
150
+ # {WhatsAppSignupResource#create} to add another number to it) and
151
+ # +business_name+ that account's business name; both are nil while the
152
+ # sender is +"pending"+. +calling_enabled+ says whether WhatsApp calling
153
+ # is on (see {WhatsAppSendersResource#set_calling}), and
154
+ # +outbound_calling_allowed+ is false for every +1 number (the US, Canada
155
+ # and the rest of the North American numbering plan) and for +20 (Egypt),
156
+ # +84 (Vietnam) and +234 (Nigeria) numbers, where Meta forbids
157
+ # business-initiated calls.
75
158
  class WhatsAppSender
76
159
  attr_reader :phone_number, :display_name, :status, :quality_rating,
77
- :created_at
160
+ :created_at, :business_account_id, :business_name,
161
+ :calling_enabled, :outbound_calling_allowed
78
162
 
79
163
  STATUSES = %w[pending active suspended].freeze
80
164
 
@@ -84,6 +168,19 @@ module Sendly
84
168
  @status = data["status"]
85
169
  @quality_rating = data["qualityRating"] || data["quality_rating"]
86
170
  @created_at = data["createdAt"] || data["created_at"]
171
+ @business_account_id = data["businessAccountId"] || data["business_account_id"]
172
+ @business_name = data["businessName"] || data["business_name"]
173
+ @calling_enabled = data.key?("callingEnabled") ? data["callingEnabled"] : data["calling_enabled"]
174
+ @outbound_calling_allowed =
175
+ data.key?("outboundCallingAllowed") ? data["outboundCallingAllowed"] : data["outbound_calling_allowed"]
176
+ end
177
+
178
+ def calling_enabled?
179
+ calling_enabled == true
180
+ end
181
+
182
+ def outbound_calling_allowed?
183
+ outbound_calling_allowed == true
87
184
  end
88
185
 
89
186
  def pending?
@@ -101,15 +198,19 @@ module Sendly
101
198
  def to_h
102
199
  {
103
200
  phone_number: phone_number, display_name: display_name,
104
- status: status, quality_rating: quality_rating, created_at: created_at
201
+ status: status, quality_rating: quality_rating, created_at: created_at,
202
+ business_account_id: business_account_id, business_name: business_name,
203
+ calling_enabled: calling_enabled, outbound_calling_allowed: outbound_calling_allowed
105
204
  }.compact
106
205
  end
107
206
  end
108
207
 
109
208
  # A sender's WhatsApp business profile — what recipients see when they
110
209
  # open the sender's details in WhatsApp. Unset fields are nil.
111
- # +profile_photo_url+ is read-only here: the photo cannot be set through
112
- # {WhatsAppSendersResource#update_profile}.
210
+ # +profile_photo_url+ cannot be set through
211
+ # {WhatsAppSendersResource#update_profile}; upload the photo with
212
+ # {WhatsAppSendersResource#upload_profile_photo} and remove it with
213
+ # {WhatsAppSendersResource#delete_profile_photo}.
113
214
  class WhatsAppSenderProfile
114
215
  attr_reader :phone_number, :display_name, :profile_photo_url, :category,
115
216
  :about, :description, :email, :website, :address
@@ -136,11 +237,78 @@ module Sendly
136
237
  end
137
238
  end
138
239
 
240
+ # A command shown to a customer who types "/" in a chat with the sender.
241
+ # +command+ is the name without the leading slash.
242
+ class WhatsAppCommand
243
+ attr_reader :command, :description
244
+
245
+ def initialize(data)
246
+ @command = data["command"]
247
+ @description = data["description"]
248
+ end
249
+
250
+ def to_h
251
+ { command: command, description: description }.compact
252
+ end
253
+ end
254
+
255
+ # A sender's conversational components. +ice_breakers+ are the tappable
256
+ # suggestions shown when someone opens a chat with the business for the
257
+ # first time; +commands+ ({WhatsAppCommand}) are shown when the customer
258
+ # types "/". Both are empty lists when none are set.
259
+ class WhatsAppConversationalComponents
260
+ attr_reader :phone_number, :ice_breakers, :commands
261
+
262
+ def initialize(data)
263
+ @phone_number = data["phoneNumber"] || data["phone_number"]
264
+ @ice_breakers = data["iceBreakers"] || data["ice_breakers"] || []
265
+ @commands = (data["commands"] || []).map { |c| WhatsAppCommand.new(c) }
266
+ end
267
+
268
+ def to_h
269
+ {
270
+ phone_number: phone_number, ice_breakers: ice_breakers,
271
+ commands: commands.map(&:to_h)
272
+ }.compact
273
+ end
274
+ end
275
+
276
+ # Whether WhatsApp calling is on for a sender. +outbound_calling_allowed+
277
+ # is false for every +1 number (the US, Canada and the rest of the North
278
+ # American numbering plan) and for +20 (Egypt), +84 (Vietnam) and +234
279
+ # (Nigeria) numbers, where Meta forbids business-initiated calls.
280
+ class WhatsAppCallingSettings
281
+ attr_reader :phone_number, :calling_enabled, :outbound_calling_allowed
282
+
283
+ def initialize(data)
284
+ @phone_number = data["phoneNumber"] || data["phone_number"]
285
+ @calling_enabled = data.key?("callingEnabled") ? data["callingEnabled"] : data["calling_enabled"]
286
+ @outbound_calling_allowed =
287
+ data.key?("outboundCallingAllowed") ? data["outboundCallingAllowed"] : data["outbound_calling_allowed"]
288
+ end
289
+
290
+ def calling_enabled?
291
+ calling_enabled == true
292
+ end
293
+
294
+ def outbound_calling_allowed?
295
+ outbound_calling_allowed == true
296
+ end
297
+
298
+ def to_h
299
+ {
300
+ phone_number: phone_number, calling_enabled: calling_enabled,
301
+ outbound_calling_allowed: outbound_calling_allowed
302
+ }.compact
303
+ end
304
+ end
305
+
139
306
  # A WhatsApp message template. Meta reviews every template (usually
140
307
  # 24-48h) and may reclassify its category — the category on the record
141
308
  # is what drives per-message pricing. +rejection_reason+ is set when the
142
309
  # status is +"REJECTED"+; +warnings+ carries non-blocking submission
143
- # warnings on create responses.
310
+ # warnings on create responses. +STATUSES+ lists the common review
311
+ # statuses; Meta may report others, which come through in uppercase.
144
312
  class WhatsAppTemplate
145
313
  attr_reader :id, :name, :language, :category, :status, :quality_rating,
146
314
  :rejection_reason, :created_at, :updated_at, :warnings
@@ -203,7 +371,11 @@ module Sendly
203
371
 
204
372
  # Whether a 24-hour customer-service window is open between one of your
205
373
  # WhatsApp senders and a recipient. +expires_at+ is when the window
206
- # closes (ISO 8601), nil when no window is open.
374
+ # closes (ISO 8601). After it closes this is the past expiry, with +open+
375
+ # false. It is nil when Sendly has no window on record for the pair; a
376
+ # free-form send may still go through then if WhatsApp reports an open
377
+ # window, and otherwise fails with +whatsapp_window_closed+. The API
378
+ # returns exactly +{ open, expiresAt }+.
207
379
  class WhatsAppWindow
208
380
  attr_reader :open, :expires_at
209
381
 
@@ -248,12 +420,70 @@ module Sendly
248
420
  end
249
421
  end
250
422
 
423
+ # The SMS fallback of a WhatsApp message sent with +fallback_to_sms: true+.
424
+ # +status+ is +"pending"+ until WhatsApp's outcome is known, +"sent"+ when
425
+ # the SMS went, +"failed"+ when the SMS was refused or credits ran out,
426
+ # +"unavailable"+ when the SMS could no longer be sent from this number,
427
+ # and +"skipped"+ when the WhatsApp failure isn't one an SMS fixes.
428
+ # +reason+ is what made the message fall back (+"window_closed"+,
429
+ # +"whatsapp_refused"+ or +"whatsapp_failed"+). +message_id+ is the SMS
430
+ # message's id once it exists, +credits_used+ what the SMS cost, +error+
431
+ # why the SMS didn't go, in plain English, and +content_dropped+ the
432
+ # template parts with no SMS form, such as +"quick_replies"+. Values the
433
+ # API adds later come through unchanged.
434
+ class WhatsAppSmsFallback
435
+ attr_reader :channel, :status, :reason, :message_id, :credits_used,
436
+ :error, :content_dropped
437
+
438
+ STATUSES = %w[pending sent failed unavailable skipped].freeze
439
+ REASONS = %w[window_closed whatsapp_refused whatsapp_failed].freeze
440
+
441
+ def initialize(data)
442
+ @channel = data["channel"] || "sms"
443
+ @status = data["status"]
444
+ @reason = data["reason"]
445
+ @message_id = data["messageId"] || data["message_id"]
446
+ @credits_used = data["creditsUsed"] || data["credits_used"]
447
+ @error = data["error"]
448
+ @content_dropped = data["contentDropped"] || data["content_dropped"]
449
+ end
450
+
451
+ def pending?
452
+ status == "pending"
453
+ end
454
+
455
+ def sent?
456
+ status == "sent"
457
+ end
458
+
459
+ def to_h
460
+ {
461
+ channel: channel, status: status, reason: reason,
462
+ message_id: message_id, credits_used: credits_used, error: error,
463
+ content_dropped: content_dropped
464
+ }.compact
465
+ end
466
+ end
467
+
251
468
  # A sent WhatsApp message. Unlike {Message}, +segments+ is always 1
252
- # (WhatsApp has no segment concept), +text+ is nil for template and media
253
- # sends, and +whatsapp+ carries the channel-specific details.
469
+ # (WhatsApp has no segment concept), +text+ is the caption for a media
470
+ # send (pass it as +text+ with +media_urls+) and nil for template sends
471
+ # and media sent without a caption, and +whatsapp+ carries the
472
+ # channel-specific details. +credits_used+: free-form text or media
473
+ # inside the 24-hour window costs 1 credit each for the first 1,000 per
474
+ # sending number per calendar month (UTC), then the destination's utility
475
+ # template price; countries without a listed price use the default
476
+ # utility price of 12 credits. Templates are priced by category and
477
+ # destination country; countries without a listed price use 33
478
+ # (marketing), 12 (utility) and 12 (authentication) credits. A failed
479
+ # send gives its slot back. +fallback+ is a {WhatsAppSmsFallback} when the
480
+ # send asked for +fallback_to_sms+: when WhatsApp couldn't take the
481
+ # message and it went as an SMS instead, +status+ is +"failed"+ and
482
+ # +fallback.status+ is +"sent"+ (check with {#fell_back?}).
254
483
  class WhatsAppMessage
255
484
  attr_reader :id, :channel, :message_format, :to, :from, :text, :status,
256
- :segments, :credits_used, :whatsapp, :created_at, :metadata
485
+ :segments, :credits_used, :whatsapp, :created_at, :metadata,
486
+ :fallback
257
487
 
258
488
  def initialize(data)
259
489
  @id = data["id"]
@@ -268,6 +498,11 @@ module Sendly
268
498
  @whatsapp = data["whatsapp"] ? WhatsAppMessageDetails.new(data["whatsapp"]) : nil
269
499
  @created_at = data["createdAt"] || data["created_at"]
270
500
  @metadata = data["metadata"]
501
+ @fallback = data["fallback"].is_a?(Hash) ? WhatsAppSmsFallback.new(data["fallback"]) : nil
502
+ end
503
+
504
+ def fell_back?
505
+ fallback&.sent? || false
271
506
  end
272
507
 
273
508
  def delivered?
@@ -283,13 +518,14 @@ module Sendly
283
518
  id: id, channel: channel, message_format: message_format,
284
519
  to: to, from: from, text: text, status: status, segments: segments,
285
520
  credits_used: credits_used, whatsapp: whatsapp&.to_h,
286
- created_at: created_at, metadata: metadata
521
+ created_at: created_at, metadata: metadata, fallback: fallback&.to_h
287
522
  }.compact
288
523
  end
289
524
  end
290
525
 
291
526
  # Connect numbers to WhatsApp. Starting a signup returns a +connect_url+
292
- # a human must complete in a browser.
527
+ # a human must complete in a browser. A number added to an account that
528
+ # is already connected is verified with a code instead ({#verify}).
293
529
  class WhatsAppSignupResource
294
530
  def initialize(client)
295
531
  @client = client
@@ -305,23 +541,179 @@ module Sendly
305
541
  #
306
542
  # Calling again for a number with an in-flight signup returns the
307
543
  # existing signup (same +connect_url+) without charging again. Requires
308
- # a live API key.
544
+ # a live API key with the +whatsapp:write+ scope (a test key gets 403
545
+ # +whatsapp_requires_live_key+) and, in a team workspace, an owner or
546
+ # admin (+settings:write+). After the Facebook step the signup stays
547
+ # +"registering"+ while WhatsApp activates the number. Activation usually
548
+ # takes a few minutes but can take hours. If it hasn't finished about 6
549
+ # hours after the session began, the session fails with
550
+ # +registration_timeout+ and the fee is refunded. If the connection
551
+ # fails, the $19 fee is refunded automatically; once the number has
552
+ # connected there is no refund, and a later disconnect gets nothing
553
+ # back.
554
+ #
555
+ # To add a number to a WhatsApp Business Account this workspace has
556
+ # already connected, pass its +business_account_id+ (as shown on
557
+ # {WhatsAppSender#business_account_id}). There is no Facebook step and no
558
+ # +connect_url+: the same $19 fee is charged, Meta sends the number a
559
+ # 6-digit code by text or voice call, and the session is +"verifying"+
560
+ # until you submit the code with {#verify}. Calling again for a number
561
+ # that is verifying returns the same session without charging again or
562
+ # sending a second code; a verifying session older than 3 hours is
563
+ # expired (and refunded) and a new one is started.
309
564
  #
310
565
  # @param phone_number [String] The number to connect, in E.164 format.
311
566
  # Must be an active number in your workspace.
567
+ # @param business_account_id [String, nil] The WhatsApp Business Account
568
+ # to add the number to. It must be connected in this workspace with at
569
+ # least one active number.
570
+ # @param verification_method [String, nil] How Meta sends the code:
571
+ # +"sms"+ (the default) or +"voice"+. Only with +business_account_id+.
572
+ # @param display_name [String, nil] The business name WhatsApp shows for
573
+ # the number (at most 512 characters). Defaults to the account's
574
+ # existing sender display name, then its business name. Only with
575
+ # +business_account_id+.
312
576
  # @return [WhatsAppSignupSession]
577
+ # @raise [Sendly::ServerError] +whatsapp_unavailable+ (503) while
578
+ # WhatsApp connections are unavailable. Nothing is charged. Only
579
+ # signup returns it, with +retryAfter+ 3600 in the body
580
+ # (+e.response_body["retryAfter"]+) and a +Retry-After: 3600+ header.
581
+ # The client retries it like any 5xx before raising.
582
+ # @raise [Sendly::RateLimitError] +whatsapp_signup_limit_reached+ (429)
583
+ # after 5 failed, charged signups in 24 hours. Not retried; try again
584
+ # the next day.
585
+ # @raise [Sendly::NotFoundError] +whatsapp_business_account_not_found+
586
+ # (404) when +business_account_id+ isn't connected in this workspace
587
+ # with an active number.
588
+ # @raise [Sendly::ValidationError] +display_name_required+ (400) when no
589
+ # display name is given and the account has none to reuse, and
590
+ # +whatsapp_verification_start_failed+ (422) when WhatsApp refused to
591
+ # verify the number: final, the session failed and the fee is refunded.
592
+ # @raise [Sendly::APIError] 409 +whatsapp_signup_in_progress+ (a Facebook
593
+ # connection for the number is in flight; +e.response_body["id"]+ is
594
+ # that session), 409 +whatsapp_verification_in_progress+ (a Facebook
595
+ # signup for a number that is being added by code, with that session in
596
+ # +e.response_body["id"]+; or, with +business_account_id+, the number is
597
+ # still being added, so try again in a moment) or 409
598
+ # +whatsapp_already_enabled+.
599
+ # @raise [Sendly::InsufficientCreditsError] 402 +payment_method_required+
600
+ # or +payment_failed+ when the $19 fee can't be charged.
601
+ # @raise [Sendly::ServerError] +whatsapp_verification_start_failed+ (502)
602
+ # when WhatsApp couldn't start verifying the number: the session failed
603
+ # and its fee is refunded, so start again. With +business_account_id+,
604
+ # a 5xx is raised at once and never retried by the client, because
605
+ # each retry would start a new charged session (a timeout or dropped
606
+ # connection is never retried either). Without it the client retries a
607
+ # 5xx before raising, as before.
313
608
  #
314
609
  # @example
315
610
  # signup = client.whatsapp.signup.create(phone_number: "+15559876543")
316
611
  # puts "Open #{signup.connect_url} and log in with Facebook"
317
- def create(phone_number:)
612
+ #
613
+ # @example Add a number to an account you already connected
614
+ # signup = client.whatsapp.signup.create(
615
+ # phone_number: "+14155550124",
616
+ # business_account_id: "102290129340398"
617
+ # )
618
+ # puts signup.status # "verifying"
619
+ def create(phone_number:, business_account_id: nil, verification_method: nil, display_name: nil)
318
620
  raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
621
+ if !business_account_id.nil? && business_account_id.to_s.strip.empty?
622
+ raise ValidationError, "business_account_id must be a non-empty string"
623
+ end
319
624
 
320
- response = @client.post("/whatsapp/signup", { phoneNumber: phone_number })
625
+ body = { phoneNumber: phone_number }
626
+ body[:businessAccountId] = business_account_id.to_s unless business_account_id.nil?
627
+ body[:verificationMethod] = verification_method unless verification_method.nil?
628
+ body[:displayName] = display_name unless display_name.nil?
629
+
630
+ by_code = !business_account_id.nil? && !business_account_id.to_s.empty?
631
+ response = @client.post("/whatsapp/signup", body, retry_server_errors: !by_code)
321
632
  WhatsAppSignupSession.new(response)
322
633
  end
323
634
 
324
- # Get the status of a WhatsApp signup.
635
+ # Submit the 6-digit code Meta sent to a number being added to a
636
+ # connected account (a +"verifying"+ signup). Spaces and dashes are
637
+ # ignored. On success the number is connected and the
638
+ # +whatsapp_account.connected+ webhook fires. A signup that is already
639
+ # +"active"+ comes back unchanged. The code can also be read from
640
+ # {WhatsAppSignup#verification_code} once Meta's text arrives on the
641
+ # number. Requires a live API key with the +whatsapp:write+ scope and, in
642
+ # a team workspace, an owner or admin (+settings:write+).
643
+ #
644
+ # @param id [String] The signup's id
645
+ # @param code [String] The 6-digit code
646
+ # @return [WhatsAppSignup] The signup, +"active"+ on success
647
+ # @raise [Sendly::ValidationError] +invalid_verification_code+ (400) when
648
+ # the code isn't 6 digits, and +whatsapp_verification_code_invalid+
649
+ # (422) for a wrong code, with the attempts left in
650
+ # +e.response_body["attemptsRemaining"]+
651
+ # @raise [Sendly::APIError] 409 +whatsapp_verification_failed+ after 5
652
+ # wrong codes (the session failed, the fee is refunded and
653
+ # +whatsapp_account.failed+ fires), 409 +whatsapp_verification_busy+
654
+ # while another code for the number is being checked (try again), or
655
+ # 409 +signup_not_active+ when the signup isn't waiting for a code or is
656
+ # older than 3 hours
657
+ # @raise [Sendly::NotFoundError] +signup_not_found+ (404)
658
+ # @raise [Sendly::ServerError] 502 +whatsapp_verification_unavailable+
659
+ # (WhatsApp couldn't check the code; the attempt isn't counted) or 502
660
+ # +whatsapp_activation_pending+ (the code was accepted but connecting
661
+ # the number didn't finish; check back shortly with {#get}). A 5xx, a
662
+ # timeout or a dropped connection is raised at once and never retried
663
+ # by the client: each submission
664
+ # uses one of the 5 attempts, and after +whatsapp_activation_pending+
665
+ # WhatsApp has already accepted the code.
666
+ #
667
+ # @example
668
+ # signup = client.whatsapp.signup.verify(signup.id, code: "482913")
669
+ # puts signup.status # "active"
670
+ def verify(id, code:)
671
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
672
+ raise ValidationError, "code is required" if code.nil? || code.to_s.empty?
673
+
674
+ encoded_id = URI.encode_www_form_component(id)
675
+ response = @client.post("/whatsapp/signup/#{encoded_id}/verify", { code: code }, retry_server_errors: false)
676
+ WhatsAppSignup.new(response)
677
+ end
678
+
679
+ # Ask Meta to send a new code to a number being added to a connected
680
+ # account. Codes can be requested at most every 30 seconds, counted from
681
+ # the signup's last change (a code submission included). A signup that
682
+ # is already +"active"+ comes back unchanged. Requires a live API key with
683
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
684
+ # (+settings:write+).
685
+ #
686
+ # @param id [String] The signup's id
687
+ # @param verification_method [String, nil] +"sms"+ (the default) or
688
+ # +"voice"+
689
+ # @return [WhatsAppSignup]
690
+ # @raise [Sendly::RateLimitError] +whatsapp_verification_resend_too_soon+
691
+ # (429), raised at once with the seconds to wait in +e.retry_after+
692
+ # @raise [Sendly::ValidationError] +whatsapp_verification_resend_failed+
693
+ # (422) when WhatsApp wouldn't send another code yet
694
+ # @raise [Sendly::APIError] 409 +signup_not_active+ when the signup isn't
695
+ # waiting for a code or is older than 3 hours
696
+ # @raise [Sendly::NotFoundError] +signup_not_found+ (404)
697
+ # @raise [Sendly::ServerError] +whatsapp_verification_resend_failed+
698
+ # (502) when WhatsApp couldn't be reached. The client retries a 5xx
699
+ # before raising.
700
+ #
701
+ # @example
702
+ # client.whatsapp.signup.resend(signup.id, verification_method: "voice")
703
+ def resend(id, verification_method: nil)
704
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
705
+
706
+ body = {}
707
+ body[:verificationMethod] = verification_method unless verification_method.nil?
708
+
709
+ encoded_id = URI.encode_www_form_component(id)
710
+ response = @client.post("/whatsapp/signup/#{encoded_id}/resend", body)
711
+ WhatsAppSignup.new(response)
712
+ end
713
+
714
+ # Get the status of a WhatsApp signup. Needs the +whatsapp:read+ scope;
715
+ # test keys work. While a number is +"verifying"+ the signup carries
716
+ # {WhatsAppSignup#verification_code} once Meta's text has arrived.
325
717
  #
326
718
  # @param id [String] The signup's id
327
719
  # @return [WhatsAppSignup]
@@ -340,7 +732,8 @@ module Sendly
340
732
  end
341
733
 
342
734
  # List the numbers connected (or connecting) to WhatsApp, and manage
343
- # their business profiles.
735
+ # their business profiles, profile photos, conversational components and
736
+ # WhatsApp calling.
344
737
  class WhatsAppSendersResource
345
738
  def initialize(client)
346
739
  @client = client
@@ -348,7 +741,8 @@ module Sendly
348
741
 
349
742
  # List your WhatsApp senders, newest first. An empty list means no
350
743
  # number is connected yet — start one with
351
- # {WhatsAppSignupResource#create}.
744
+ # {WhatsAppSignupResource#create}. Needs the +whatsapp:read+ scope; test
745
+ # keys work.
352
746
  #
353
747
  # @return [Hash] +{ senders: Array<WhatsAppSender> }+
354
748
  #
@@ -367,7 +761,8 @@ module Sendly
367
761
  # The business profile is what recipients see when they open the
368
762
  # sender's details in WhatsApp: display name, photo, category, about
369
763
  # line, description, and contact details. The sender must be +"active"+
370
- # (connected to WhatsApp).
764
+ # (connected to WhatsApp). Needs the +whatsapp:read+ scope; test keys
765
+ # work.
371
766
  #
372
767
  # @param phone_number [String] Your WhatsApp-connected sending number,
373
768
  # in E.164 format
@@ -391,8 +786,10 @@ module Sendly
391
786
  # Pass only the fields to change; omitted fields keep their current
392
787
  # value. +about+ is limited to 139 characters and +description+ to 512.
393
788
  # +display_name+ changes are reviewed by Meta before they take effect.
394
- # The profile photo cannot be set through this method. Requires a live
395
- # API key.
789
+ # The profile photo cannot be set through this method; use
790
+ # {#upload_profile_photo}. Requires a live
791
+ # API key with the +whatsapp:write+ scope and, in a team workspace, an
792
+ # owner or admin (+settings:write+).
396
793
  #
397
794
  # @param phone_number [String] Your WhatsApp-connected sending number,
398
795
  # in E.164 format
@@ -430,6 +827,166 @@ module Sendly
430
827
  response = @client.patch("/whatsapp/senders/#{encoded_number}/profile", body)
431
828
  WhatsAppSenderProfile.new(response)
432
829
  end
830
+
831
+ # Upload a sender's WhatsApp profile photo, replacing the current one.
832
+ #
833
+ # The photo must be a JPEG or PNG (checked by its bytes, not by
834
+ # +content_type+) of at most 5 MB. WhatsApp wants it square and at least
835
+ # 192 pixels wide (640 recommended). Free. Requires a live API key with
836
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
837
+ # (+settings:write+).
838
+ #
839
+ # @param phone_number [String] Your WhatsApp-connected sending number,
840
+ # in E.164 format
841
+ # @param file [String, IO] A file path or an IO holding the image
842
+ # @param content_type [String] MIME type sent with the file
843
+ # @param filename [String] Name sent with the file
844
+ # @return [WhatsAppSenderProfile] The profile with its new photo
845
+ # @raise [Sendly::ValidationError] +whatsapp_profile_photo_invalid+ (400)
846
+ # when the file isn't a JPEG or PNG, or +file_required+ (400)
847
+ # @raise [Sendly::APIError] +whatsapp_profile_photo_too_large+ with
848
+ # +status_code+ 413 when the photo is over 5 MB
849
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
850
+ # @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (502)
851
+ # when WhatsApp refused the image or couldn't be reached. A 5xx, a
852
+ # timeout or a dropped connection is raised at once and never retried
853
+ # by the client; check the image is
854
+ # square and at least 192 pixels wide before trying again.
855
+ #
856
+ # @example
857
+ # client.whatsapp.senders.upload_profile_photo("+14155550123", "logo.png",
858
+ # content_type: "image/png", filename: "logo.png")
859
+ def upload_profile_photo(phone_number, file, content_type: "image/jpeg", filename: "profile.jpg")
860
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
861
+ raise ValidationError, "file is required" if file.nil?
862
+
863
+ encoded_number = URI.encode_www_form_component(phone_number)
864
+ response = @client.post_multipart("/whatsapp/senders/#{encoded_number}/profile/photo", file,
865
+ content_type: content_type, filename: filename,
866
+ retry_server_errors: false)
867
+ WhatsAppSenderProfile.new(response)
868
+ end
869
+
870
+ # Remove a sender's WhatsApp profile photo. Requires a live API key with
871
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
872
+ # (+settings:write+).
873
+ #
874
+ # @param phone_number [String] Your WhatsApp-connected sending number,
875
+ # in E.164 format
876
+ # @return [WhatsAppSenderProfile] The profile, normally with
877
+ # +profile_photo_url+ nil
878
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
879
+ # @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (502).
880
+ # The client retries a 5xx before raising.
881
+ #
882
+ # @example
883
+ # client.whatsapp.senders.delete_profile_photo("+14155550123")
884
+ def delete_profile_photo(phone_number)
885
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
886
+
887
+ encoded_number = URI.encode_www_form_component(phone_number)
888
+ response = @client.delete("/whatsapp/senders/#{encoded_number}/profile/photo")
889
+ WhatsAppSenderProfile.new(response)
890
+ end
891
+
892
+ # Get a sender's conversational components: the ice breakers shown when
893
+ # someone opens a chat with the business for the first time, and the
894
+ # commands shown when the customer types "/". Needs the +whatsapp:read+
895
+ # scope; test keys work.
896
+ #
897
+ # @param phone_number [String] Your WhatsApp-connected sending number,
898
+ # in E.164 format
899
+ # @return [WhatsAppConversationalComponents]
900
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
901
+ # @raise [Sendly::ServerError]
902
+ # +whatsapp_conversational_components_fetch_failed+ (502). The client
903
+ # retries a 5xx before raising.
904
+ #
905
+ # @example
906
+ # components = client.whatsapp.senders.get_conversational_components("+14155550123")
907
+ # components.commands.each { |c| puts "/#{c.command}: #{c.description}" }
908
+ def get_conversational_components(phone_number)
909
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
910
+
911
+ encoded_number = URI.encode_www_form_component(phone_number)
912
+ response = @client.get("/whatsapp/senders/#{encoded_number}/conversational_components")
913
+ WhatsAppConversationalComponents.new(response)
914
+ end
915
+
916
+ # Replace a sender's ice breakers, commands, or both.
917
+ #
918
+ # Each list you pass replaces the stored one, and +[]+ clears it; a list
919
+ # you leave out is kept. Ice breakers: at most 4, each 1-80 characters,
920
+ # no duplicates. Commands: at most 30, each a hash with +:command+
921
+ # (letters, digits and underscores, 1-32 characters; a leading "/" is
922
+ # dropped) and +:description+ (1-256 characters), no duplicate commands.
923
+ # The {WhatsAppCommand} objects a read returns can be passed back. Free.
924
+ # Requires a live API key with the +whatsapp:write+ scope and, in a team
925
+ # workspace, an owner or admin (+settings:write+).
926
+ #
927
+ # @param phone_number [String] Your WhatsApp-connected sending number,
928
+ # in E.164 format
929
+ # @param ice_breakers [Array<String>, nil] The new ice breakers
930
+ # @param commands [Array<Hash, WhatsAppCommand>, nil] The new commands
931
+ # @return [WhatsAppConversationalComponents] The stored components
932
+ # @raise [Sendly::ValidationError] If neither list is given, or
933
+ # +invalid_request+ (400) with a message naming the problem
934
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
935
+ # @raise [Sendly::ServerError]
936
+ # +whatsapp_conversational_components_update_failed+ (502). The client
937
+ # retries a 5xx before raising.
938
+ #
939
+ # @example
940
+ # client.whatsapp.senders.update_conversational_components("+14155550123",
941
+ # ice_breakers: ["Track my order", "Opening hours"],
942
+ # commands: [{ command: "menu", description: "See today's menu" }]
943
+ # )
944
+ def update_conversational_components(phone_number, ice_breakers: nil, commands: nil)
945
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
946
+
947
+ body = {}
948
+ body[:iceBreakers] = ice_breakers unless ice_breakers.nil?
949
+ body[:commands] = commands.map { |c| c.is_a?(WhatsAppCommand) ? c.to_h : c } unless commands.nil?
950
+ raise ValidationError, "Provide ice_breakers, commands, or both" if body.empty?
951
+
952
+ encoded_number = URI.encode_www_form_component(phone_number)
953
+ response = @client.patch("/whatsapp/senders/#{encoded_number}/conversational_components", body)
954
+ WhatsAppConversationalComponents.new(response)
955
+ end
956
+
957
+ # Switch WhatsApp calling on or off for a sender. Free.
958
+ #
959
+ # Once it is on, a WhatsApp user calling the number rings exactly like
960
+ # a phone call (in the dashboard or with your AI agent, per the number's
961
+ # voice settings), billed at the normal inbound rate. Turning it on
962
+ # needs voice switched on for the number first. There is no API for
963
+ # placing WhatsApp calls. Requires a live API key with the
964
+ # +whatsapp:write+ scope and, in a team workspace, an owner or admin
965
+ # (+settings:write+).
966
+ #
967
+ # @param phone_number [String] Your WhatsApp-connected sending number,
968
+ # in E.164 format
969
+ # @param enabled [Boolean] true to switch calling on, false to switch it off
970
+ # @return [WhatsAppCallingSettings]
971
+ # @raise [Sendly::APIError] +voice_not_enabled+ with +status_code+ 409
972
+ # when voice isn't on for the number
973
+ # @raise [Sendly::ValidationError] +whatsapp_calling_unavailable+ (422)
974
+ # when Meta refused: calling needs the account at the 2,000
975
+ # recipients a day messaging limit and an approved display name
976
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
977
+ # @raise [Sendly::ServerError] +whatsapp_calling_update_failed+ (502).
978
+ # The client retries a 5xx before raising.
979
+ #
980
+ # @example
981
+ # settings = client.whatsapp.senders.set_calling("+14155550123", enabled: true)
982
+ # puts settings.calling_enabled?
983
+ def set_calling(phone_number, enabled:)
984
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
985
+
986
+ encoded_number = URI.encode_www_form_component(phone_number)
987
+ response = @client.patch("/whatsapp/senders/#{encoded_number}/calling", { enabled: enabled })
988
+ WhatsAppCallingSettings.new(response)
989
+ end
433
990
  end
434
991
 
435
992
  # Manage Meta-reviewed message templates.
@@ -438,7 +995,8 @@ module Sendly
438
995
  @client = client
439
996
  end
440
997
 
441
- # List your WhatsApp templates.
998
+ # List your WhatsApp templates. Needs the +whatsapp:read+ scope; test
999
+ # keys work.
442
1000
  #
443
1001
  # @return [Hash] +{ templates: Array<WhatsAppTemplate> }+
444
1002
  #
@@ -455,18 +1013,24 @@ module Sendly
455
1013
  # Create a template and submit it to Meta for review.
456
1014
  #
457
1015
  # Review usually takes 24-48h; the template is usable once its status
458
- # is +"APPROVED"+. Requires a live API key.
1016
+ # is +"APPROVED"+. Requires a live API key with the +whatsapp:write+
1017
+ # scope and, in a team workspace, an owner, admin or member
1018
+ # (+templates:write+). A marketing template without an opt-out button is
1019
+ # still accepted, with a warning.
459
1020
  #
460
1021
  # @param sender [String] The WhatsApp-connected sending number this
461
1022
  # template belongs to, in E.164 format
462
1023
  # @param name [String] Template name: lowercase letters, digits, and
463
1024
  # underscores (e.g. "order_shipped")
464
1025
  # @param language [String] Template language code (e.g. "en_US")
465
- # @param category [String] "AUTHENTICATION", "UTILITY", or "MARKETING" —
466
- # drives Meta review rules and pricing
1026
+ # @param category [String] "AUTHENTICATION", "UTILITY", or "MARKETING"
1027
+ # (the server uppercases it). Required, with no default; it drives Meta
1028
+ # review rules and pricing and can't be changed later
467
1029
  # @param body [String] Body text. Use {{1}}, {{2}}, ... for variables;
468
1030
  # every placeholder needs an example value in +examples+
469
- # @param header [String, nil] Optional text header
1031
+ # @param header [String, nil] Optional text header. Fixed text only: a
1032
+ # header containing {{n}} is refused with
1033
+ # +template_header_variable_unsupported+
470
1034
  # @param footer [String, nil] Optional footer line
471
1035
  # @param buttons [Array<Hash>, nil] Optional buttons, each with :type
472
1036
  # ("url", "quick_reply", or "otp"), :text, and for url buttons a :url
@@ -476,7 +1040,15 @@ module Sendly
476
1040
  # Required when the body has variables.
477
1041
  # @return [WhatsAppTemplate] The created template (status "PENDING"),
478
1042
  # with any submission warnings
479
- # @raise [Sendly::NotFoundError] If the sender isn't connected to WhatsApp
1043
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404) if
1044
+ # the sender isn't connected to WhatsApp; this is checked first
1045
+ # @raise [Sendly::ValidationError] If the API refuses the template with a
1046
+ # 400 +template_*+ code (in +e.response_body["error"]+) and a readable
1047
+ # message: +template_category_invalid+ (category missing or not one of
1048
+ # the three), +template_authentication_otp_button_required+,
1049
+ # +template_authentication_no_links+ (a link in the body or a URL
1050
+ # button on an authentication template) or
1051
+ # +template_header_variable_unsupported+
480
1052
  #
481
1053
  # @example
482
1054
  # template = client.whatsapp.templates.create(
@@ -517,11 +1089,14 @@ module Sendly
517
1089
  # This is the recovery path for rejections: template names are locked
518
1090
  # for ~30 days after deletion, so editing a rejected template (rather
519
1091
  # than deleting and re-creating it) is the way to fix it. The updated
520
- # template goes back to "PENDING" review. Requires a live API key.
1092
+ # template goes back to "PENDING" review. The category can't be
1093
+ # changed. Requires a live API key with the +whatsapp:write+ scope and,
1094
+ # in a team workspace, an owner, admin or member (+templates:write+).
521
1095
  #
522
1096
  # @param id [String] The template's id
523
1097
  # @param body [String, nil] Replacement body text
524
- # @param header [String, nil] Replacement text header
1098
+ # @param header [String, nil] Replacement text header; it can't contain
1099
+ # {{n}} variables (+template_header_variable_unsupported+)
525
1100
  # @param footer [String, nil] Replacement footer
526
1101
  # @param buttons [Array<Hash>, nil] Replacement buttons
527
1102
  # @param examples [Hash, nil] Replacement example values for body placeholders
@@ -552,7 +1127,9 @@ module Sendly
552
1127
  #
553
1128
  # Meta locks a deleted template's name for ~30 days — re-creating it
554
1129
  # fails with +template_name_locked+ until the lock lifts. To fix a
555
- # rejected template, prefer {#update}. Requires a live API key.
1130
+ # rejected template, prefer {#update}. Requires a live API key with the
1131
+ # +whatsapp:write+ scope and, in a team workspace, an owner, admin or
1132
+ # member (+templates:write+).
556
1133
  #
557
1134
  # @param id [String] The template's id
558
1135
  # @return [WhatsAppTemplateDeletion]
@@ -576,10 +1153,12 @@ module Sendly
576
1153
  # create Meta-reviewed message templates, and send via
577
1154
  # +client.messages.send(channel: "whatsapp", ...)+.
578
1155
  #
579
- # Connecting a number is a one-time $19 setup (no monthly fee) and always
580
- # ends with a human step: {WhatsAppSignupResource#create} returns a
581
- # +connect_url+ that a person must open in a browser and log in with
582
- # Facebook to link their WhatsApp Business Account.
1156
+ # Connecting a number is a one-time $19 setup (no monthly fee). The first
1157
+ # number always ends with a human step: {WhatsAppSignupResource#create}
1158
+ # returns a +connect_url+ that a person must open in a browser and log in
1159
+ # with Facebook to link their WhatsApp Business Account. More numbers can
1160
+ # then be added to that account with a code Meta sends to the number
1161
+ # (+business_account_id:+ on create, then {WhatsAppSignupResource#verify}).
583
1162
  #
584
1163
  # Two ways to reach a recipient:
585
1164
  #
@@ -590,6 +1169,31 @@ module Sendly
590
1169
  # marketing — pricing follows the category and destination country.
591
1170
  # Note: Meta has paused marketing template delivery to US (+1) numbers.
592
1171
  #
1172
+ # Pricing: free-form text or media inside the 24-hour window costs 1
1173
+ # credit each for the first 1,000 per sending number per calendar month
1174
+ # (UTC), then the destination's utility template price; countries without
1175
+ # a listed price use the default utility price of 12 credits. Templates
1176
+ # are priced by category and destination country; countries without a
1177
+ # listed price use 33 (marketing), 12 (utility) and 12 (authentication)
1178
+ # credits. A failed send gives its slot back.
1179
+ #
1180
+ # Scopes and keys: sends go through +messages.send(channel: "whatsapp")+
1181
+ # and need +sms:send+, not +whatsapp:write+, and a live key. Reads (signup
1182
+ # status, templates, the window, senders and sender profiles) need
1183
+ # +whatsapp:read+ and accept test keys. Signup, template create/edit/delete
1184
+ # and profile edits need +whatsapp:write+ and a live key (otherwise 403
1185
+ # +whatsapp_requires_live_key+). In a team workspace, connecting and
1186
+ # profile edits need an owner or admin (+settings:write+), and template
1187
+ # writes need an owner, admin or member (+templates:write+); a missing
1188
+ # role returns 403 +insufficient_permissions+. Reading conversational
1189
+ # components needs +whatsapp:read+ too and accepts test keys. The profile
1190
+ # photo, conversational component and calling changes, and submitting or
1191
+ # resending a verification code, need the same as a profile edit.
1192
+ #
1193
+ # WhatsApp is enabled per person: the user who owns the API key, not the
1194
+ # workspace. While it is off, sends return 403 +whatsapp_not_enabled+ and
1195
+ # every method on this resource gets 404 +not_found+.
1196
+ #
593
1197
  # @example Connect a number, create a template, then send
594
1198
  # # 1. Connect ($19 one-time; a human must open the connect URL)
595
1199
  # signup = client.whatsapp.signup.create(phone_number: "+15559876543")
@@ -615,7 +1219,7 @@ module Sendly
615
1219
  attr_reader :signup
616
1220
 
617
1221
  # @return [WhatsAppSendersResource] List connected numbers and manage
618
- # their business profiles
1222
+ # their business profiles, photos, conversational components and calling
619
1223
  attr_reader :senders
620
1224
 
621
1225
  # @return [WhatsAppTemplatesResource] Manage Meta-reviewed templates
@@ -633,7 +1237,8 @@ module Sendly
633
1237
  #
634
1238
  # Free-form text and media only deliver while a window is open (it
635
1239
  # opens when the recipient messages you and lasts 24h from their last
636
- # inbound message). Outside a window, send an approved template.
1240
+ # inbound message). Outside a window, send an approved template. Needs
1241
+ # the +whatsapp:read+ scope; test keys work.
637
1242
  #
638
1243
  # @param from [String] Your WhatsApp-connected sending number, in E.164 format
639
1244
  # @param to [String] The recipient's number, in E.164 format