ruby-whatsapp 0.4.2 → 0.6.2

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.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/README.md +70 -7
  4. data/docs/README.md +4 -1
  5. data/docs/business_phone_number/README.md +12 -1
  6. data/docs/business_phone_number/contact_book.md +90 -0
  7. data/docs/business_phone_number/username.md +157 -0
  8. data/docs/configuration.md +6 -1
  9. data/docs/errors.md +12 -1
  10. data/docs/message_templates/README.md +1 -1
  11. data/docs/message_templates/components.md +37 -1
  12. data/docs/messages/README.md +86 -9
  13. data/docs/messages/address.md +1 -1
  14. data/docs/messages/audio.md +1 -1
  15. data/docs/messages/contacts.md +1 -1
  16. data/docs/messages/document.md +1 -1
  17. data/docs/messages/image.md +1 -1
  18. data/docs/messages/interactive.md +84 -4
  19. data/docs/messages/location.md +1 -1
  20. data/docs/messages/location_request.md +1 -1
  21. data/docs/messages/reaction.md +1 -1
  22. data/docs/messages/sticker.md +1 -1
  23. data/docs/messages/template.md +28 -1
  24. data/docs/messages/text.md +1 -1
  25. data/docs/messages/video.md +1 -1
  26. data/docs/schedule/README.md +183 -0
  27. data/docs/webhooks/README.md +20 -12
  28. data/docs/webhooks/business_username_updates.md +55 -0
  29. data/docs/webhooks/history.md +78 -15
  30. data/docs/webhooks/messages.md +141 -6
  31. data/docs/webhooks/smb_app_state_sync.md +50 -12
  32. data/docs/webhooks/smb_message_echoes.md +58 -21
  33. data/docs/webhooks/user_id_update.md +84 -0
  34. data/docs/webhooks/user_preferences.md +55 -16
  35. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  36. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  37. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  38. data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
  39. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  40. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  41. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  42. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  43. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  44. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  45. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  46. data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
  47. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  48. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  49. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  50. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  51. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  52. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +11 -6
  53. data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +12 -0
  54. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  55. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  56. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  57. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  58. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  59. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  60. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  61. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  62. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  63. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  64. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  65. data/lib/ruby/whatsapp/schedule.rb +15 -0
  66. data/lib/ruby/whatsapp/version.rb +1 -1
  67. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  68. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  69. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  70. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  71. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  72. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  73. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  74. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  75. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  76. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  77. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  78. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  79. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  80. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  81. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  82. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  83. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  84. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  85. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  86. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  87. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  88. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  89. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  90. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  91. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  92. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  93. metadata +38 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fa9a4e0fe14f7868639ddb3fe8285801aba43d4c92ae5decf7f8d7e5e80882e
4
- data.tar.gz: 534f91cc1841a1be0b9740619360287dcc0ee9cfc6d82b3ba12fde421cdbc81e
3
+ metadata.gz: 5c33c221aee102d5b25468617f808a4f91c2e54a5659922576cd542dff802458
4
+ data.tar.gz: 10d24cb87f52e82ba182b4360bd25c6e2ae1fe9b8bb78d4d6141c17b2dc6c91f
5
5
  SHA512:
6
- metadata.gz: dd34f6bd15f531af85b31feeeb767290abcb82c291b03cbd02398ce310cb752140c31ba9e1b37b5e36db796c4992126be2dadc12b1041a2ab67f114c71a20a6c
7
- data.tar.gz: 1a5afbd7d50790859e60340e87856574f7b53f6c647191c5b7d639023efd1e6129e2f37010c6f513dbc429aaa4b09d3ac87903dca86dff0e8d6f0cce4dcf20bd
6
+ metadata.gz: bd104f12c9ec44b4b07e5912f18e98ae4646a4eb1f51869b27dcc978b76ee06d385e81a6d41ce449b96460f08e0d2ddfb56b4d57ce410d3493f45e243f71883c
7
+ data.tar.gz: 79366a3af17543676a403b43b23e2df31b5c4219f2b440a2a438053ee8fa651f6513651e85b237693f30749afe085879d948c828a6090337c5e14508398ba7a3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,161 @@
1
+ ## [0.6.2] - 2026-10-22
2
+
3
+ ### Fixed
4
+
5
+ - **A single-section list message no longer needs a section title.** Meta requires
6
+ `sections[].title` only when a list has more than one section, but
7
+ `Interactive::ListButtons::Section` demanded one always — `title:` was a required
8
+ keyword with a presence validation — so a valid single-section list without a title
9
+ could not be built. `Section` now takes `title:` as optional (still max 24 characters)
10
+ and omits the key from the payload when it is absent, and `ListButtons` enforces Meta's
11
+ actual rule: with two or more sections, every one must have a title, otherwise it fails
12
+ with "Sections must each have a title when there is more than one section". A blank
13
+ title (`""` or whitespace) is treated as absent — omitted from a single section, and
14
+ counted as missing when there are several — rather than sent as an empty heading.
15
+
16
+ ## [0.6.1] - 2026-10-21
17
+
18
+ ### Added
19
+
20
+ - **`Whatsapp::Schedule`** — `List` and `Create` wrap a WhatsApp Business Account's
21
+ campaign schedules (`/{waba_id}/schedules`): an approved template sent to an audience
22
+ at a Unix `delivery_time`. `Create` checks all six documented-required fields before
23
+ the request, and requires `delivery_time` to be an integer. It checks nothing else,
24
+ since Meta publishes no length limits and no rule that the time must be in the future.
25
+ `List` takes `fields`, `limit`, `after`, and `before`, and returns an `Enumerable`
26
+ `Response::Collection` of `Response::Node`s with `next_cursor`. Its paging reuses
27
+ `MessageTemplates::Response::Paging` rather than duplicating it. `Node#status` stays
28
+ the raw string, with `Statuses` constants and `scheduled?`/`sending?`/`completed?`/
29
+ `failed?` for comparison. Meta's default `fields` omit `id`, so
30
+ `List::Fields::ALL` is offered to ask for it. Every failure raises the new
31
+ `Whatsapp::Schedule::Error`.
32
+
33
+ ## [0.6.0] - 2026-10-20
34
+
35
+ Completes the Business-Scoped User ID (BSUID) work that 0.5.0 began. 0.5.0 covered the
36
+ inbound webhook half; this covers the send side, the four webhook fields Meta's BSUID
37
+ documentation changed but 0.5.0 did not touch, the two new phone-number APIs, and the
38
+ button that asks a username adopter for their phone number back.
39
+
40
+ ### Fixed
41
+
42
+ - **`Whatsapp::Webhook::UserPreferences` and `::History` could never populate.** Both read
43
+ their payload one nesting level too high — `value["wa_id"]` and `value["metadata"]`,
44
+ where Meta nests entries under a repeat of the field's own name
45
+ (`value["user_preferences"][]`, `value["history"][]`), the convention `UserIdUpdate`
46
+ already followed. Every accessor on both classes returned `nil` or `[]` for a real
47
+ payload. The BSUID documentation is the first published example for either field, which
48
+ is what caught it; both specs now carry those payloads verbatim as regression guards.
49
+ **Breaking:** `UserPreferences`' `wa_id`/`detail`/`value`/`timestamp` move to
50
+ `change.value.preferences.first.*`, and `History#threads` moves to
51
+ `chunks.first.threads` — `#phase`/`#chunk_order`/`#progress` keep their names and now
52
+ actually work.
53
+ - **Corrected `Status#recipient_user_id`'s documented behavior.** The docs and one spec
54
+ claimed it appears only when you addressed a message to a BSUID. Per Meta's May 4, 2026
55
+ correction it is sent on _every_ status however the message was addressed, and omitted
56
+ only on a `failed` status for a message sent to a phone number. The deserializer was
57
+ always right; the claim around it was not. Also newly documented: the status webhook's
58
+ `contacts[]` accompanies `sent`/`delivered`/`read` but is omitted entirely for `failed`,
59
+ and `contacts[].profile.username` is omitted on `sent`.
60
+
61
+ ### Added
62
+
63
+ - **Send to a BSUID.** `Whatsapp::Messages::Base` gains `recipient` alongside `to`, so a
64
+ BSUID read off a webhook can be replied to without ever knowing the phone number. Supply
65
+ either or both — Meta gives `to` precedence — and at least one is required, which is now
66
+ a validation rather than a missing-keyword `ArgumentError`. Because all 13 message kinds
67
+ already forwarded with `super(**)`, and the `KINDS` factory splats `**payload`, this
68
+ reached every kind and `send_<kind>!` method without touching either. `envelope` compacts
69
+ the recipient pair only, so an omitted identifier is left out rather than sent as null.
70
+ `Messages::Response::Contacts` gains `user_id` and makes `wa_id` optional to match —
71
+ noting that Meta omits `user_id` whenever the message went to a phone number, _including_
72
+ when both identifiers were supplied, so a nil `user_id` does not mean the recipient has
73
+ no BSUID. The BSUID is deliberately not format-checked: its canonical source is this
74
+ gem's own webhook side, which keeps it a raw string, and rejecting on the way out what
75
+ was accepted on the way in would be the worst possible asymmetry.
76
+ - **`Whatsapp::BusinessPhoneNumber::Username`** — `Get`, `Claim`, `Delete`, and `Suggest`
77
+ wrap the searchable `@name` a WhatsApp user can find a number by. Four endpoints, three
78
+ wire shapes, two new value classes: `Username::Details` covers both `Get` and `Claim`
79
+ since `{status}` is a subset of `{username, status}`, `Username::Suggestions` owns the
80
+ doubly-wrapped suggestions envelope and is `Enumerable`, and `Delete` reuses the existing
81
+ `BusinessPhoneNumber::Response` for its bare `{success}`. Every one of Meta's published
82
+ username format rules is checked before the request, since the edge is rate limited — bar
83
+ the domain-suffix rule, whose published list ends in "and so on", so a suffix outside the
84
+ documented ten is left to the API. Mixed case is downcased rather than rejected, because
85
+ Meta compares usernames case-insensitively. `Details::Statuses` deliberately omits
86
+ `"deleted"`: that value reaches you only through the `business_username_updates` webhook,
87
+ and a write-side enum must not name a state no endpoint here can return.
88
+ - **`Whatsapp::BusinessPhoneNumber::ContactBook::Delete`** — removes a stored user contact
89
+ from the store Meta uses to keep putting a username adopter's phone number in your
90
+ webhooks. Returns its own `ContactBook::Deletion` rather than the shared `Response`,
91
+ because `deleted` is a second independent boolean that changes how `success` reads:
92
+ `success: true, deleted: false` means the request was fine and there was simply nothing
93
+ stored. Rejects a parent BSUID locally — the one BSUID-shape assertion in the gem, and
94
+ safe because it tests for a documented extra segment rather than a country-code grammar
95
+ Meta has never published. Both new namespaces reuse `BusinessPhoneNumber::Transport`
96
+ unchanged; there are still only two transports in that directory, three sub-namespaces
97
+ later.
98
+ - **`REQUEST_CONTACT_INFO`**, in both forms: a `MessageTemplates::Button` type for opening
99
+ a conversation with the ask, and a `Messages::Interactive` action kind
100
+ (`type: :request_contact_info`) for making it inside the 24-hour service window. Both
101
+ were previously recorded as deliberately absent for want of a published schema; the BSUID
102
+ documentation publishes one. The template button's `UTILITY`/`MARKETING` restriction lives
103
+ in `ComponentSet` alongside the other cross-component rules, and is checked by drilling
104
+ into the `BUTTONS` component rather than through `CATEGORY_RESTRICTED_TYPES`, which keys
105
+ on component types. The interactive kind is the first with nothing for a caller to
106
+ supply, so `ACTION_TYPES` gained a `parameterless` flag and `action:` is no longer
107
+ required for it.
108
+ - **Four more webhook fields brought up to their published schemas.**
109
+ `Whatsapp::Webhook::SmbMessageEchoes` gains a value-level `contacts` array and wraps each
110
+ echo in a new `SmbMessageEchoes::Echo` carrying `to`/`to_user_id`/`to_parent_user_id` —
111
+ a wrapper rather than three more accessors on `Message::Base`, because an inbound message
112
+ can never carry a recipient, and three permanently-nil readers on fifteen classes would
113
+ turn a helpful `NoMethodError` into a silent `nil`. **Breaking:** `echo.body` becomes
114
+ `echo.message.body`. `SmbAppStateSync` gains `messaging_product`/`metadata` and types its
115
+ synced contact as `SmbAppStateSync::Contact` (`full_name`, `first_name`, `phone_number`,
116
+ `user_id`, `parent_user_id`, `username`), with the per-entry `metadata.timestamp`
117
+ flattened onto the entry. **Breaking:** `sync.contact["phone_number"]` becomes
118
+ `sync.contact.phone_number`, and an entry with no contact returns `nil` rather than `{}`.
119
+ All four fields, plus `UserPreferences` and `History`, now expose `raw`.
120
+ - **`revoke` and `edit` message types**, registered in `Message::MESSAGE_TYPES` rather than
121
+ falling through to `Message::Unknown`. `Revoke` carries `original_message_id`; `Edit`
122
+ carries that plus a nested `message` recursed back through `Message.deserialize`, so an
123
+ edited image behaves exactly like an inbound one — content only, so the nested payload's
124
+ own `from`/`id`/`timestamp` read `nil`. Both also arrive through `smb_message_echoes`
125
+ when the business edits or revokes from the WhatsApp Business app.
126
+ - **`Message::Base#group_id`**, set when an inbound message came from a group chat.
127
+ Extracted once in `common_attributes`, so every message type has it. The matching
128
+ group-status identifiers on `Status` are deliberately _not_ added, and are now recorded
129
+ under a new "Not implemented" section in `lib/ruby/whatsapp/webhook/CLAUDE.md` along with
130
+ the `calls` and `group_participants_update` fields: they only appear on a status for a
131
+ message sent to a group, and this gem wraps no Groups API, so it can never receive one.
132
+
133
+ ## [0.5.0] - 2026-08-24
134
+
135
+ - Add Business-Scoped User ID (BSUID) support to inbound webhooks: Meta is rolling out
136
+ WhatsApp usernames, after which a user's phone number can be omitted from a webhook
137
+ entirely, leaving their BSUID (`user_id`, e.g. `"US.13491208655302741918"`) as the only
138
+ stable identifier. `Whatsapp::Webhook::Contact`, `Message::Base` (and therefore every
139
+ message type: `Text`, the `Media` family, `Location`, `Contacts`, `Interactive`,
140
+ `Button`, `Order`, `System`, `Reaction`, `Unknown`), and `Status` now all carry
141
+ `user_id`/`from_user_id`/`recipient_user_id`, alongside `parent_user_id` variants for
142
+ businesses enrolled in [parent BSUIDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#parent-business-scoped-user-ids).
143
+ `Contact` additionally gains `username`. `Message::Contacts::Contact` gains `vcard`/
144
+ `origin` for cards shared via a `REQUEST_CONTACT_INFO` button, and `Message::System`
145
+ gains its own `user_id`/`parent_user_id` for the customer's new BSUID after a
146
+ `user_changed_user_id` event. `Message::Context` carries `from_user_id`/
147
+ `from_parent_user_id` too, so a quoted reply still identifies its sender once the phone
148
+ number is gone. `from_user_id`/`from_parent_user_id` are extracted once in
149
+ `Message::Base.common_attributes`, so every message type gained them without any
150
+ per-type changes.
151
+ - Add two new confirmed webhook fields from the same BSUID rollout:
152
+ `Whatsapp::Webhook::UserIdUpdate` (a user's BSUID changed — composes the existing
153
+ `Metadata`/`Contact` plus `Entry`/`IdChange` for the `{previous, current}` change
154
+ records) and `Whatsapp::Webhook::BusinessUsernameUpdates` (a business phone number's
155
+ username moved between `approved`/`reserved`/`deleted`). Both also expose `raw`, the
156
+ untouched `value` hash, so a payload whose shape differs from Meta's published example
157
+ is still recoverable rather than silently deserializing to an empty object.
158
+
1
159
  ## [0.4.2] - 2026-08-19
2
160
 
3
161
  - Add WhatsApp Business Profile management: `Whatsapp::BusinessPhoneNumber::Profile::Get` reads the public card a WhatsApp user sees before they reply — about text, description, address, email, websites, industry vertical, and profile picture URL — and `::Profile::Update` writes them. Unlike `::Account`, this is not an exception to the module's `phone_id` rule: Meta exposes the profile as an edge on the phone number itself (`GET`/`POST /{phone_id}/whatsapp_business_profile`), so both actions reuse the existing `BusinessPhoneNumber::Transport#edge_path` unchanged — no new transport, no new ID to configure. `Update` validates the 21-value `Profile::Verticals` enum and Meta's documented limits (about 139, address 256, description 512, email 128 characters, at most 2 websites) before any request is made, and compacts omitted fields out of the body rather than sending null — which Meta reads as an instruction to blank them — with an empty `websites` array surviving on purpose, since that is how the list is cleared. `email`'s format is deliberately left to Meta, which validates it server-side; a regex here would reject valid exotic addresses. It reuses the existing `BusinessPhoneNumber::Response`, since the endpoint answers a bare `{success}`.
data/README.md CHANGED
@@ -48,6 +48,7 @@ onboarding, and reading or updating the business account itself.
48
48
  - [🔔 Webhooks](#-webhooks)
49
49
  - [📷 Media](#-media)
50
50
  - [🔌 Subscribed Apps](#-subscribed-apps)
51
+ - [📅 Scheduling Campaigns](#-scheduling-campaigns)
51
52
  - [📞 Business Phone Numbers](#-business-phone-numbers)
52
53
  - [🚨 Errors](#-errors)
53
54
  - [🧩 Compatibility](#-compatibility)
@@ -69,9 +70,14 @@ onboarding, and reading or updating the business account itself.
69
70
  - **Secrets stay out of logs.** `api_key`, `app_secret`, and `verify_token` are
70
71
  redacted from every `#inspect`, including credentials in transit like a
71
72
  registration PIN.
72
- - **Inbound webhooks, fully typed.** An object tree for all 19 documented Meta
73
+ - **Inbound webhooks, fully typed.** An object tree for all 21 documented Meta
73
74
  notification fields, plus HMAC signature verification and a Rails controller
74
75
  generator.
76
+ - **Usernames and BSUIDs, both directions.** Once a WhatsApp user adopts a username,
77
+ their phone number can vanish from your payloads. Inbound notifications still identify
78
+ their sender through a Business-Scoped User ID, and `recipient:` sends straight back to
79
+ one — so a BSUID read off a webhook can be replied to without ever knowing the number.
80
+ Your own business username is managed too, along with Meta's contact book.
75
81
  - **Persistent HTTP connections** via `HTTP.persistent`, reused across requests, with
76
82
  pluggable logging.
77
83
  - **Four runtime dependencies**, no Rails requirement. Rails integration activates
@@ -120,13 +126,16 @@ end
120
126
  phone_id ──► Messages · Media · BusinessPhoneNumber
121
127
  permission: whatsapp_business_messaging
122
128
 
123
- waba_id ──► MessageTemplates · SubscribedApp · BusinessPhoneNumber::Account
129
+ waba_id ──► MessageTemplates · SubscribedApp · Schedule · BusinessPhoneNumber::Account
124
130
  permission: whatsapp_business_management
125
131
  ```
126
132
 
127
133
  `BusinessPhoneNumber` appears on both sides: its onboarding actions address a phone
128
134
  number, its `Account` actions the account that number belongs to.
129
135
 
136
+ `Schedule`'s permission is an assumption: Meta's Schedules reference names none, so it
137
+ is grouped under `whatsapp_business_management` only because it addresses the WABA.
138
+
130
139
  `api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect`
131
140
  and `Client#inspect`, so they will not leak into logs or error reports.
132
141
 
@@ -155,9 +164,10 @@ response.contacts.first.wa_id # => "15551234567"
155
164
  | [**Configuration**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md) | Credentials, the client, connection reuse, instrumentation |
156
165
  | [**Messages**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md) | Every message kind, one page each, with exact payloads |
157
166
  | [**Message Templates**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md) | Creating and managing templates: standard, auth, carousel, offers, library |
158
- | [**Webhooks**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md) | Install, verification, signatures, and all 19 notification fields |
167
+ | [**Webhooks**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md) | Install, verification, signatures, and all 21 notification fields |
159
168
  | [**Media**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md) | Upload, download, delete, and the token-safety allowlist |
160
169
  | [**Subscribed Apps**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md) | Turning webhook delivery on and off for an account |
170
+ | [**Campaign Schedules**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/schedule/README.md) | Sending an approved template to an audience at a set time |
161
171
  | [**Business Phone Numbers**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md) | Onboarding: request code → verify → register |
162
172
  | [**Business Account**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/account.md) | Reading and updating the account: name, timezone, review status |
163
173
  | [**Business Profile**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/profile.md) | The profile a user sees: about, description, address, vertical, picture |
@@ -167,7 +177,13 @@ Or start at the [documentation index](https://github.com/saleszera/ruby-whatsapp
167
177
 
168
178
  ## 💬 Sending Messages
169
179
 
170
- Every registered kind gets its own `Whatsapp::Messages.send_<kind>!` class method:
180
+ Every registered kind gets its own `Whatsapp::Messages.send_<kind>!` class method.
181
+ Address a recipient by phone number, by BSUID, or both — at least one is required:
182
+
183
+ ```ruby
184
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi") # phone number
185
+ Whatsapp::Messages.send_text!(recipient: "US.13491208655302741918", body: "Hi") # BSUID
186
+ ```
171
187
 
172
188
  ```ruby
173
189
  Whatsapp::Messages.send_interactive!(
@@ -245,7 +261,7 @@ plus list, find, update, and delete.
245
261
 
246
262
  ## 🔔 Webhooks
247
263
 
248
- Meta pushes inbound messages, delivery statuses, and ~18 other notification types to a
264
+ Meta pushes inbound messages, delivery statuses, and 20 other notification types to a
249
265
  callback URL you register. Inside a Rails app:
250
266
 
251
267
  ```bash
@@ -282,8 +298,9 @@ message.id # => "wamid.HBg..."
282
298
  message.body # => "Does it come in another color?"
283
299
  ```
284
300
 
285
- All 19 documented fields get a class. Only `messages` has a Meta-published schema; the
286
- other 18 are best-effort and flagged as such, per field.
301
+ All 21 documented fields get a class. `messages`, `user_id_update`, and
302
+ `business_username_updates`, `user_preferences`, and `history` have a Meta-published schema; the other 16 are best-effort
303
+ and flagged as such, per field.
287
304
 
288
305
  → **[Webhooks](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md)**
289
306
  · [inbound messages & statuses](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/messages.md)
@@ -321,6 +338,26 @@ Tech Providers routing several accounts to different callback URLs pass an
321
338
 
322
339
  → **[Subscribed apps](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md)**
323
340
 
341
+ ## 📅 Scheduling Campaigns
342
+
343
+ Send an approved template to an audience at a set time, and list what is scheduled:
344
+
345
+ ```ruby
346
+ Whatsapp::Schedule::Create.call(
347
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
348
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
349
+ delivery_time: Time.now.to_i + 3600
350
+ ).id # => "9876543210"
351
+
352
+ page = Whatsapp::Schedule::List.call(fields: Whatsapp::Schedule::List::Fields::ALL, limit: 50)
353
+ page.select(&:scheduled?).map(&:name) # => ["Black Friday"]
354
+ Whatsapp::Schedule::List.call(after: page.next_cursor) if page.next_cursor
355
+ ```
356
+
357
+ Meta's default fields leave out `id`, so ask for it with `fields:` when you need it.
358
+
359
+ → **[Campaign schedules](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/schedule/README.md)**
360
+
324
361
  ## 📞 Business Phone Numbers
325
362
 
326
363
  A phone number is unusable with Cloud API until it is **registered** — the prerequisite
@@ -372,9 +409,34 @@ Whatsapp::BusinessPhoneNumber::Profile::Update.call(
372
409
  Character limits, the 21-value vertical enum, and the two-website cap are all checked
373
410
  before the request goes out.
374
411
 
412
+ The **business username** — the searchable `@name` a user can find you by — is managed on
413
+ the same `phone_id`:
414
+
415
+ ```ruby
416
+ Whatsapp::BusinessPhoneNumber::Username::Suggest.call.to_a
417
+ # => ["jaspersmarket", "jaspers_market"] — names Meta reserved for you
418
+
419
+ Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: "jaspersmarket").reserved?
420
+ # => true, until Meta makes the feature generally available
421
+
422
+ Whatsapp::BusinessPhoneNumber::Username::Get.call.approved? # => false, for now
423
+ ```
424
+
425
+ Every one of Meta's format rules is checked locally first, so a name that was never going
426
+ to be accepted doesn't cost you a rate-limited attempt. And Meta's **contact book** — the
427
+ store that keeps putting a username adopter's phone number in your webhooks — can be
428
+ pruned:
429
+
430
+ ```ruby
431
+ result = Whatsapp::BusinessPhoneNumber::ContactBook::Delete.call(bsuid: "US.13491208655302741918")
432
+ result.deleted # => false means there was simply nothing stored, not a failure
433
+ ```
434
+
375
435
  → **[Business phone numbers](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md)**
376
436
  · [the business account](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/account.md)
377
437
  · [the business profile](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/profile.md)
438
+ · [business usernames](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/username.md)
439
+ · [the contact book](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/contact_book.md)
378
440
 
379
441
  ## 🚨 Errors
380
442
 
@@ -388,6 +450,7 @@ you can rescue narrowly:
388
450
  | `Whatsapp::Media::MediaError` | Anything in `Media` |
389
451
  | `Whatsapp::MessageTemplates::TemplateError` | Anything in `MessageTemplates` |
390
452
  | `Whatsapp::SubscribedApp::Error` | Anything in `SubscribedApp` |
453
+ | `Whatsapp::Schedule::Error` | Anything in `Schedule` |
391
454
  | `Whatsapp::BusinessPhoneNumber::Error` | Anything in `BusinessPhoneNumber`, including `Account` |
392
455
 
393
456
  Local validation failures raise `ActiveModel::ValidationError` instead — they happen at
data/docs/README.md CHANGED
@@ -17,6 +17,7 @@ Full reference for [ruby-whatsapp](https://github.com/saleszera/ruby-whatsapp).
17
17
  | [messages/](messages/README.md) | Sending every message kind, the shared envelope, responses |
18
18
  | [media/](media/README.md) | Upload, download, delete, and the token-safety allowlist |
19
19
  | [message_templates/](message_templates/README.md) | Creating and managing the templates you send |
20
+ | [schedule/](schedule/README.md) | Scheduling an approved template to go out to an audience later |
20
21
 
21
22
  ### Message kinds
22
23
 
@@ -49,7 +50,7 @@ Full reference for [ruby-whatsapp](https://github.com/saleszera/ruby-whatsapp).
49
50
 
50
51
  | Page | Covers |
51
52
  | --- | --- |
52
- | [webhooks/](webhooks/README.md) | Install, verification, signature, dispatch, all 19 notification fields |
53
+ | [webhooks/](webhooks/README.md) | Install, verification, signature, dispatch, all 21 notification fields |
53
54
  | [webhooks/messages.md](webhooks/messages.md) | Inbound messages and delivery statuses — the one you'll actually use |
54
55
 
55
56
  ## Account administration
@@ -60,6 +61,8 @@ Full reference for [ruby-whatsapp](https://github.com/saleszera/ruby-whatsapp).
60
61
  | [business_phone_number/](business_phone_number/README.md) | Onboarding a phone number: request code → verify → register |
61
62
  | [business_phone_number/account.md](business_phone_number/account.md) | Reading and updating the business account itself |
62
63
  | [business_phone_number/profile.md](business_phone_number/profile.md) | Reading and updating the business profile a user sees |
64
+ | [business_phone_number/username.md](business_phone_number/username.md) | Claiming and managing the searchable business username |
65
+ | [business_phone_number/contact_book.md](business_phone_number/contact_book.md) | Removing a stored user contact from Meta's contact book |
63
66
 
64
67
  ## Reading order
65
68
 
@@ -27,6 +27,11 @@ Addresses your **phone number** (`phone_id`, not `waba_id`), like
27
27
  > and picture. Those address your phone number (`phone_id`), just like the four actions
28
28
  > below.
29
29
 
30
+ > **Looking for the username?** [`Username::Get` / `Claim` / `Delete` / `Suggest`](username.md)
31
+ > read and set the searchable `@name` a WhatsApp user can find the number by, and
32
+ > [`ContactBook::Delete`](contact_book.md) forgets a stored user contact. Both address
33
+ > `phone_id`, like the four actions below.
34
+
30
35
  > **Unrelated to [`SubscribedApp`](../subscribed_app/README.md).** That turns *webhook
31
36
  > delivery* on and off for a whole WhatsApp Business Account. This is per phone number
32
37
  > and has nothing to do with notifications.
@@ -55,6 +60,11 @@ Whatsapp::BusinessPhoneNumber::Deregister.call
55
60
  | [`Account::Update.call(name:, timezone_id:)`](account.md) | `POST /{waba_id}` | `Response` |
56
61
  | [`Profile::Get.call(fields:)`](profile.md) | `GET /{phone_id}/whatsapp_business_profile` | `Profile::Details` |
57
62
  | [`Profile::Update.call(**fields)`](profile.md) | `POST /{phone_id}/whatsapp_business_profile` | `Response` |
63
+ | [`Username::Get.call`](username.md) | `GET /{phone_id}/username` | `Username::Details` |
64
+ | [`Username::Claim.call(username:, transfer_action:)`](username.md) | `POST /{phone_id}/username` | `Username::Details` |
65
+ | [`Username::Delete.call`](username.md) | `DELETE /{phone_id}/username` | `Response` |
66
+ | [`Username::Suggest.call`](username.md) | `GET /{phone_id}/username_suggestions` | `Username::Suggestions` |
67
+ | [`ContactBook::Delete.call(bsuid:)`](contact_book.md) | `DELETE /{phone_id}/contact_book` | `ContactBook::Deletion` |
58
68
 
59
69
  Every action accepts an optional `client:` and raises
60
70
  `Whatsapp::BusinessPhoneNumber::Error` if `phone_id` is missing or the API rejects the
@@ -221,7 +231,8 @@ Local validation failures raise `ActiveModel::ValidationError` before any reques
221
231
  ---
222
232
 
223
233
  **See also:** [Reading and updating the business account](account.md) ·
224
- [Reading and updating the business profile](profile.md)
234
+ [Reading and updating the business profile](profile.md) ·
235
+ [Business usernames](username.md) · [Contact book](contact_book.md)
225
236
 
226
237
  ---
227
238
 
@@ -0,0 +1,90 @@
1
+ # Contact book
2
+
3
+ Meta keeps a **contact book** for your business portfolio: the phone numbers and BSUIDs
4
+ of users you have interacted with. Its purpose is thread continuity — once a user adopts
5
+ a username, Meta will keep putting their phone number in your webhooks if they are in
6
+ your contact book, instead of leaving you with only their
7
+ [BSUID](../webhooks/messages.md#business-scoped-user-ids-bsuid).
8
+
9
+ The store itself is hosted by Meta and needs no integration work; entries appear on their
10
+ own as you exchange messages or calls, and when a user taps a `REQUEST_CONTACT_INFO`
11
+ button (which arrives as a [`contacts` message](../webhooks/messages.md#contacts) with
12
+ `origin == "contact_request"`). This page covers the one operation Meta exposes on it:
13
+ removing an entry.
14
+
15
+ | Method | Request | Returns |
16
+ | --- | --- | --- |
17
+ | `ContactBook::Delete.call(bsuid:)` | `DELETE /{phone_id}/contact_book` | `ContactBook::Deletion` |
18
+
19
+ ## Deleting an entry
20
+
21
+ ```ruby
22
+ result = Whatsapp::BusinessPhoneNumber::ContactBook::Delete.call(bsuid: "US.13491208655302741918")
23
+
24
+ result.success # => true — the request was processed
25
+ result.deleted # => true — an entry existed and is gone
26
+ ```
27
+
28
+ The two booleans are independent, which is why this returns its own class rather than the
29
+ shared `Response`:
30
+
31
+ | `success` | `deleted` | Meaning |
32
+ | --- | --- | --- |
33
+ | `true` | `true` | An entry existed and was removed |
34
+ | `true` | `false` | The request was fine; there was nothing stored for that BSUID |
35
+ | `false` | — | The request was not processed |
36
+
37
+ So `deleted == false` is **not** a failure. Check `success` for that, or rescue
38
+ `Whatsapp::BusinessPhoneNumber::Error`.
39
+
40
+ ## What it affects
41
+
42
+ > **The edge is on one phone number; the effect is portfolio-wide.** Contact books are
43
+ > scoped to the business portfolio, so deleting an entry removes that user's phone number
44
+ > from webhooks for **every** business phone number in the portfolio — not just the one
45
+ > you addressed.
46
+
47
+ And the consequence to expect: after a deletion, `wa_id`/`from` may start being omitted
48
+ from your webhooks for that user, leaving their BSUID as the only identifier. That is the
49
+ feature working, not a bug — but code that keys off `wa_id` will notice.
50
+
51
+ The phone number can come back, without a new deletion, if the user messages or calls you
52
+ again (or you them) within the 30-day window Meta caches, since that recreates the entry.
53
+
54
+ ## Rules
55
+
56
+ - **Standard BSUIDs only.** Parent BSUIDs are not supported. Since this release hands you
57
+ `user_id` and `parent_user_id` side by side on half a dozen webhook classes, the mix-up
58
+ is easy, so it is caught locally:
59
+
60
+ ```ruby
61
+ Whatsapp::BusinessPhoneNumber::ContactBook::Delete.call(bsuid: "US.ENT.11815799212886844830")
62
+ # => ActiveModel::ValidationError: Bsuid looks like a parent BSUID;
63
+ # the contact book accepts standard BSUIDs only
64
+ ```
65
+
66
+ Detected by segment count rather than by matching `ENT`, which would bet that `ENT` is
67
+ the only marker Meta will ever use.
68
+
69
+ - **Same portfolio.** The BSUID must belong to the same portfolio as the phone number.
70
+ That is account state the gem cannot see, so it is left to the API — a mismatch comes
71
+ back as `Whatsapp::BusinessPhoneNumber::Error`.
72
+
73
+ ## Disabling the feature entirely
74
+
75
+ The contact book can be turned off in **Meta Business Suite → Business settings →
76
+ Business info**. Disabling it stops all storage and deletes everything already stored;
77
+ re-enabling starts fresh, with no way to restore the old contents. There is no API for
78
+ this, so it is not wrapped.
79
+
80
+ ## Not wrapped
81
+
82
+ Meta documents only the delete on this edge. A future read or write belongs in this same
83
+ namespace.
84
+
85
+ ---
86
+
87
+ **See also:** [Business usernames](username.md) ·
88
+ [BSUIDs in webhooks](../webhooks/messages.md#business-scoped-user-ids-bsuid)
89
+
90
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
@@ -0,0 +1,157 @@
1
+ # Business usernames
2
+
3
+ A business username is the searchable `@name` a WhatsApp user can find you by. It is the
4
+ business half of Meta's usernames rollout — the user half is what makes
5
+ [`recipient:`](../messages/README.md#addressing-to-recipient-or-both) necessary when
6
+ sending.
7
+
8
+ Two things it is **not**: it does not hide your business phone number in the app (unlike
9
+ a *user* username, which hides theirs), and it does not replace your display name. When
10
+ you have both, the display name shows first.
11
+
12
+ ```ruby
13
+ Whatsapp::BusinessPhoneNumber::Username::Get.call
14
+ Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: "jaspersmarket")
15
+ Whatsapp::BusinessPhoneNumber::Username::Suggest.call
16
+ Whatsapp::BusinessPhoneNumber::Username::Delete.call
17
+ ```
18
+
19
+ | Method | Request | Returns |
20
+ | --- | --- | --- |
21
+ | `Get.call` | `GET /{phone_id}/username` | `Username::Details` |
22
+ | `Claim.call(username:, transfer_action:)` | `POST /{phone_id}/username` | `Username::Details` |
23
+ | `Delete.call` | `DELETE /{phone_id}/username` | `Response` |
24
+ | `Suggest.call` | `GET /{phone_id}/username_suggestions` | `Username::Suggestions` |
25
+
26
+ ## Reading the current username
27
+
28
+ ```ruby
29
+ details = Whatsapp::BusinessPhoneNumber::Username::Get.call
30
+
31
+ details.username # => "jaspersmarket", or nil if the number has none
32
+ details.status # => "approved" | "reserved"
33
+ details.approved? # => true once it is visible to WhatsApp users
34
+ details.reserved? # => held for you, but not visible yet
35
+ ```
36
+
37
+ `status` stays a raw String and is never validated on the way in, so a value Meta adds
38
+ later still round-trips. Compare against `Details::Statuses::ALL` (`%w[approved reserved]`).
39
+
40
+ ## The lifecycle
41
+
42
+ ```
43
+ Suggest ──▶ Claim ──▶ reserved ──▶ approved
44
+ (what Meta (ask for (Meta makes the feature
45
+ reserved one) generally available)
46
+ for you)
47
+ ```
48
+
49
+ `reserved` means the name is yours but not yet shown to users. It becomes `approved` on
50
+ its own — you do not claim it twice. Either poll `Get`, or subscribe to the
51
+ [`business_username_updates`](../webhooks/business_username_updates.md) webhook, which
52
+ is the asynchronous counterpart to everything on this page.
53
+
54
+ ## Claiming one
55
+
56
+ ```ruby
57
+ details = Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: "jaspersmarket")
58
+ details.status # => "reserved"
59
+ ```
60
+
61
+ `Claim` answers with a status only — `details.username` is `nil`, because you already
62
+ hold the name you passed in.
63
+
64
+ Starting with names Meta reserved for you gives a higher chance of approval:
65
+
66
+ ```ruby
67
+ suggestions = Whatsapp::BusinessPhoneNumber::Username::Suggest.call
68
+
69
+ suggestions.to_a # => ["jaspersmarket", "jaspers_market"]
70
+ suggestions.first # => "jaspersmarket" — it's Enumerable
71
+
72
+ Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: suggestions.first)
73
+ ```
74
+
75
+ ### Format rules
76
+
77
+ Checked locally, before any request — the edge is rate limited, so a name that was never
78
+ going to be accepted should not cost you an attempt:
79
+
80
+ | Rule | Rejected example |
81
+ | --- | --- |
82
+ | Only `a-z`, `0-9`, `.` and `_` | `jaspers-market`, `jasperś` |
83
+ | 3–35 characters | `ab`, `a` × 36 |
84
+ | At least one letter | `12345` |
85
+ | Must not start or end with `.` | `.jaspers`, `jaspers.` |
86
+ | No two consecutive `.` | `jaspers..market` |
87
+ | Must not start with `www` | `www.jaspers` |
88
+ | Must not end with a domain suffix | `jaspers.com`, `jaspers.html` |
89
+
90
+ Each failure raises `ActiveModel::ValidationError` naming the rule.
91
+
92
+ Three things worth knowing about how these are applied:
93
+
94
+ - **Mixed case is downcased, not rejected.** Meta compares usernames case-insensitively,
95
+ so `"JaspersMarket"` is sent as `"jaspersmarket"`. Refusing a name Meta would have
96
+ accepted is the failure mode this gem avoids.
97
+ - **`.` and `_` are significant.** `myid`, `my.id`, and `my_id` are three different names.
98
+ - **`www` is read literally**, so `wwwidgets` is rejected too. Meta writes the rule as a
99
+ bare prefix; a reversible local rejection beats a wasted request.
100
+
101
+ > The domain-suffix list is **`.com .org .net .int .edu .gov .mil .us .in .html`** —
102
+ > and Meta's own list ends in "and so on", so it cannot be complete. A suffix outside it
103
+ > reaches the API and comes back as error `100`.
104
+
105
+ ### Moving a username between your own numbers
106
+
107
+ If another phone number *in the same portfolio* holds the name, `Claim` fails with
108
+ `147005`. Resend with a forced transfer:
109
+
110
+ ```ruby
111
+ Whatsapp::BusinessPhoneNumber::Username::Claim.call(
112
+ username: "jaspersmarket",
113
+ transfer_action: :force_transfer
114
+ )
115
+ ```
116
+
117
+ > **This takes the name off the other number.** There is deliberately no automatic
118
+ > retry — a destructive step stays an explicit one.
119
+
120
+ ## Deleting one
121
+
122
+ ```ruby
123
+ Whatsapp::BusinessPhoneNumber::Username::Delete.call.success # => true
124
+ ```
125
+
126
+ Note this reports `success`, not a status: a delete arrives back through the
127
+ [webhook](../webhooks/business_username_updates.md) as `status: "deleted"`, which is the
128
+ only place that value ever appears — `Details::Statuses::ALL` deliberately omits it.
129
+
130
+ ## Errors
131
+
132
+ | Code | Meaning and remedy |
133
+ | --- | --- |
134
+ | `10` | The token's system user needs Full control, or Partial access for Phone numbers, on the WABA |
135
+ | `33` | Invalid phone number ID, deleted WABA, or the app lacks `whatsapp_business_management` |
136
+ | `100` | The username format is invalid — including a domain suffix outside the list above |
137
+ | `147001` | Already claimed, or failed Meta's internal checks. Try another |
138
+ | `147002` | The portfolio's [messaging limit](https://developers.facebook.com/documentation/business-messaging/whatsapp/messaging-limits) is too low |
139
+ | `147003` | Link the number to the Facebook Page already using this name |
140
+ | `147004` | Link the number to the Instagram account already using this name |
141
+ | `147005` | Another of your numbers holds it — resend with `transfer_action: "force_transfer"` |
142
+ | `133010` | The number must be [registered](README.md) for API use first |
143
+
144
+ All arrive as `Whatsapp::BusinessPhoneNumber::Error` with Meta's message attached.
145
+
146
+ ## Targeting a different number
147
+
148
+ ```ruby
149
+ Whatsapp::BusinessPhoneNumber::Username::Get.call(client: Whatsapp::Client.new(phone_id: "OTHER_ID"))
150
+ ```
151
+
152
+ ---
153
+
154
+ **See also:** [`business_username_updates` webhook](../webhooks/business_username_updates.md) ·
155
+ [Sending to a BSUID](../messages/README.md#business-scoped-user-ids-bsuid)
156
+
157
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids