ruby-whatsapp 0.4.0 → 0.6.1

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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
@@ -0,0 +1,59 @@
1
+ # Image Messages
2
+
3
+ Shares a photo or graphic — a product shot, a receipt, a screenshot.
4
+
5
+ ```ruby
6
+ Whatsapp::Messages.send_image!(
7
+ to: "+15551234567",
8
+ link: "https://example.com/photo.jpg",
9
+ caption: "Our new product"
10
+ )
11
+ ```
12
+
13
+ ## Fields
14
+
15
+ | Field | Required | Rules |
16
+ | --- | --- | --- |
17
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
18
+ | `id` | one of | A media ID from [`Media#upload`](../media/README.md) |
19
+ | `link` | one of | A publicly reachable HTTPS URL |
20
+ | `caption` | no | Max **1024** characters |
21
+
22
+ **Either `id` or `link` is required** — never both are mandatory, but at least one
23
+ must be present. Prefer `id` when you'll send the same asset more than once: Meta
24
+ caches an uploaded asset, whereas a `link` is re-fetched on every send.
25
+
26
+ ```ruby
27
+ media = Whatsapp::Media.new
28
+ media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
29
+
30
+ Whatsapp::Messages.send_image!(to: "+15551234567", id: media_id, caption: "Our new product")
31
+ ```
32
+
33
+ ## Serialized payload
34
+
35
+ ```ruby
36
+ Whatsapp::Messages::Image.new(
37
+ to: "+15551234567", link: "https://example.com/photo.jpg", caption: "Our new product"
38
+ ).serialize
39
+ # => {
40
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "image",
41
+ # image: { link: "https://example.com/photo.jpg", caption: "Our new product" }
42
+ # }
43
+ ```
44
+
45
+ The `image` hash is compacted, so absent optional keys never reach the wire.
46
+
47
+ ## Validation errors
48
+
49
+ ```ruby
50
+ Whatsapp::Messages.send_image!(to: "+15551234567")
51
+ # => ActiveModel::ValidationError: Either id or link must be present
52
+
53
+ Whatsapp::Messages.send_image!(to: "+15551234567", link: "https://example.com/p.jpg", caption: "x" * 1025)
54
+ # => ActiveModel::ValidationError: Caption is too long (maximum is 1024 characters)
55
+ ```
56
+
57
+ ---
58
+
59
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/image-messages>
@@ -0,0 +1,342 @@
1
+ # Interactive Messages
2
+
3
+ Gives the user tappable UI — buttons, a list, a carousel — instead of asking them to
4
+ type a free-text reply. Tapping "Confirm" is one action; typing "yes", "Yes", "yeah",
5
+ or "ya" is four things you have to parse.
6
+
7
+ Unlike every other kind, `Interactive` is a **single class** that dispatches on
8
+ `type:` through a frozen registry:
9
+
10
+ | `type:` | Wire `interactive.type` | Shape |
11
+ | --- | --- | --- |
12
+ | `:reply_buttons` | `"button"` | [Up to 3 quick-reply buttons](#reply-buttons) |
13
+ | `:list_buttons` | `"list"` | [A button expanding into sections of rows](#list) |
14
+ | `:url_button` | `"cta_url"` | [A link rendered as a button](#cta-url-button) |
15
+ | `:media_carousel` | `"carousel"` | [2–10 swipeable media cards](#media-carousel) |
16
+ | `:product_carousel` | `"product_list"` | [2–10 catalog product cards](#product-carousel) |
17
+ | `:request_contact_info` | `"request_contact_info"` | [A one-tap "share my number" button](#request-contact-info) |
18
+
19
+ ## Shared fields
20
+
21
+ | Field | Required | Rules |
22
+ | --- | --- | --- |
23
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
24
+ | `type` | yes | One of the six keys above (Symbol or String) |
25
+ | `body` | yes | Body text, max **1024** characters |
26
+ | `action` | usually | Shape depends on `type` — see each section. Omit it only for `:request_contact_info`, the one kind with nothing to supply |
27
+ | `header` | no | `{ type: "text"\|"image"\|"video"\|"document", ... }` |
28
+ | `footer` | no | `{ text: "..." }`, max **60** characters |
29
+
30
+ ### Header
31
+
32
+ A text header is capped at **60** characters. Media headers take a **`link:` only** —
33
+ there is no media-`id` path on interactive headers, unlike [image](image.md) and
34
+ friends:
35
+
36
+ ```ruby
37
+ header: { type: "text", text: "Order #1234" }
38
+ header: { type: "image", link: "https://example.com/photo.jpg" }
39
+ header: { type: "video", link: "https://example.com/clip.mp4" }
40
+ header: { type: "document", link: "https://example.com/invoice.pdf" }
41
+ ```
42
+
43
+ A header missing its content raises `Whatsapp::Messages::Interactive::Header::HeaderError`
44
+ (not a validation error):
45
+
46
+ ```ruby
47
+ Whatsapp::Messages.send_interactive!(
48
+ to: "+15551234567", type: :reply_buttons, body: "Pick one",
49
+ header: { type: "image" },
50
+ action: { buttons: [{ id: "a", title: "A" }] }
51
+ )
52
+ # => Whatsapp::Messages::Interactive::Header::HeaderError: Image link is required for image header
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Reply buttons
58
+
59
+ Up to **3** quick-reply buttons. The user's tap comes back as a webhook
60
+ `Message::Interactive` with `interactive_type == "button_reply"`.
61
+
62
+ ```ruby
63
+ Whatsapp::Messages.send_interactive!(
64
+ to: "+15551234567",
65
+ type: :reply_buttons,
66
+ body: "Would you like to confirm your order?",
67
+ action: { buttons: [{ id: "confirm", title: "Confirm" },
68
+ { id: "cancel", title: "Cancel" }] }
69
+ )
70
+ ```
71
+
72
+ | Field | Rules |
73
+ | --- | --- |
74
+ | `buttons` | 1–**3** entries |
75
+ | `buttons[].id` | Required, max **256** characters — echoed back on tap |
76
+ | `buttons[].title` | Required, max **20** characters — the visible label |
77
+
78
+ ```ruby
79
+ Whatsapp::Messages::Interactive.new(
80
+ to: "+15551234567", type: :reply_buttons, body: "Would you like to confirm your order?",
81
+ action: { buttons: [{ id: "confirm", title: "Confirm" }, { id: "cancel", title: "Cancel" }] }
82
+ ).serialize
83
+ # => { messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567",
84
+ # type: "interactive",
85
+ # interactive: { type: "button",
86
+ # body: { text: "Would you like to confirm your order?" },
87
+ # action: { buttons: [{ type: "reply", reply: { id: "confirm", title: "Confirm" } },
88
+ # { type: "reply", reply: { id: "cancel", title: "Cancel" } }] } } }
89
+ ```
90
+
91
+ ---
92
+
93
+ ## List
94
+
95
+ One button that expands into up to **10 sections** of up to **10 rows** each. Use it
96
+ when three buttons aren't enough — a menu, a time-slot picker, a product list.
97
+
98
+ ```ruby
99
+ Whatsapp::Messages.send_interactive!(
100
+ to: "+15551234567",
101
+ type: :list_buttons,
102
+ body: "Choose a drink",
103
+ action: {
104
+ button: "Menu",
105
+ sections: [
106
+ { title: "Coffee", rows: [
107
+ { id: "espresso", title: "Espresso", description: "Strong & short" },
108
+ { id: "latte", title: "Latte", description: "Milky & mild" },
109
+ ] },
110
+ ],
111
+ }
112
+ )
113
+ ```
114
+
115
+ | Field | Rules |
116
+ | --- | --- |
117
+ | `button` | Required, max **20** characters — the label that opens the list |
118
+ | `sections` | **1–10** sections |
119
+ | `sections[].title` | Required, max **24** characters |
120
+ | `sections[].rows` | **1–10** rows |
121
+ | `rows[].id` | Required, max **200** characters |
122
+ | `rows[].title` | Required, max **24** characters |
123
+ | `rows[].description` | Optional, max **72** characters |
124
+
125
+ ```ruby
126
+ Whatsapp::Messages::Interactive.new(
127
+ to: "+15551234567", type: :list_buttons, body: "Choose a drink",
128
+ action: { button: "Menu", sections: [
129
+ { title: "Coffee", rows: [{ id: "espresso", title: "Espresso", description: "Strong & short" }] },
130
+ ] }
131
+ ).serialize
132
+ # => { ..., interactive: { type: "list", body: { text: "Choose a drink" },
133
+ # action: { button: "Menu",
134
+ # sections: [{ title: "Coffee",
135
+ # rows: [{ id: "espresso", title: "Espresso",
136
+ # description: "Strong & short" }] }] } } }
137
+ ```
138
+
139
+ A tap arrives back as a webhook `Message::Interactive` with
140
+ `interactive_type == "list_reply"`.
141
+
142
+ ---
143
+
144
+ ## CTA URL button
145
+
146
+ Surfaces a link as a tappable button instead of raw text in the body — clearer, and it
147
+ sidesteps the link preview.
148
+
149
+ ```ruby
150
+ Whatsapp::Messages.send_interactive!(
151
+ to: "+15551234567",
152
+ type: :url_button,
153
+ body: "Check out our new arrivals",
154
+ action: { name: "cta_url", display_text: "Shop now", url: "https://example.com/new" }
155
+ )
156
+ ```
157
+
158
+ | Field | Rules |
159
+ | --- | --- |
160
+ | `name` | Required, max **20** characters — always `"cta_url"` |
161
+ | `display_text` | Required, max **20** characters — the button label |
162
+ | `url` | The destination. **Not validated**, not even for presence |
163
+
164
+ ```ruby
165
+ Whatsapp::Messages::Interactive.new(
166
+ to: "+15551234567", type: :url_button, body: "Check out our new arrivals",
167
+ action: { name: "cta_url", display_text: "Shop now", url: "https://example.com/new" }
168
+ ).serialize
169
+ # => { ..., interactive: { type: "cta_url", body: { text: "Check out our new arrivals" },
170
+ # action: { name: "cta_url",
171
+ # parameters: { display_text: "Shop now", url: "https://example.com/new" } } } }
172
+ ```
173
+
174
+ ---
175
+
176
+ ## Media carousel
177
+
178
+ **2–10** swipeable cards, each with its own header, body, CTA URL, and quick-reply
179
+ buttons. `card_index` is assigned automatically from array position — do not pass it.
180
+
181
+ ```ruby
182
+ Whatsapp::Messages.send_interactive!(
183
+ to: "+15551234567",
184
+ type: :media_carousel,
185
+ body: "Today's picks",
186
+ action: {
187
+ cards: [
188
+ {
189
+ header: { type: "image", link: "https://example.com/1.jpg" },
190
+ body: "Item one",
191
+ action: { name: "cta_url", display_text: "Buy", url: "https://example.com/1" },
192
+ buttons: [{ quick_reply: { id: "q1", title: "Details" } }],
193
+ },
194
+ {
195
+ header: { type: "image", link: "https://example.com/2.jpg" },
196
+ body: "Item two",
197
+ action: { name: "cta_url", display_text: "Buy", url: "https://example.com/2" },
198
+ buttons: [{ quick_reply: { id: "q2", title: "Details" } }],
199
+ },
200
+ ],
201
+ }
202
+ )
203
+ ```
204
+
205
+ | Field | Rules |
206
+ | --- | --- |
207
+ | `cards` | **2–10** cards |
208
+ | `cards[].header` | Required — same shape as the message header (`link:` only for media) |
209
+ | `cards[].body` | Required |
210
+ | `cards[].action` | Required — a CTA URL action |
211
+ | `cards[].buttons` | Required — array of `{ quick_reply: { id:, title: } }` |
212
+
213
+ All five card keys are required Ruby keywords; omitting one raises `ArgumentError`,
214
+ not a validation error.
215
+
216
+ > **Two caveats.** The wire value `"carousel"` is flagged in the source as worth
217
+ > re-checking against Meta's docs for your API version before relying on it in
218
+ > production. And `MediaCarousel::Card::Button::QuickReply` declares `id`/`title`
219
+ > length validations but never runs `validate!`, so an over-long or blank
220
+ > `id`/`title` serializes silently and is rejected by Meta instead of locally.
221
+
222
+ ---
223
+
224
+ ## Product carousel
225
+
226
+ **2–10** cards referencing products in your Meta catalog. Like the media carousel,
227
+ `card_index` is assigned from array position.
228
+
229
+ ```ruby
230
+ Whatsapp::Messages.send_interactive!(
231
+ to: "+15551234567",
232
+ type: :product_carousel,
233
+ body: "Recommended for you",
234
+ action: {
235
+ cards: [
236
+ { catalog_id: "123456789", product_retailer_id: "SKU-1" },
237
+ { catalog_id: "123456789", product_retailer_id: "SKU-2" },
238
+ ],
239
+ }
240
+ )
241
+ ```
242
+
243
+ | Field | Rules |
244
+ | --- | --- |
245
+ | `cards` | **2–10** cards |
246
+ | `cards[].catalog_id` | Required (Ruby keyword) |
247
+ | `cards[].product_retailer_id` | Required (Ruby keyword) |
248
+
249
+ Card contents carry **no validations at all** — a wrong catalog or SKU surfaces
250
+ server-side.
251
+
252
+ > The wire value `"product_list"` carries the same "verify against your API version"
253
+ > caveat as the media carousel.
254
+
255
+ ---
256
+
257
+ ## Request contact info
258
+
259
+ Asks the recipient to share their phone number, which they hand over with one tap. The
260
+ one action kind that takes no `action:` — there is nothing to configure:
261
+
262
+ ```ruby
263
+ Whatsapp::Messages.send_interactive!(
264
+ recipient: "US.13491208655302741918",
265
+ type: :request_contact_info,
266
+ body: "Mind sharing your number so we can text you the confirmation?"
267
+ )
268
+ ```
269
+
270
+ ```json
271
+ {
272
+ "messaging_product": "whatsapp",
273
+ "recipient_type": "individual",
274
+ "recipient": "US.13491208655302741918",
275
+ "type": "interactive",
276
+ "interactive": {
277
+ "type": "request_contact_info",
278
+ "body": { "text": "Mind sharing your number so we can text you the confirmation?" },
279
+ "action": { "name": "request_contact_info" }
280
+ }
281
+ }
282
+ ```
283
+
284
+ Note `interactive.type` and `action.name` are the same literal — this is the only kind
285
+ where the registry key and the wire value coincide.
286
+
287
+ Use this inside the 24-hour service window; to *open* a conversation with the same ask,
288
+ use the [`REQUEST_CONTACT_INFO` template button](../message_templates/components.md#the-request-contact-info-button)
289
+ instead.
290
+
291
+ Why you would: once a WhatsApp user adopts a username, their phone number can stop
292
+ appearing in your webhooks, leaving only their
293
+ [BSUID](README.md#business-scoped-user-ids-bsuid) — which is enough to keep messaging
294
+ them, but not enough for a one-tap, zero-tap, or copy-code
295
+ [authentication template](../message_templates/authentication.md). This is how you ask
296
+ for the number back.
297
+
298
+ A tap arrives as an inbound [`contacts` message](../webhooks/messages.md#contacts) whose
299
+ `origin` is `"contact_request"`:
300
+
301
+ ```ruby
302
+ when "messages"
303
+ change.value.messages.each do |message|
304
+ next unless message.type == "contacts"
305
+
306
+ shared = message.contacts.first
307
+ next unless shared.origin == "contact_request"
308
+
309
+ Customer.find_by(bsuid: message.from_user_id)
310
+ &.update!(wa_id: shared.phones.first&.wa_id)
311
+ end
312
+ ```
313
+
314
+ Meta's published example shows no `header` or `footer`; both are accepted here and
315
+ omitted from the payload when absent, as with every other kind.
316
+
317
+ ## Validation errors
318
+
319
+ `Interactive` itself validates `type`, `body`, and `action` presence at construction.
320
+ The **nested** action rules — button counts, title lengths, section limits — run when
321
+ the payload is serialized, which for `send_interactive!` is still before any HTTP
322
+ request:
323
+
324
+ ```ruby
325
+ Whatsapp::Messages.send_interactive!(
326
+ to: "+15551234567", type: :telepathy, body: "Pick",
327
+ action: { buttons: [{ id: "a", title: "A" }] }
328
+ )
329
+ # => ActiveModel::ValidationError: Type is not included in the list
330
+
331
+ Whatsapp::Messages.send_interactive!(
332
+ to: "+15551234567", type: :reply_buttons, body: "Pick",
333
+ action: { buttons: [{ id: "a", title: "A really quite long button label" }] }
334
+ )
335
+ # => ActiveModel::ValidationError: Title is too long (maximum is 20 characters)
336
+ ```
337
+
338
+ ---
339
+
340
+ **Meta docs:**
341
+ [CTA URL](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-cta-url-messages)
342
+ · [lists](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/interactive-list-messages)
@@ -0,0 +1,51 @@
1
+ # Location Messages
2
+
3
+ Shares a fixed latitude/longitude pin — a shop, a pickup point, a venue. The inverse
4
+ direction (asking the *user* where they are) is
5
+ [Location Request](location_request.md).
6
+
7
+ ```ruby
8
+ Whatsapp::Messages.send_location!(
9
+ to: "+15551234567",
10
+ latitude: 37.4847,
11
+ longitude: -122.1477,
12
+ name: "Meta HQ"
13
+ )
14
+ ```
15
+
16
+ ## Fields
17
+
18
+ | Field | Required | Rules |
19
+ | --- | --- | --- |
20
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
21
+ | `latitude` | yes | Numeric |
22
+ | `longitude` | yes | Numeric |
23
+ | `name` | no | Label shown above the pin |
24
+ | `address` | no | Street address shown under the name |
25
+
26
+ Both coordinates are validated with `numericality`, so a string that doesn't parse as
27
+ a number is rejected locally. `name` and `address` are free text — WhatsApp renders
28
+ them as the pin's caption; without them the recipient sees bare coordinates.
29
+
30
+ ## Serialized payload
31
+
32
+ ```ruby
33
+ Whatsapp::Messages::Location.new(
34
+ to: "+15551234567", latitude: 37.4847, longitude: -122.1477, name: "Meta HQ"
35
+ ).serialize
36
+ # => {
37
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "location",
38
+ # location: { latitude: 37.4847, longitude: -122.1477, name: "Meta HQ" }
39
+ # }
40
+ ```
41
+
42
+ ## Validation errors
43
+
44
+ ```ruby
45
+ Whatsapp::Messages.send_location!(to: "+15551234567", latitude: "here", longitude: -122.1477)
46
+ # => ActiveModel::ValidationError: Latitude is not a number
47
+ ```
48
+
49
+ ---
50
+
51
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/location-messages>
@@ -0,0 +1,59 @@
1
+ # Location Request Messages
2
+
3
+ Asks the user to **share their current location back to the business** — the inverse
4
+ of [Location](location.md). Useful for coordinating a delivery or a pickup without
5
+ making the customer type an address.
6
+
7
+ ```ruby
8
+ Whatsapp::Messages.send_location_request!(
9
+ to: "+15551234567",
10
+ body: "Can you share your delivery location?"
11
+ )
12
+ ```
13
+
14
+ ## Fields
15
+
16
+ | Field | Required | Rules |
17
+ | --- | --- | --- |
18
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
19
+ | `body` | yes | The prompt text, max **1024** characters |
20
+
21
+ ## Serialized payload
22
+
23
+ Despite being its own Ruby class, this travels as an interactive message on the wire
24
+ (`type: "interactive"`, `interactive.type: "location_request_message"`):
25
+
26
+ ```ruby
27
+ Whatsapp::Messages::LocationRequest.new(
28
+ to: "+15551234567", body: "Can you share your delivery location?"
29
+ ).serialize
30
+ # => {
31
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567",
32
+ # type: "interactive",
33
+ # interactive: {
34
+ # type: "location_request_message",
35
+ # body: "Can you share your delivery location?",
36
+ # action: { name: "send_location" }
37
+ # }
38
+ # }
39
+ ```
40
+
41
+ > **Known bug — confirmed broken against the live API.** `body` is serialized as a
42
+ > bare String, but Meta's JSON schema requires `interactive.body` to be an object
43
+ > (`{ text: ... }`) or null. [`Address`](address.md) and
44
+ > [`Interactive`](interactive.md) both wrap body text correctly; this class does not,
45
+ > and live testing returns a schema rejection. Not yet fixed — track it before
46
+ > relying on this kind in production.
47
+
48
+ ## The reply
49
+
50
+ The user's shared location arrives as an inbound webhook notification, deserialized
51
+ into `Whatsapp::Webhook::Message::Location` with `latitude`, `longitude`, `name`, and
52
+ `address`. See [../webhooks/messages.md](../webhooks/messages.md).
53
+
54
+ ## Validation errors
55
+
56
+ ```ruby
57
+ Whatsapp::Messages.send_location_request!(to: "+15551234567", body: "")
58
+ # => ActiveModel::ValidationError: Body can't be blank
59
+ ```
@@ -0,0 +1,84 @@
1
+ # Mark Message As Read
2
+
3
+ Closes the read-receipt loop — this is what turns on the blue "seen" checkmarks on the
4
+ customer's side. Marking one message read also marks **every earlier message in that
5
+ conversation** as read.
6
+
7
+ ```ruby
8
+ Whatsapp::Messages.mark_message_as_read!(
9
+ message_id: "wamid.HBgLMTY1MDM4Nzk0MzkVAgARGBJDQjZCMzlEQUE4OTJBMTE4RTUA"
10
+ )
11
+ ```
12
+
13
+ ## Fields
14
+
15
+ | Field | Required | Rules |
16
+ | --- | --- | --- |
17
+ | `message_id` | yes | The WAMID of an inbound message |
18
+ | `client` | no | Defaults to a new `Whatsapp::Client` |
19
+
20
+ `message_id` is checked for presence only — there is no WAMID format check, so an
21
+ invalid ID surfaces server-side as error `131009`.
22
+
23
+ > **30-day window.** Meta requires the call within 30 days of receiving the message.
24
+ > Past that, the request fails.
25
+
26
+ ## Why it isn't a `send_<kind>!` method
27
+
28
+ Every other kind shares the `to` / `recipient_type` / `type` envelope from
29
+ `Messages::Base`. This endpoint's payload is flat — no recipient, no type — so
30
+ `MarkMessageAsRead` doesn't inherit `Base` at all and isn't registered in
31
+ `Messages::KINDS`. Rather than carve an exception into the loop that generates
32
+ `send_<kind>!` methods, it gets its own hand-written class method.
33
+
34
+ ## Serialized payload
35
+
36
+ ```ruby
37
+ Whatsapp::Messages::MarkMessageAsRead.new(message_id: "wamid.HBg...").serialize
38
+ # => { messaging_product: "whatsapp", status: "read", message_id: "wamid.HBg..." }
39
+ ```
40
+
41
+ ## The response
42
+
43
+ This endpoint replies `{"success": true}` rather than the usual
44
+ `{messaging_product, contacts, messages}`, so the shared `Messages::Response` carries
45
+ it on `#success`:
46
+
47
+ ```ruby
48
+ result = Whatsapp::Messages.mark_message_as_read!(message_id: "wamid.HBg...")
49
+
50
+ result.success # => true
51
+ result.messages # => [] — nothing to report for this endpoint
52
+ result.contacts # => []
53
+ ```
54
+
55
+ ## Errors
56
+
57
+ ```ruby
58
+ Whatsapp::Messages::MarkMessageAsRead.new(message_id: "")
59
+ # => ActiveModel::ValidationError: Message can't be blank
60
+ ```
61
+
62
+ A non-2xx response raises `Whatsapp::RequestError: Failed to mark message as read: ...`.
63
+
64
+ ## Typical use
65
+
66
+ Call it from your webhook handler as soon as an inbound message is deserialized:
67
+
68
+ ```ruby
69
+ notification.entry.each do |entry|
70
+ entry.changes.each do |change|
71
+ next unless change.field == "messages"
72
+
73
+ change.value.messages.each do |message|
74
+ Whatsapp::Messages.mark_message_as_read!(message_id: message.id)
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
80
+ See [../webhooks/messages.md](../webhooks/messages.md) for the inbound side.
81
+
82
+ ---
83
+
84
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/mark-message-as-read>
@@ -0,0 +1,59 @@
1
+ # Reaction Messages
2
+
3
+ Acknowledges a previous message with an emoji, mirroring WhatsApp's native
4
+ tap-and-hold reaction, without cluttering the thread with a new text message.
5
+
6
+ ```ruby
7
+ Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "👍")
8
+ ```
9
+
10
+ ## Fields
11
+
12
+ | Field | Required | Rules |
13
+ | --- | --- | --- |
14
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
15
+ | `message_id` | yes | The WAMID of the message being reacted to |
16
+ | `emoji` | yes | A single emoji, or `""` to remove |
17
+
18
+ `emoji` is checked against Ruby's `\p{Emoji}` property, so a plain word is rejected
19
+ locally rather than by Meta. `nil` is invalid; the empty string is not.
20
+
21
+ ## Removing a reaction
22
+
23
+ Sending an empty `emoji` removes a reaction you previously sent to that message:
24
+
25
+ ```ruby
26
+ Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "")
27
+ ```
28
+
29
+ ## Serialized payload
30
+
31
+ ```ruby
32
+ Whatsapp::Messages::Reaction.new(
33
+ to: "+15551234567", message_id: "wamid.HBg...", emoji: "\u{1F44D}"
34
+ ).serialize
35
+ # => {
36
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "reaction",
37
+ # reaction: { message_id: "wamid.HBg...", emoji: "👍" }
38
+ # }
39
+ ```
40
+
41
+ The `reaction` hash is not compacted — both keys always ship, which is what makes the
42
+ empty-string removal work.
43
+
44
+ ## Validation errors
45
+
46
+ ```ruby
47
+ Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "nope")
48
+ # => ActiveModel::ValidationError: Emoji must be a valid emoji character
49
+
50
+ Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: nil)
51
+ # => ActiveModel::ValidationError: Emoji can't be nil
52
+ ```
53
+
54
+ `message_id` is checked for presence only — an existent-looking but wrong WAMID
55
+ surfaces server-side as error `131009`.
56
+
57
+ ---
58
+
59
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/reaction-messages>
@@ -0,0 +1,40 @@
1
+ # Sticker Messages
2
+
3
+ Sends a WhatsApp sticker asset (a WebP image, animated or static).
4
+
5
+ ```ruby
6
+ Whatsapp::Messages.send_sticker!(to: "+15551234567", id: "STICKER_MEDIA_ID")
7
+ ```
8
+
9
+ ## Fields
10
+
11
+ | Field | Required | Rules |
12
+ | --- | --- | --- |
13
+ | `to` / `recipient` | at least one | Phone number and/or [BSUID](README.md#addressing-to-recipient-or-both) |
14
+ | `id` | one of | A media ID from [`Media#upload`](../media/README.md) |
15
+ | `link` | one of | A publicly reachable HTTPS URL |
16
+
17
+ **Either `id` or `link` is required** — prefer `id`. Stickers are almost always
18
+ reused across many conversations, so uploading once and holding the ID avoids
19
+ re-fetching the same asset on every send.
20
+
21
+ ## Serialized payload
22
+
23
+ ```ruby
24
+ Whatsapp::Messages::Sticker.new(to: "+15551234567", id: "STICKER_MEDIA_ID").serialize
25
+ # => {
26
+ # messaging_product: "whatsapp", recipient_type: "individual", to: "+15551234567", type: "sticker",
27
+ # sticker: { id: "STICKER_MEDIA_ID" }
28
+ # }
29
+ ```
30
+
31
+ ## Validation errors
32
+
33
+ ```ruby
34
+ Whatsapp::Messages.send_sticker!(to: "+15551234567")
35
+ # => ActiveModel::ValidationError: Either id or link must be present
36
+ ```
37
+
38
+ ---
39
+
40
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/sticker-messages>