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,49 @@
1
+ # `message_template_quality_update`
2
+
3
+ A template's quality score changed. Quality is computed from how recipients react —
4
+ blocks, reports, and "not useful" feedback push it down. A template that reaches `RED`
5
+ gets paused, then eventually disabled, so a `GREEN → YELLOW` transition is your early
6
+ warning.
7
+
8
+ `Whatsapp::Webhook::MessageTemplateQualityUpdate` · confidence: **moderate-high**
9
+
10
+ ## Payload
11
+
12
+ ```json
13
+ { "field": "message_template_quality_update",
14
+ "value": {
15
+ "message_template_id": "123",
16
+ "message_template_name": "order_confirmation",
17
+ "message_template_language": "en_US",
18
+ "previous_quality_score": "GREEN",
19
+ "new_quality_score": "YELLOW"
20
+ } }
21
+ ```
22
+
23
+ ## Accessors
24
+
25
+ | Accessor | Meaning |
26
+ | --- | --- |
27
+ | `message_template_id` | The template's ID |
28
+ | `message_template_name` | Its name |
29
+ | `message_template_language` | Its locale |
30
+ | `previous_quality_score` | `GREEN` \| `YELLOW` \| `RED` \| `UNKNOWN` |
31
+ | `new_quality_score` | Same set |
32
+
33
+ ## Handling it
34
+
35
+ ```ruby
36
+ when "message_template_quality_update"
37
+ update = change.value
38
+
39
+ if update.new_quality_score == "RED"
40
+ Ops.alert("Template #{update.message_template_name} dropped to RED — pause the campaign")
41
+ end
42
+ ```
43
+
44
+ The same score is readable on demand via
45
+ [`Response::Node#quality_score`](../message_templates/responses.md#qualityscore).
46
+
47
+ ---
48
+
49
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,55 @@
1
+ # `message_template_status_update`
2
+
3
+ The review verdict on a template you submitted. This is **the** notification to handle
4
+ if you create templates from code — Meta reviews asynchronously and can take up to 24
5
+ hours, so this is how you learn the outcome without polling.
6
+
7
+ `Whatsapp::Webhook::MessageTemplateStatusUpdate` · confidence: **high**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "message_template_status_update",
13
+ "value": {
14
+ "message_template_id": "123",
15
+ "message_template_name": "order_confirmation",
16
+ "message_template_language": "en_US",
17
+ "event": "REJECTED",
18
+ "reason": "INVALID_FORMAT"
19
+ } }
20
+ ```
21
+
22
+ ## Accessors
23
+
24
+ | Accessor | Meaning |
25
+ | --- | --- |
26
+ | `message_template_id` | The template's ID, as returned by `create` |
27
+ | `message_template_name` | Its name |
28
+ | `message_template_language` | Its locale — a template is one per `(name, language)` pair |
29
+ | `event` | `APPROVED` \| `REJECTED` \| `PAUSED` \| `PENDING_DELETION` \| … |
30
+ | `reason` | Why it was rejected: `NONE` `ABUSIVE_CONTENT` `INVALID_FORMAT` `PROMOTIONAL` `TAG_CONTENT_MISMATCH` `SCAM` |
31
+
32
+ ## Handling it
33
+
34
+ ```ruby
35
+ when "message_template_status_update"
36
+ update = change.value
37
+
38
+ MessageTemplate
39
+ .find_by(meta_id: update.message_template_id)
40
+ &.update!(status: update.event, rejected_reason: update.reason)
41
+
42
+ if update.event == "REJECTED"
43
+ Rails.logger.warn(
44
+ "Template #{update.message_template_name} (#{update.message_template_language}) " \
45
+ "rejected: #{update.reason}"
46
+ )
47
+ end
48
+ ```
49
+
50
+ See [../message_templates/README.md](../message_templates/README.md) for the creation
51
+ side.
52
+
53
+ ---
54
+
55
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,520 @@
1
+ # `messages`
2
+
3
+ Inbound customer messages and outbound delivery statuses — the field you will spend
4
+ almost all your time on, and one of only five fields with a Meta-published JSON
5
+ schema (alongside [`user_id_update`](user_id_update.md) and
6
+ [`business_username_updates`](business_username_updates.md)).
7
+
8
+ `Whatsapp::Webhook::Messages` · confidence: **confirmed** — Meta publishes a real JSON
9
+ example
10
+
11
+ ## A full notification
12
+
13
+ ```json
14
+ {
15
+ "object": "whatsapp_business_account",
16
+ "entry": [{
17
+ "id": "102290129340398",
18
+ "changes": [{
19
+ "field": "messages",
20
+ "value": {
21
+ "messaging_product": "whatsapp",
22
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
23
+ "contacts": [{ "profile": { "name": "Sheena Nelson" }, "wa_id": "16505551234" }],
24
+ "messages": [{
25
+ "from": "16505551234",
26
+ "id": "wamid.HBg...",
27
+ "timestamp": "1749416383",
28
+ "type": "text",
29
+ "text": { "body": "Does it come in another color?" }
30
+ }]
31
+ }
32
+ }]
33
+ }]
34
+ }
35
+ ```
36
+
37
+ ```ruby
38
+ value = notification.entry.first.changes.first.value
39
+
40
+ value.messaging_product # => "whatsapp"
41
+ value.metadata.phone_number_id # => "106540352242922"
42
+ value.metadata.display_phone_number # => "15550783881"
43
+ value.contacts.first.profile_name # => "Sheena Nelson"
44
+ value.contacts.first.wa_id # => "16505551234"
45
+ value.messages.first.body # => "Does it come in another color?"
46
+ value.statuses # => []
47
+ ```
48
+
49
+ ## Value accessors
50
+
51
+ | Accessor | Type |
52
+ | --- | --- |
53
+ | `messaging_product` | `"whatsapp"` |
54
+ | `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
55
+ | `contacts` | `Array<Contact>` — `profile_name`, `username`, `wa_id`, `user_id`, `parent_user_id` |
56
+ | `messages` | `Array<Message::*>` |
57
+ | `statuses` | `Array<Status>` |
58
+
59
+ A given notification carries **either** `messages` **or** `statuses`, never both — but
60
+ both accessors always return an array, so you can iterate either without a nil check.
61
+
62
+ > `contacts` here is the value-level sender identity (who wrote to you). It is a
63
+ > different thing from [`Message::Contacts`](#contacts) below, which is a contact
64
+ > *card* someone shared with you.
65
+
66
+ ### Business-scoped user IDs (BSUID)
67
+
68
+ Once a WhatsApp user adopts a username, their phone number may stop appearing in
69
+ webhooks — `wa_id`/`from`/`recipient_id` are omitted, and the only stable identifier
70
+ left is their **business-scoped user ID** (BSUID), a country-code-prefixed string like
71
+ `"US.13491208655302741918"` assigned to `user_id`/`from_user_id`/`recipient_user_id`.
72
+ Meta sends `user_id`/`from_user_id` with every contact entry and inbound message in its
73
+ published examples, whether or not a username has been adopted, and sends
74
+ `recipient_user_id` on every delivery status regardless of how you addressed the
75
+ message — see [what arrives with which status](#what-arrives-with-which-status) for the
76
+ one exception. All of
77
+ them deserialize to `nil` rather than raising when absent, so read them as optional. A
78
+ `parent_user_id`/`from_parent_user_id`/`recipient_parent_user_id` variant additionally
79
+ appears if you have enrolled in [parent BSUIDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#parent-business-scoped-user-ids)
80
+ (shared across business portfolios). See
81
+ [Business-scoped user IDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids)
82
+ for the full rollout details.
83
+
84
+ ```ruby
85
+ contact = value.contacts.first
86
+ contact.wa_id # => "16315551181" or nil once phone number sharing stops
87
+ contact.user_id # => "US.13491208655302741918" — sent with every contact entry
88
+ contact.username # => "@testusername" or nil — set only once adopted
89
+ contact.parent_user_id # => set only when parent BSUIDs are enabled
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Message types
95
+
96
+ Every entry in `messages` is dispatched by its `type` through the frozen
97
+ `Message::MESSAGE_TYPES` registry. An unrecognized type falls back to
98
+ [`Unknown`](#unknown), never an exception.
99
+
100
+ All message classes share this envelope from `Message::Base`:
101
+
102
+ | Accessor | Meaning |
103
+ | --- | --- |
104
+ | `from` | The customer's WhatsApp ID, or `nil` once they adopt a username (see [BSUID](#business-scoped-user-ids-bsuid)) |
105
+ | `from_user_id` | The customer's BSUID — sent on every inbound message; `nil` if absent |
106
+ | `from_parent_user_id` | The customer's parent BSUID, present only if enabled |
107
+ | `group_id` | Set only if the message was sent in a group chat; `nil` otherwise |
108
+ | `id` | The WAMID — pass it to [`mark_message_as_read!`](../messages/mark_message_as_read.md) |
109
+ | `timestamp` | Unix seconds, as a String |
110
+ | `type` | `"text"`, `"image"`, … |
111
+ | `context` | [`Context`](#context) or `nil` — set when replying or forwarding |
112
+ | `referral` | [`Referral`](#referral) or `nil` — set when the chat started from an ad |
113
+
114
+ ### Text
115
+
116
+ ```json
117
+ { "type": "text", "text": { "body": "Does it come in another color?" } }
118
+ ```
119
+
120
+ ```ruby
121
+ message.body # => "Does it come in another color?"
122
+ ```
123
+
124
+ ### Media — image, video, audio, document, sticker
125
+
126
+ All five share a `Media` superclass:
127
+
128
+ | Accessor | Meaning |
129
+ | --- | --- |
130
+ | `media_id` | Pass to [`Media#get_url`](../media/README.md) to download it |
131
+ | `mime_type` | `"image/jpeg"`, `"application/pdf"`, … |
132
+ | `sha256` | Content hash |
133
+ | `caption` | Present on image, video, document |
134
+
135
+ Plus one extra each on three of them:
136
+
137
+ | Type | Extra |
138
+ | --- | --- |
139
+ | `audio` | `voice` — `true` for a recorded voice note, `false` for an audio file |
140
+ | `document` | `filename` |
141
+ | `sticker` | `animated` |
142
+
143
+ ```json
144
+ { "type": "document",
145
+ "document": { "id": "med.4", "mime_type": "application/pdf",
146
+ "sha256": "abc", "caption": "Invoice", "filename": "invoice.pdf" } }
147
+ ```
148
+
149
+ ```ruby
150
+ message.media_id # => "med.4"
151
+ message.mime_type # => "application/pdf"
152
+ message.filename # => "invoice.pdf"
153
+
154
+ # fetch the bytes
155
+ info = Whatsapp::Media.new.get_url(media_id: message.media_id)
156
+ Whatsapp::Media.new.download(url: info["url"], save_to: message.filename)
157
+ ```
158
+
159
+ ```json
160
+ { "type": "audio", "audio": { "id": "med.3", "mime_type": "audio/ogg", "sha256": "abc", "voice": true } }
161
+ ```
162
+
163
+ ```ruby
164
+ message.voice # => true
165
+ ```
166
+
167
+ ### Location
168
+
169
+ ```json
170
+ { "type": "location",
171
+ "location": { "latitude": 37.4847, "longitude": -122.1477,
172
+ "name": "Meta HQ", "address": "1 Hacker Way" } }
173
+ ```
174
+
175
+ ```ruby
176
+ message.latitude # => 37.4847
177
+ message.longitude # => -122.1477
178
+ message.name # => "Meta HQ"
179
+ message.address # => "1 Hacker Way"
180
+ ```
181
+
182
+ This is what comes back from a
183
+ [location request](../messages/location_request.md).
184
+
185
+ ### Contacts
186
+
187
+ A contact *card* the customer shared with you.
188
+
189
+ ```json
190
+ { "type": "contacts",
191
+ "contacts": [{ "name": { "formatted_name": "Jane Doe" },
192
+ "phones": [{ "phone": "+1", "type": "WORK" }] }] }
193
+ ```
194
+
195
+ ```ruby
196
+ card = message.contacts.first
197
+ card.name.formatted_name # => "Jane Doe"
198
+ card.phones.first.phone # => "+1"
199
+ ```
200
+
201
+ Nested objects mirror the outbound [contacts message](../messages/contacts.md)
202
+ field-for-field: `Name`, `Phone`, `Email`, `Address`, `Org`, `Url`, plus `birthday`.
203
+
204
+ When the card arrives from a [`REQUEST_CONTACT_INFO`
205
+ button](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#requesting-phone-numbers-from-users)
206
+ or a direct in-chat share, two more fields are present:
207
+
208
+ ```ruby
209
+ card.origin # => "contact_request" (button tap) or "other" (shared from the chat)
210
+ card.vcard # => the raw vCard string when origin is "other"; nil for "contact_request"
211
+ ```
212
+
213
+ ### Interactive
214
+
215
+ A tap on [reply buttons or a list](../messages/interactive.md).
216
+
217
+ ```json
218
+ { "type": "interactive",
219
+ "interactive": { "type": "button_reply",
220
+ "button_reply": { "id": "confirm", "title": "Confirm" } } }
221
+ ```
222
+
223
+ ```ruby
224
+ message.interactive_type # => "button_reply" (or "list_reply")
225
+ message.reply_id # => "confirm" — the id you set when sending
226
+ message.title # => "Confirm"
227
+ message.description # => nil — list_reply only
228
+ ```
229
+
230
+ ```json
231
+ { "type": "interactive",
232
+ "interactive": { "type": "list_reply",
233
+ "list_reply": { "id": "espresso", "title": "Espresso",
234
+ "description": "Strong & short" } } }
235
+ ```
236
+
237
+ ```ruby
238
+ message.interactive_type # => "list_reply"
239
+ message.reply_id # => "espresso"
240
+ message.description # => "Strong & short"
241
+ ```
242
+
243
+ ### Button
244
+
245
+ A tap on a **template** quick-reply button — distinct from `interactive` above, which
246
+ covers interactive messages.
247
+
248
+ ```json
249
+ { "type": "button", "button": { "text": "Confirm", "payload": "CONFIRM_PAYLOAD" } }
250
+ ```
251
+
252
+ ```ruby
253
+ message.text # => "Confirm"
254
+ message.payload # => "CONFIRM_PAYLOAD"
255
+ ```
256
+
257
+ ### Order
258
+
259
+ A cart submitted from your Meta catalog.
260
+
261
+ ```json
262
+ { "type": "order",
263
+ "order": { "catalog_id": "cat.1", "text": "Here's my order",
264
+ "product_items": [{ "product_retailer_id": "sku.1", "quantity": 2,
265
+ "item_price": 9.99, "currency": "USD" }] } }
266
+ ```
267
+
268
+ ```ruby
269
+ message.catalog_id # => "cat.1"
270
+ message.text # => "Here's my order"
271
+ item = message.product_items.first
272
+ item.product_retailer_id # => "sku.1"
273
+ item.quantity # => 2
274
+ item.item_price # => 9.99
275
+ item.currency # => "USD"
276
+ ```
277
+
278
+ ### System
279
+
280
+ A change to the customer's account — a new phone number, an identity change.
281
+
282
+ ```json
283
+ { "type": "system",
284
+ "system": { "body": "Jane changed to a new phone", "identity": "ABCD1234",
285
+ "wa_id": "16505551234", "type": "customer_changed_number" } }
286
+ ```
287
+
288
+ ```ruby
289
+ message.body # => "Jane changed to a new phone"
290
+ message.identity # => "ABCD1234"
291
+ message.wa_id # => "16505551234"
292
+ message.change_type # => "customer_changed_number"
293
+ ```
294
+
295
+ Note `change_type`, not `type` — `type` is taken by the message envelope.
296
+
297
+ When a user's BSUID itself changes (`change_type == "user_changed_user_id"`), the new
298
+ identifiers are also present:
299
+
300
+ ```ruby
301
+ message.user_id # => the customer's new BSUID
302
+ message.parent_user_id # => the customer's new parent BSUID, if enabled
303
+ ```
304
+
305
+ ### Reaction
306
+
307
+ ```json
308
+ { "type": "reaction", "reaction": { "message_id": "wamid.OLD", "emoji": "👍" } }
309
+ ```
310
+
311
+ ```ruby
312
+ message.message_id # => "wamid.OLD" — the message being reacted to
313
+ message.emoji # => "👍" — empty String when a reaction is removed
314
+ ```
315
+
316
+ ### Revoke
317
+
318
+ The customer deleted a message they had sent you. A *control* message rather than a
319
+ content one: it names a message you already have instead of carrying a body.
320
+
321
+ ```json
322
+ { "from": "16505551234", "id": "wamid.HBg", "timestamp": "1749416383",
323
+ "type": "revoke", "revoke": { "original_message_id": "wamid.ORIGINAL" } }
324
+ ```
325
+
326
+ ```ruby
327
+ message.original_message_id # => "wamid.ORIGINAL" — the message to delete on your side
328
+ message.id # => "wamid.HBg" — the revoke notification's own wamid
329
+ ```
330
+
331
+ Also arrives through [`smb_message_echoes`](smb_message_echoes.md) when the *business*
332
+ revokes a message from the WhatsApp Business app.
333
+
334
+ ### Edit
335
+
336
+ The customer edited a message they had sent you. Names the message being replaced and
337
+ carries the replacement content.
338
+
339
+ ```json
340
+ { "from": "16505551234", "id": "wamid.HBg", "timestamp": "1749416383",
341
+ "type": "edit",
342
+ "edit": { "original_message_id": "wamid.ORIGINAL",
343
+ "message": { "context": { "id": "wamid.CONTEXT" },
344
+ "type": "image",
345
+ "image": { "caption": "Now in blue", "id": "media.1" } } } }
346
+ ```
347
+
348
+ ```ruby
349
+ message.original_message_id # => "wamid.ORIGINAL"
350
+ message.message # => #<Whatsapp::Webhook::Message::Image ...>
351
+ message.message.caption # => "Now in blue"
352
+ ```
353
+
354
+ `message` is a full nested `Message::*`, dispatched by its own `type` — so an edited
355
+ image behaves exactly like an inbound one. It carries **content only**: the nested
356
+ payload has no `from`/`id`/`timestamp` of its own, so those read `nil`. Use the outer
357
+ `message.id` and `message.from`.
358
+
359
+ Also arrives through [`smb_message_echoes`](smb_message_echoes.md) when the *business*
360
+ edits a message from the WhatsApp Business app.
361
+
362
+ ### Unknown
363
+
364
+ The fallback for a type this gem doesn't model — including anything Meta flags as
365
+ unsupported. It carries the errors and the whole raw hash, so nothing is lost.
366
+
367
+ ```json
368
+ { "from": "16505551234", "type": "unsupported_future_type",
369
+ "errors": [{ "code": 131051, "title": "Unsupported message type", "message": "m" }] }
370
+ ```
371
+
372
+ ```ruby
373
+ message.type # => "unsupported_future_type"
374
+ message.errors.first.code # => 131051
375
+ message.errors.first.title # => "Unsupported message type"
376
+ message.raw # => the complete hash
377
+ ```
378
+
379
+ ---
380
+
381
+ ## Context
382
+
383
+ Present when the customer replied to, or forwarded, a message.
384
+
385
+ ```ruby
386
+ message.context&.id # => "wamid.HBg..." — the message replied to
387
+ message.context&.from # => the sender of that message, or nil once
388
+ # they adopt a username
389
+ message.context&.from_user_id # => that sender's BSUID
390
+ message.context&.from_parent_user_id # => their parent BSUID, if enrolled
391
+ message.context&.forwarded # => true | false
392
+ message.context&.frequently_forwarded # => true | false
393
+ ```
394
+
395
+ `context` is `nil` — not an all-nil object — when the key is absent, so `&.` is the
396
+ right idiom.
397
+
398
+ `from_user_id`/`from_parent_user_id` carry the quoted sender's identity when their phone
399
+ number is gone, the same way [`from_user_id` does on the message itself](#business-scoped-user-ids-bsuid).
400
+ **Their key names are inferred** by symmetry with `Message::Base` — Meta has not published
401
+ a `context` example carrying them, so validate against a real payload before depending on
402
+ them.
403
+
404
+ ## Referral
405
+
406
+ Present when the conversation started from a click-to-WhatsApp ad or post.
407
+
408
+ | Accessor | Meaning |
409
+ | --- | --- |
410
+ | `source_url` | The ad or post URL |
411
+ | `source_type` | `"ad"` or `"post"` |
412
+ | `source_id` | The ad or post ID |
413
+ | `headline` | Ad headline |
414
+ | `body` | Ad body |
415
+ | `media_type` | `"image"` or `"video"` |
416
+ | `image_url` `video_url` `thumbnail_url` | Creative assets |
417
+
418
+ ```ruby
419
+ message.referral&.source_type # => "ad"
420
+ message.referral&.headline # => "Big sale"
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Statuses
426
+
427
+ Delivery receipts for messages **you** sent. They arrive on the same `messages` field.
428
+
429
+ ```json
430
+ { "id": "wamid.HBg", "status": "delivered", "timestamp": "1750263773",
431
+ "recipient_id": "16505551234",
432
+ "conversation": { "id": "6ceb9d9", "origin": { "type": "service" } },
433
+ "pricing": { "billable": true, "pricing_model": "CBP", "category": "service" },
434
+ "errors": [{ "code": 131026, "title": "Message undeliverable", "message": "m" }] }
435
+ ```
436
+
437
+ ```ruby
438
+ status = value.statuses.first
439
+
440
+ status.id # => "wamid.HBg" — matches what send! returned
441
+ status.status # => "delivered" — sent | delivered | read | failed
442
+ status.timestamp # => "1750263773"
443
+ status.recipient_id # => "16505551234", or nil once sent to a BSUID (see below)
444
+ ```
445
+
446
+ | Accessor | Type |
447
+ | --- | --- |
448
+ | `recipient_user_id` | The recipient's BSUID — sent on every status; see the table below for the `failed` exception |
449
+ | `recipient_parent_user_id` | The recipient's parent BSUID, present only if enabled |
450
+ | `conversation` | `Status::Conversation` — `id`, `origin_type`, `expiration_timestamp` |
451
+ | `pricing` | `Status::Pricing` — `billable`, `pricing_model`, `category` |
452
+ | `errors` | `Array<Webhook::Error>` — populated on `"failed"` |
453
+
454
+ `recipient_id` is present when you sent the message to a phone number (or when the
455
+ recipient's phone number is otherwise available). `recipient_user_id` is **not** the
456
+ mirror image of that: Meta sends it on every status whatever you addressed, so the two
457
+ routinely arrive together.
458
+
459
+ ### What arrives with which status
460
+
461
+ | | `sent` | `delivered` | `read` | `failed` |
462
+ | --- | :---: | :---: | :---: | :---: |
463
+ | `recipient_user_id` | always | always | always | only if addressed to a BSUID |
464
+ | `contacts[]` | yes | yes | yes | **omitted entirely** |
465
+ | `contacts[].profile.username` | omitted | if adopted | if adopted | — |
466
+ | `errors` | — | — | — | populated |
467
+
468
+ Two traps in that table. A `failed` status is the only one that arrives with no
469
+ `contacts` array at all, so code that reads `value.contacts.first` while handling
470
+ failures must tolerate `nil` — `contacts` is always an Array, but an empty one. And a
471
+ `failed` status for a message you addressed to a phone number carries **neither**
472
+ identifier beyond `recipient_id`, so a BSUID-keyed lookup has to fall back to the phone
473
+ number on that path.
474
+
475
+ ```ruby
476
+ # Correlating a failure back to a customer, whichever identifier arrived
477
+ status = value.statuses.first
478
+ customer =
479
+ Customer.find_by(bsuid: status.recipient_user_id) ||
480
+ Customer.find_by(wa_id: status.recipient_id)
481
+ ```
482
+
483
+ ```ruby
484
+ status.conversation.id # => "6ceb9d9"
485
+ status.conversation.origin_type # => "service" | marketing | utility | authentication
486
+ status.conversation.expiration_timestamp # => when the 24-hour window closes
487
+
488
+ status.pricing.billable # => true
489
+ status.pricing.pricing_model # => "CBP"
490
+ status.pricing.category # => "service"
491
+ ```
492
+
493
+ Correlating a status back to your own record:
494
+
495
+ ```ruby
496
+ response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
497
+ OutboundMessage.create!(wamid: response.messages.first.id)
498
+
499
+ # ... later, in the webhook handler
500
+ value.statuses.each do |status|
501
+ OutboundMessage.find_by(wamid: status.id)&.update!(state: status.status)
502
+ end
503
+ ```
504
+
505
+ ## Errors
506
+
507
+ `Whatsapp::Webhook::Error` is a **value object**, not an exception — it models Meta's
508
+ error payloads on `Status#errors` and `Message::Unknown#errors`.
509
+
510
+ ```ruby
511
+ error = status.errors.first
512
+ error.code # => 131026
513
+ error.title # => "Message undeliverable"
514
+ error.message # => "m"
515
+ error.details # => dug from error_data.details
516
+ ```
517
+
518
+ ---
519
+
520
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview>
@@ -0,0 +1,35 @@
1
+ # `partner_solutions`
2
+
3
+ Lifecycle events for a Tech Provider "solution" — the packaged onboarding a partner
4
+ offers to its own customers.
5
+
6
+ `Whatsapp::Webhook::PartnerSolutions` · confidence: **low**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "partner_solutions", "value": { "solution_id": "sol.1", "event": "DISCONNECTED" } }
12
+ ```
13
+
14
+ ## Accessors
15
+
16
+ | Accessor | Meaning |
17
+ | --- | --- |
18
+ | `solution_id` | The solution's ID |
19
+ | `event` | e.g. `CONNECTED`, `DISCONNECTED` |
20
+
21
+ ## Handling it
22
+
23
+ ```ruby
24
+ when "partner_solutions"
25
+ Solution.find_by(meta_id: change.value.solution_id)&.update!(state: change.value.event)
26
+ ```
27
+
28
+ Relevant only if you operate as a Tech Provider. Most integrations never see it.
29
+
30
+ > **Best-effort schema, low confidence.** Validate against a real payload before
31
+ > depending on it in production.
32
+
33
+ ---
34
+
35
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview