sendly 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -249,8 +421,17 @@ module Sendly
249
421
  end
250
422
 
251
423
  # 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.
424
+ # (WhatsApp has no segment concept), +text+ is the caption for a media
425
+ # send (pass it as +text+ with +media_urls+) and nil for template sends
426
+ # and media sent without a caption, and +whatsapp+ carries the
427
+ # channel-specific details. +credits_used+: free-form text or media
428
+ # inside the 24-hour window costs 1 credit each for the first 1,000 per
429
+ # sending number per calendar month (UTC), then the destination's utility
430
+ # template price; countries without a listed price use the default
431
+ # utility price of 12 credits. Templates are priced by category and
432
+ # destination country; countries without a listed price use 33
433
+ # (marketing), 12 (utility) and 12 (authentication) credits. A failed
434
+ # send gives its slot back.
254
435
  class WhatsAppMessage
255
436
  attr_reader :id, :channel, :message_format, :to, :from, :text, :status,
256
437
  :segments, :credits_used, :whatsapp, :created_at, :metadata
@@ -289,7 +470,8 @@ module Sendly
289
470
  end
290
471
 
291
472
  # Connect numbers to WhatsApp. Starting a signup returns a +connect_url+
292
- # a human must complete in a browser.
473
+ # a human must complete in a browser. A number added to an account that
474
+ # is already connected is verified with a code instead ({#verify}).
293
475
  class WhatsAppSignupResource
294
476
  def initialize(client)
295
477
  @client = client
@@ -305,23 +487,179 @@ module Sendly
305
487
  #
306
488
  # Calling again for a number with an in-flight signup returns the
307
489
  # existing signup (same +connect_url+) without charging again. Requires
308
- # a live API key.
490
+ # a live API key with the +whatsapp:write+ scope (a test key gets 403
491
+ # +whatsapp_requires_live_key+) and, in a team workspace, an owner or
492
+ # admin (+settings:write+). After the Facebook step the signup stays
493
+ # +"registering"+ while WhatsApp activates the number. Activation usually
494
+ # takes a few minutes but can take hours. If it hasn't finished about 6
495
+ # hours after the session began, the session fails with
496
+ # +registration_timeout+ and the fee is refunded. If the connection
497
+ # fails, the $19 fee is refunded automatically; once the number has
498
+ # connected there is no refund, and a later disconnect gets nothing
499
+ # back.
500
+ #
501
+ # To add a number to a WhatsApp Business Account this workspace has
502
+ # already connected, pass its +business_account_id+ (as shown on
503
+ # {WhatsAppSender#business_account_id}). There is no Facebook step and no
504
+ # +connect_url+: the same $19 fee is charged, Meta sends the number a
505
+ # 6-digit code by text or voice call, and the session is +"verifying"+
506
+ # until you submit the code with {#verify}. Calling again for a number
507
+ # that is verifying returns the same session without charging again or
508
+ # sending a second code; a verifying session older than 3 hours is
509
+ # expired (and refunded) and a new one is started.
309
510
  #
310
511
  # @param phone_number [String] The number to connect, in E.164 format.
311
512
  # Must be an active number in your workspace.
513
+ # @param business_account_id [String, nil] The WhatsApp Business Account
514
+ # to add the number to. It must be connected in this workspace with at
515
+ # least one active number.
516
+ # @param verification_method [String, nil] How Meta sends the code:
517
+ # +"sms"+ (the default) or +"voice"+. Only with +business_account_id+.
518
+ # @param display_name [String, nil] The business name WhatsApp shows for
519
+ # the number (at most 512 characters). Defaults to the account's
520
+ # existing sender display name, then its business name. Only with
521
+ # +business_account_id+.
312
522
  # @return [WhatsAppSignupSession]
523
+ # @raise [Sendly::ServerError] +whatsapp_unavailable+ (503) while
524
+ # WhatsApp connections are unavailable. Nothing is charged. Only
525
+ # signup returns it, with +retryAfter+ 3600 in the body
526
+ # (+e.response_body["retryAfter"]+) and a +Retry-After: 3600+ header.
527
+ # The client retries it like any 5xx before raising.
528
+ # @raise [Sendly::RateLimitError] +whatsapp_signup_limit_reached+ (429)
529
+ # after 5 failed, charged signups in 24 hours. Not retried; try again
530
+ # the next day.
531
+ # @raise [Sendly::NotFoundError] +whatsapp_business_account_not_found+
532
+ # (404) when +business_account_id+ isn't connected in this workspace
533
+ # with an active number.
534
+ # @raise [Sendly::ValidationError] +display_name_required+ (400) when no
535
+ # display name is given and the account has none to reuse, and
536
+ # +whatsapp_verification_start_failed+ (422) when WhatsApp refused to
537
+ # verify the number: final, the session failed and the fee is refunded.
538
+ # @raise [Sendly::APIError] 409 +whatsapp_signup_in_progress+ (a Facebook
539
+ # connection for the number is in flight; +e.response_body["id"]+ is
540
+ # that session), 409 +whatsapp_verification_in_progress+ (a Facebook
541
+ # signup for a number that is being added by code, with that session in
542
+ # +e.response_body["id"]+; or, with +business_account_id+, the number is
543
+ # still being added, so try again in a moment) or 409
544
+ # +whatsapp_already_enabled+.
545
+ # @raise [Sendly::InsufficientCreditsError] 402 +payment_method_required+
546
+ # or +payment_failed+ when the $19 fee can't be charged.
547
+ # @raise [Sendly::ServerError] +whatsapp_verification_start_failed+ (502)
548
+ # when WhatsApp couldn't start verifying the number: the session failed
549
+ # and its fee is refunded, so start again. With +business_account_id+,
550
+ # a 5xx is raised at once and never retried by the client, because
551
+ # each retry would start a new charged session (a timeout or dropped
552
+ # connection is never retried either). Without it the client retries a
553
+ # 5xx before raising, as before.
313
554
  #
314
555
  # @example
315
556
  # signup = client.whatsapp.signup.create(phone_number: "+15559876543")
316
557
  # puts "Open #{signup.connect_url} and log in with Facebook"
317
- def create(phone_number:)
558
+ #
559
+ # @example Add a number to an account you already connected
560
+ # signup = client.whatsapp.signup.create(
561
+ # phone_number: "+14155550124",
562
+ # business_account_id: "102290129340398"
563
+ # )
564
+ # puts signup.status # "verifying"
565
+ def create(phone_number:, business_account_id: nil, verification_method: nil, display_name: nil)
318
566
  raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
567
+ if !business_account_id.nil? && business_account_id.to_s.strip.empty?
568
+ raise ValidationError, "business_account_id must be a non-empty string"
569
+ end
319
570
 
320
- response = @client.post("/whatsapp/signup", { phoneNumber: phone_number })
571
+ body = { phoneNumber: phone_number }
572
+ body[:businessAccountId] = business_account_id.to_s unless business_account_id.nil?
573
+ body[:verificationMethod] = verification_method unless verification_method.nil?
574
+ body[:displayName] = display_name unless display_name.nil?
575
+
576
+ by_code = !business_account_id.nil? && !business_account_id.to_s.empty?
577
+ response = @client.post("/whatsapp/signup", body, retry_server_errors: !by_code)
321
578
  WhatsAppSignupSession.new(response)
322
579
  end
323
580
 
324
- # Get the status of a WhatsApp signup.
581
+ # Submit the 6-digit code Meta sent to a number being added to a
582
+ # connected account (a +"verifying"+ signup). Spaces and dashes are
583
+ # ignored. On success the number is connected and the
584
+ # +whatsapp_account.connected+ webhook fires. A signup that is already
585
+ # +"active"+ comes back unchanged. The code can also be read from
586
+ # {WhatsAppSignup#verification_code} once Meta's text arrives on the
587
+ # number. Requires a live API key with the +whatsapp:write+ scope and, in
588
+ # a team workspace, an owner or admin (+settings:write+).
589
+ #
590
+ # @param id [String] The signup's id
591
+ # @param code [String] The 6-digit code
592
+ # @return [WhatsAppSignup] The signup, +"active"+ on success
593
+ # @raise [Sendly::ValidationError] +invalid_verification_code+ (400) when
594
+ # the code isn't 6 digits, and +whatsapp_verification_code_invalid+
595
+ # (422) for a wrong code, with the attempts left in
596
+ # +e.response_body["attemptsRemaining"]+
597
+ # @raise [Sendly::APIError] 409 +whatsapp_verification_failed+ after 5
598
+ # wrong codes (the session failed, the fee is refunded and
599
+ # +whatsapp_account.failed+ fires), 409 +whatsapp_verification_busy+
600
+ # while another code for the number is being checked (try again), or
601
+ # 409 +signup_not_active+ when the signup isn't waiting for a code or is
602
+ # older than 3 hours
603
+ # @raise [Sendly::NotFoundError] +signup_not_found+ (404)
604
+ # @raise [Sendly::ServerError] 502 +whatsapp_verification_unavailable+
605
+ # (WhatsApp couldn't check the code; the attempt isn't counted) or 502
606
+ # +whatsapp_activation_pending+ (the code was accepted but connecting
607
+ # the number didn't finish; check back shortly with {#get}). A 5xx, a
608
+ # timeout or a dropped connection is raised at once and never retried
609
+ # by the client: each submission
610
+ # uses one of the 5 attempts, and after +whatsapp_activation_pending+
611
+ # WhatsApp has already accepted the code.
612
+ #
613
+ # @example
614
+ # signup = client.whatsapp.signup.verify(signup.id, code: "482913")
615
+ # puts signup.status # "active"
616
+ def verify(id, code:)
617
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
618
+ raise ValidationError, "code is required" if code.nil? || code.to_s.empty?
619
+
620
+ encoded_id = URI.encode_www_form_component(id)
621
+ response = @client.post("/whatsapp/signup/#{encoded_id}/verify", { code: code }, retry_server_errors: false)
622
+ WhatsAppSignup.new(response)
623
+ end
624
+
625
+ # Ask Meta to send a new code to a number being added to a connected
626
+ # account. Codes can be requested at most every 30 seconds, counted from
627
+ # the signup's last change (a code submission included). A signup that
628
+ # is already +"active"+ comes back unchanged. Requires a live API key with
629
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
630
+ # (+settings:write+).
631
+ #
632
+ # @param id [String] The signup's id
633
+ # @param verification_method [String, nil] +"sms"+ (the default) or
634
+ # +"voice"+
635
+ # @return [WhatsAppSignup]
636
+ # @raise [Sendly::RateLimitError] +whatsapp_verification_resend_too_soon+
637
+ # (429), raised at once with the seconds to wait in +e.retry_after+
638
+ # @raise [Sendly::ValidationError] +whatsapp_verification_resend_failed+
639
+ # (422) when WhatsApp wouldn't send another code yet
640
+ # @raise [Sendly::APIError] 409 +signup_not_active+ when the signup isn't
641
+ # waiting for a code or is older than 3 hours
642
+ # @raise [Sendly::NotFoundError] +signup_not_found+ (404)
643
+ # @raise [Sendly::ServerError] +whatsapp_verification_resend_failed+
644
+ # (502) when WhatsApp couldn't be reached. The client retries a 5xx
645
+ # before raising.
646
+ #
647
+ # @example
648
+ # client.whatsapp.signup.resend(signup.id, verification_method: "voice")
649
+ def resend(id, verification_method: nil)
650
+ raise ValidationError, "id is required" if id.nil? || id.to_s.empty?
651
+
652
+ body = {}
653
+ body[:verificationMethod] = verification_method unless verification_method.nil?
654
+
655
+ encoded_id = URI.encode_www_form_component(id)
656
+ response = @client.post("/whatsapp/signup/#{encoded_id}/resend", body)
657
+ WhatsAppSignup.new(response)
658
+ end
659
+
660
+ # Get the status of a WhatsApp signup. Needs the +whatsapp:read+ scope;
661
+ # test keys work. While a number is +"verifying"+ the signup carries
662
+ # {WhatsAppSignup#verification_code} once Meta's text has arrived.
325
663
  #
326
664
  # @param id [String] The signup's id
327
665
  # @return [WhatsAppSignup]
@@ -340,7 +678,8 @@ module Sendly
340
678
  end
341
679
 
342
680
  # List the numbers connected (or connecting) to WhatsApp, and manage
343
- # their business profiles.
681
+ # their business profiles, profile photos, conversational components and
682
+ # WhatsApp calling.
344
683
  class WhatsAppSendersResource
345
684
  def initialize(client)
346
685
  @client = client
@@ -348,7 +687,8 @@ module Sendly
348
687
 
349
688
  # List your WhatsApp senders, newest first. An empty list means no
350
689
  # number is connected yet — start one with
351
- # {WhatsAppSignupResource#create}.
690
+ # {WhatsAppSignupResource#create}. Needs the +whatsapp:read+ scope; test
691
+ # keys work.
352
692
  #
353
693
  # @return [Hash] +{ senders: Array<WhatsAppSender> }+
354
694
  #
@@ -367,7 +707,8 @@ module Sendly
367
707
  # The business profile is what recipients see when they open the
368
708
  # sender's details in WhatsApp: display name, photo, category, about
369
709
  # line, description, and contact details. The sender must be +"active"+
370
- # (connected to WhatsApp).
710
+ # (connected to WhatsApp). Needs the +whatsapp:read+ scope; test keys
711
+ # work.
371
712
  #
372
713
  # @param phone_number [String] Your WhatsApp-connected sending number,
373
714
  # in E.164 format
@@ -391,8 +732,10 @@ module Sendly
391
732
  # Pass only the fields to change; omitted fields keep their current
392
733
  # value. +about+ is limited to 139 characters and +description+ to 512.
393
734
  # +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.
735
+ # The profile photo cannot be set through this method; use
736
+ # {#upload_profile_photo}. Requires a live
737
+ # API key with the +whatsapp:write+ scope and, in a team workspace, an
738
+ # owner or admin (+settings:write+).
396
739
  #
397
740
  # @param phone_number [String] Your WhatsApp-connected sending number,
398
741
  # in E.164 format
@@ -430,6 +773,166 @@ module Sendly
430
773
  response = @client.patch("/whatsapp/senders/#{encoded_number}/profile", body)
431
774
  WhatsAppSenderProfile.new(response)
432
775
  end
776
+
777
+ # Upload a sender's WhatsApp profile photo, replacing the current one.
778
+ #
779
+ # The photo must be a JPEG or PNG (checked by its bytes, not by
780
+ # +content_type+) of at most 5 MB. WhatsApp wants it square and at least
781
+ # 192 pixels wide (640 recommended). Free. Requires a live API key with
782
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
783
+ # (+settings:write+).
784
+ #
785
+ # @param phone_number [String] Your WhatsApp-connected sending number,
786
+ # in E.164 format
787
+ # @param file [String, IO] A file path or an IO holding the image
788
+ # @param content_type [String] MIME type sent with the file
789
+ # @param filename [String] Name sent with the file
790
+ # @return [WhatsAppSenderProfile] The profile with its new photo
791
+ # @raise [Sendly::ValidationError] +whatsapp_profile_photo_invalid+ (400)
792
+ # when the file isn't a JPEG or PNG, or +file_required+ (400)
793
+ # @raise [Sendly::APIError] +whatsapp_profile_photo_too_large+ with
794
+ # +status_code+ 413 when the photo is over 5 MB
795
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
796
+ # @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (502)
797
+ # when WhatsApp refused the image or couldn't be reached. A 5xx, a
798
+ # timeout or a dropped connection is raised at once and never retried
799
+ # by the client; check the image is
800
+ # square and at least 192 pixels wide before trying again.
801
+ #
802
+ # @example
803
+ # client.whatsapp.senders.upload_profile_photo("+14155550123", "logo.png",
804
+ # content_type: "image/png", filename: "logo.png")
805
+ def upload_profile_photo(phone_number, file, content_type: "image/jpeg", filename: "profile.jpg")
806
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
807
+ raise ValidationError, "file is required" if file.nil?
808
+
809
+ encoded_number = URI.encode_www_form_component(phone_number)
810
+ response = @client.post_multipart("/whatsapp/senders/#{encoded_number}/profile/photo", file,
811
+ content_type: content_type, filename: filename,
812
+ retry_server_errors: false)
813
+ WhatsAppSenderProfile.new(response)
814
+ end
815
+
816
+ # Remove a sender's WhatsApp profile photo. Requires a live API key with
817
+ # the +whatsapp:write+ scope and, in a team workspace, an owner or admin
818
+ # (+settings:write+).
819
+ #
820
+ # @param phone_number [String] Your WhatsApp-connected sending number,
821
+ # in E.164 format
822
+ # @return [WhatsAppSenderProfile] The profile, normally with
823
+ # +profile_photo_url+ nil
824
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
825
+ # @raise [Sendly::ServerError] +whatsapp_profile_update_failed+ (502).
826
+ # The client retries a 5xx before raising.
827
+ #
828
+ # @example
829
+ # client.whatsapp.senders.delete_profile_photo("+14155550123")
830
+ def delete_profile_photo(phone_number)
831
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
832
+
833
+ encoded_number = URI.encode_www_form_component(phone_number)
834
+ response = @client.delete("/whatsapp/senders/#{encoded_number}/profile/photo")
835
+ WhatsAppSenderProfile.new(response)
836
+ end
837
+
838
+ # Get a sender's conversational components: the ice breakers shown when
839
+ # someone opens a chat with the business for the first time, and the
840
+ # commands shown when the customer types "/". Needs the +whatsapp:read+
841
+ # scope; test keys work.
842
+ #
843
+ # @param phone_number [String] Your WhatsApp-connected sending number,
844
+ # in E.164 format
845
+ # @return [WhatsAppConversationalComponents]
846
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
847
+ # @raise [Sendly::ServerError]
848
+ # +whatsapp_conversational_components_fetch_failed+ (502). The client
849
+ # retries a 5xx before raising.
850
+ #
851
+ # @example
852
+ # components = client.whatsapp.senders.get_conversational_components("+14155550123")
853
+ # components.commands.each { |c| puts "/#{c.command}: #{c.description}" }
854
+ def get_conversational_components(phone_number)
855
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
856
+
857
+ encoded_number = URI.encode_www_form_component(phone_number)
858
+ response = @client.get("/whatsapp/senders/#{encoded_number}/conversational_components")
859
+ WhatsAppConversationalComponents.new(response)
860
+ end
861
+
862
+ # Replace a sender's ice breakers, commands, or both.
863
+ #
864
+ # Each list you pass replaces the stored one, and +[]+ clears it; a list
865
+ # you leave out is kept. Ice breakers: at most 4, each 1-80 characters,
866
+ # no duplicates. Commands: at most 30, each a hash with +:command+
867
+ # (letters, digits and underscores, 1-32 characters; a leading "/" is
868
+ # dropped) and +:description+ (1-256 characters), no duplicate commands.
869
+ # The {WhatsAppCommand} objects a read returns can be passed back. Free.
870
+ # Requires a live API key with the +whatsapp:write+ scope and, in a team
871
+ # workspace, an owner or admin (+settings:write+).
872
+ #
873
+ # @param phone_number [String] Your WhatsApp-connected sending number,
874
+ # in E.164 format
875
+ # @param ice_breakers [Array<String>, nil] The new ice breakers
876
+ # @param commands [Array<Hash, WhatsAppCommand>, nil] The new commands
877
+ # @return [WhatsAppConversationalComponents] The stored components
878
+ # @raise [Sendly::ValidationError] If neither list is given, or
879
+ # +invalid_request+ (400) with a message naming the problem
880
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
881
+ # @raise [Sendly::ServerError]
882
+ # +whatsapp_conversational_components_update_failed+ (502). The client
883
+ # retries a 5xx before raising.
884
+ #
885
+ # @example
886
+ # client.whatsapp.senders.update_conversational_components("+14155550123",
887
+ # ice_breakers: ["Track my order", "Opening hours"],
888
+ # commands: [{ command: "menu", description: "See today's menu" }]
889
+ # )
890
+ def update_conversational_components(phone_number, ice_breakers: nil, commands: nil)
891
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
892
+
893
+ body = {}
894
+ body[:iceBreakers] = ice_breakers unless ice_breakers.nil?
895
+ body[:commands] = commands.map { |c| c.is_a?(WhatsAppCommand) ? c.to_h : c } unless commands.nil?
896
+ raise ValidationError, "Provide ice_breakers, commands, or both" if body.empty?
897
+
898
+ encoded_number = URI.encode_www_form_component(phone_number)
899
+ response = @client.patch("/whatsapp/senders/#{encoded_number}/conversational_components", body)
900
+ WhatsAppConversationalComponents.new(response)
901
+ end
902
+
903
+ # Switch WhatsApp calling on or off for a sender. Free.
904
+ #
905
+ # Once it is on, a WhatsApp user calling the number rings exactly like
906
+ # a phone call (in the dashboard or with your AI agent, per the number's
907
+ # voice settings), billed at the normal inbound rate. Turning it on
908
+ # needs voice switched on for the number first. There is no API for
909
+ # placing WhatsApp calls. Requires a live API key with the
910
+ # +whatsapp:write+ scope and, in a team workspace, an owner or admin
911
+ # (+settings:write+).
912
+ #
913
+ # @param phone_number [String] Your WhatsApp-connected sending number,
914
+ # in E.164 format
915
+ # @param enabled [Boolean] true to switch calling on, false to switch it off
916
+ # @return [WhatsAppCallingSettings]
917
+ # @raise [Sendly::APIError] +voice_not_enabled+ with +status_code+ 409
918
+ # when voice isn't on for the number
919
+ # @raise [Sendly::ValidationError] +whatsapp_calling_unavailable+ (422)
920
+ # when Meta refused: calling needs the account at the 2,000
921
+ # recipients a day messaging limit and an approved display name
922
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404)
923
+ # @raise [Sendly::ServerError] +whatsapp_calling_update_failed+ (502).
924
+ # The client retries a 5xx before raising.
925
+ #
926
+ # @example
927
+ # settings = client.whatsapp.senders.set_calling("+14155550123", enabled: true)
928
+ # puts settings.calling_enabled?
929
+ def set_calling(phone_number, enabled:)
930
+ raise ValidationError, "phone_number is required" if phone_number.nil? || phone_number.to_s.empty?
931
+
932
+ encoded_number = URI.encode_www_form_component(phone_number)
933
+ response = @client.patch("/whatsapp/senders/#{encoded_number}/calling", { enabled: enabled })
934
+ WhatsAppCallingSettings.new(response)
935
+ end
433
936
  end
434
937
 
435
938
  # Manage Meta-reviewed message templates.
@@ -438,7 +941,8 @@ module Sendly
438
941
  @client = client
439
942
  end
440
943
 
441
- # List your WhatsApp templates.
944
+ # List your WhatsApp templates. Needs the +whatsapp:read+ scope; test
945
+ # keys work.
442
946
  #
443
947
  # @return [Hash] +{ templates: Array<WhatsAppTemplate> }+
444
948
  #
@@ -455,18 +959,24 @@ module Sendly
455
959
  # Create a template and submit it to Meta for review.
456
960
  #
457
961
  # Review usually takes 24-48h; the template is usable once its status
458
- # is +"APPROVED"+. Requires a live API key.
962
+ # is +"APPROVED"+. Requires a live API key with the +whatsapp:write+
963
+ # scope and, in a team workspace, an owner, admin or member
964
+ # (+templates:write+). A marketing template without an opt-out button is
965
+ # still accepted, with a warning.
459
966
  #
460
967
  # @param sender [String] The WhatsApp-connected sending number this
461
968
  # template belongs to, in E.164 format
462
969
  # @param name [String] Template name: lowercase letters, digits, and
463
970
  # underscores (e.g. "order_shipped")
464
971
  # @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
972
+ # @param category [String] "AUTHENTICATION", "UTILITY", or "MARKETING"
973
+ # (the server uppercases it). Required, with no default; it drives Meta
974
+ # review rules and pricing and can't be changed later
467
975
  # @param body [String] Body text. Use {{1}}, {{2}}, ... for variables;
468
976
  # every placeholder needs an example value in +examples+
469
- # @param header [String, nil] Optional text header
977
+ # @param header [String, nil] Optional text header. Fixed text only: a
978
+ # header containing {{n}} is refused with
979
+ # +template_header_variable_unsupported+
470
980
  # @param footer [String, nil] Optional footer line
471
981
  # @param buttons [Array<Hash>, nil] Optional buttons, each with :type
472
982
  # ("url", "quick_reply", or "otp"), :text, and for url buttons a :url
@@ -476,7 +986,15 @@ module Sendly
476
986
  # Required when the body has variables.
477
987
  # @return [WhatsAppTemplate] The created template (status "PENDING"),
478
988
  # with any submission warnings
479
- # @raise [Sendly::NotFoundError] If the sender isn't connected to WhatsApp
989
+ # @raise [Sendly::NotFoundError] +whatsapp_sender_not_connected+ (404) if
990
+ # the sender isn't connected to WhatsApp; this is checked first
991
+ # @raise [Sendly::ValidationError] If the API refuses the template with a
992
+ # 400 +template_*+ code (in +e.response_body["error"]+) and a readable
993
+ # message: +template_category_invalid+ (category missing or not one of
994
+ # the three), +template_authentication_otp_button_required+,
995
+ # +template_authentication_no_links+ (a link in the body or a URL
996
+ # button on an authentication template) or
997
+ # +template_header_variable_unsupported+
480
998
  #
481
999
  # @example
482
1000
  # template = client.whatsapp.templates.create(
@@ -517,11 +1035,14 @@ module Sendly
517
1035
  # This is the recovery path for rejections: template names are locked
518
1036
  # for ~30 days after deletion, so editing a rejected template (rather
519
1037
  # 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.
1038
+ # template goes back to "PENDING" review. The category can't be
1039
+ # changed. Requires a live API key with the +whatsapp:write+ scope and,
1040
+ # in a team workspace, an owner, admin or member (+templates:write+).
521
1041
  #
522
1042
  # @param id [String] The template's id
523
1043
  # @param body [String, nil] Replacement body text
524
- # @param header [String, nil] Replacement text header
1044
+ # @param header [String, nil] Replacement text header; it can't contain
1045
+ # {{n}} variables (+template_header_variable_unsupported+)
525
1046
  # @param footer [String, nil] Replacement footer
526
1047
  # @param buttons [Array<Hash>, nil] Replacement buttons
527
1048
  # @param examples [Hash, nil] Replacement example values for body placeholders
@@ -552,7 +1073,9 @@ module Sendly
552
1073
  #
553
1074
  # Meta locks a deleted template's name for ~30 days — re-creating it
554
1075
  # fails with +template_name_locked+ until the lock lifts. To fix a
555
- # rejected template, prefer {#update}. Requires a live API key.
1076
+ # rejected template, prefer {#update}. Requires a live API key with the
1077
+ # +whatsapp:write+ scope and, in a team workspace, an owner, admin or
1078
+ # member (+templates:write+).
556
1079
  #
557
1080
  # @param id [String] The template's id
558
1081
  # @return [WhatsAppTemplateDeletion]
@@ -576,10 +1099,12 @@ module Sendly
576
1099
  # create Meta-reviewed message templates, and send via
577
1100
  # +client.messages.send(channel: "whatsapp", ...)+.
578
1101
  #
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.
1102
+ # Connecting a number is a one-time $19 setup (no monthly fee). The first
1103
+ # number always ends with a human step: {WhatsAppSignupResource#create}
1104
+ # returns a +connect_url+ that a person must open in a browser and log in
1105
+ # with Facebook to link their WhatsApp Business Account. More numbers can
1106
+ # then be added to that account with a code Meta sends to the number
1107
+ # (+business_account_id:+ on create, then {WhatsAppSignupResource#verify}).
583
1108
  #
584
1109
  # Two ways to reach a recipient:
585
1110
  #
@@ -590,6 +1115,31 @@ module Sendly
590
1115
  # marketing — pricing follows the category and destination country.
591
1116
  # Note: Meta has paused marketing template delivery to US (+1) numbers.
592
1117
  #
1118
+ # Pricing: free-form text or media inside the 24-hour window costs 1
1119
+ # credit each for the first 1,000 per sending number per calendar month
1120
+ # (UTC), then the destination's utility template price; countries without
1121
+ # a listed price use the default utility price of 12 credits. Templates
1122
+ # are priced by category and destination country; countries without a
1123
+ # listed price use 33 (marketing), 12 (utility) and 12 (authentication)
1124
+ # credits. A failed send gives its slot back.
1125
+ #
1126
+ # Scopes and keys: sends go through +messages.send(channel: "whatsapp")+
1127
+ # and need +sms:send+, not +whatsapp:write+, and a live key. Reads (signup
1128
+ # status, templates, the window, senders and sender profiles) need
1129
+ # +whatsapp:read+ and accept test keys. Signup, template create/edit/delete
1130
+ # and profile edits need +whatsapp:write+ and a live key (otherwise 403
1131
+ # +whatsapp_requires_live_key+). In a team workspace, connecting and
1132
+ # profile edits need an owner or admin (+settings:write+), and template
1133
+ # writes need an owner, admin or member (+templates:write+); a missing
1134
+ # role returns 403 +insufficient_permissions+. Reading conversational
1135
+ # components needs +whatsapp:read+ too and accepts test keys. The profile
1136
+ # photo, conversational component and calling changes, and submitting or
1137
+ # resending a verification code, need the same as a profile edit.
1138
+ #
1139
+ # WhatsApp is enabled per person: the user who owns the API key, not the
1140
+ # workspace. While it is off, sends return 403 +whatsapp_not_enabled+ and
1141
+ # every method on this resource gets 404 +not_found+.
1142
+ #
593
1143
  # @example Connect a number, create a template, then send
594
1144
  # # 1. Connect ($19 one-time; a human must open the connect URL)
595
1145
  # signup = client.whatsapp.signup.create(phone_number: "+15559876543")
@@ -615,7 +1165,7 @@ module Sendly
615
1165
  attr_reader :signup
616
1166
 
617
1167
  # @return [WhatsAppSendersResource] List connected numbers and manage
618
- # their business profiles
1168
+ # their business profiles, photos, conversational components and calling
619
1169
  attr_reader :senders
620
1170
 
621
1171
  # @return [WhatsAppTemplatesResource] Manage Meta-reviewed templates
@@ -633,7 +1183,8 @@ module Sendly
633
1183
  #
634
1184
  # Free-form text and media only deliver while a window is open (it
635
1185
  # opens when the recipient messages you and lasts 24h from their last
636
- # inbound message). Outside a window, send an approved template.
1186
+ # inbound message). Outside a window, send an approved template. Needs
1187
+ # the +whatsapp:read+ scope; test keys work.
637
1188
  #
638
1189
  # @param from [String] Your WhatsApp-connected sending number, in E.164 format
639
1190
  # @param to [String] The recipient's number, in E.164 format