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,217 @@
1
+ # Webhooks
2
+
3
+ Meta pushes inbound messages, delivery statuses, and 20 other account and template
4
+ notifications to a callback URL you register. This directory covers the *inbound*
5
+ direction — the opposite of everything under [../messages/](../messages/README.md).
6
+
7
+ > **Business-scoped user IDs (BSUID).** Meta is rolling out usernames, after which a
8
+ > WhatsApp user's phone number may stop appearing in webhooks entirely. `Contact`,
9
+ > `Message::Base`, and `Status` all carry BSUID fallback identifiers now — see
10
+ > [messages.md's BSUID section](messages.md#business-scoped-user-ids-bsuid).
11
+
12
+ > **Two different things.** `Whatsapp::Webhook` deserializes notifications once Meta is
13
+ > already sending them. Turning that delivery **on** for a WhatsApp Business Account is
14
+ > [`Whatsapp::SubscribedApp`](../subscribed_app/README.md). You need both.
15
+
16
+ ## Install (Rails)
17
+
18
+ ```bash
19
+ bundle exec rake whatsapp:install:webhook
20
+ ```
21
+
22
+ This copies a personalizable controller to
23
+ `app/controllers/whatsapp/webhooks_controller.rb` — it never overwrites an existing
24
+ one — and prints the routes and configuration you still need to add by hand:
25
+
26
+ ```ruby
27
+ # config/routes.rb
28
+ get "/whatsapp/webhooks", to: "whatsapp/webhooks#verify"
29
+ post "/whatsapp/webhooks", to: "whatsapp/webhooks#receive"
30
+
31
+ # config/initializers/whatsapp.rb
32
+ Whatsapp.configure do |config|
33
+ config.verify_token = Rails.application.credentials.whatsapp_verify_token
34
+ config.app_secret = Rails.application.credentials.whatsapp_app_secret
35
+ end
36
+ ```
37
+
38
+ The rake task is registered by `Whatsapp::Railtie` and only exists inside a Rails app.
39
+ Outside Rails, call the installer directly — it is plain Ruby with no Rails dependency:
40
+
41
+ ```ruby
42
+ Whatsapp::Webhook::Installer.call(root: Dir.pwd) # => :created | :skipped
43
+ ```
44
+
45
+ ## The generated controller
46
+
47
+ Yours to edit. It handles the handshake and the signature check, then deserializes
48
+ every notification into typed objects and leaves a `# TODO` where your handling goes:
49
+
50
+ ```ruby
51
+ class Whatsapp::WebhooksController < ApplicationController
52
+ skip_before_action :verify_authenticity_token, raise: false
53
+
54
+ def verify
55
+ challenge = Whatsapp::Webhook::Verification.call(params: params)
56
+ challenge ? render(plain: challenge) : head(:forbidden)
57
+ end
58
+
59
+ def receive
60
+ raw_body = request.body.read
61
+ return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
62
+ payload: raw_body, header: request.headers["X-Hub-Signature-256"]
63
+ )
64
+
65
+ notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
66
+ # notification.entry.each { |entry| entry.changes.each { |change| WebhookJob.perform_later(change) } }
67
+
68
+ head :ok
69
+ end
70
+ end
71
+ ```
72
+
73
+ > **Answer fast, work later.** Meta retries a notification that doesn't get a 2xx
74
+ > quickly. Enqueue the work; don't do it inline.
75
+
76
+ ## Verification (the GET handshake)
77
+
78
+ When you register the callback URL, Meta calls it once with `hub.mode`,
79
+ `hub.verify_token`, and `hub.challenge`. Echo the challenge back if the token matches:
80
+
81
+ ```ruby
82
+ Whatsapp::Webhook::Verification.call(params: params)
83
+ # => "1158201444" — the challenge, to render as plain text
84
+ # => nil — mode wasn't "subscribe", or the token didn't match
85
+ ```
86
+
87
+ The token comparison uses `ActiveSupport::SecurityUtils.secure_compare`. A nil or empty
88
+ configured `verify_token` always returns `nil` rather than accepting anything.
89
+
90
+ ## Signature (the POST check)
91
+
92
+ Every notification carries an `X-Hub-Signature-256` header: HMAC-SHA256 of the **raw
93
+ request body** using your app secret.
94
+
95
+ ```ruby
96
+ Whatsapp::Webhook::Signature.valid?(payload: raw_body, header: request.headers["X-Hub-Signature-256"])
97
+ # => true | false
98
+ ```
99
+
100
+ > **Use the raw body, not `params`.** Re-serializing a parsed hash changes the bytes
101
+ > and the signature will never match.
102
+
103
+ Returns `false` — never raises — for a nil payload, a missing or empty header, or a
104
+ missing app secret.
105
+
106
+ ## Multi-tenant apps
107
+
108
+ Both take the credential as an overridable keyword, defaulting to
109
+ `Whatsapp.configuration`. That override is the multi-tenant path: an app serving many
110
+ customers, each with their own Meta App, resolves the account first and passes its
111
+ credentials explicitly.
112
+
113
+ ```ruby
114
+ account = Account.find_by!(slug: params[:account_slug])
115
+
116
+ Whatsapp::Webhook::Verification.call(params:, verify_token: account.verify_token)
117
+ Whatsapp::Webhook::Signature.valid?(payload: raw_body, header:, app_secret: account.app_secret)
118
+ ```
119
+
120
+ ## The notification tree
121
+
122
+ ```
123
+ { object, entry: [ { id, changes: [ { field, value } ] } ] }
124
+ ```
125
+
126
+ ```ruby
127
+ notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
128
+
129
+ notification.object # => "whatsapp_business_account"
130
+ notification.entry.first.id # => "102290129340398" — the WABA ID
131
+ change = notification.entry.first.changes.first
132
+ change.field # => "messages"
133
+ change.value # => #<Whatsapp::Webhook::Messages ...>
134
+ ```
135
+
136
+ `change.field` is one of Meta's 21 documented field names; `change.value` is that
137
+ field's deserialized payload, resolved through a frozen `Change::FIELDS` registry —
138
+ **never `const_get` on the field name**, since it comes straight off the internet.
139
+
140
+ A field not in the registry (something Meta adds after this gem ships) falls back to
141
+ `UnknownField` rather than raising, so your controller never 500s on an unrecognized
142
+ notification:
143
+
144
+ ```ruby
145
+ change.value # => #<Whatsapp::Webhook::UnknownField ...>
146
+ change.value["some_key"] # reach into the raw hash
147
+ change.value.to_h # the whole thing
148
+ ```
149
+
150
+ ## The 21 fields
151
+
152
+ | Field | Page | Confidence |
153
+ | --- | --- | --- |
154
+ | `messages` | [messages.md](messages.md) | **Confirmed** — Meta publishes a real payload |
155
+ | `user_id_update` | [user_id_update.md](user_id_update.md) | **Confirmed** — Meta publishes a real payload |
156
+ | `business_username_updates` | [business_username_updates.md](business_username_updates.md) | **Confirmed** — Meta publishes a real payload |
157
+ | `user_preferences` | [user_preferences.md](user_preferences.md) | **Confirmed** — Meta publishes a real payload |
158
+ | `history` | [history.md](history.md) | **Confirmed** — Meta publishes a real payload |
159
+ | `account_alerts` | [account_alerts.md](account_alerts.md) | Moderate |
160
+ | `account_review_update` | [account_review_update.md](account_review_update.md) | High |
161
+ | `account_update` | [account_update.md](account_update.md) | Moderate |
162
+ | `automatic_events` | [automatic_events.md](automatic_events.md) | Low |
163
+ | `business_capability_update` | [business_capability_update.md](business_capability_update.md) | Moderate |
164
+ | `message_template_components_update` | [message_template_components_update.md](message_template_components_update.md) | Moderate |
165
+ | `message_template_quality_update` | [message_template_quality_update.md](message_template_quality_update.md) | Moderate-high |
166
+ | `message_template_status_update` | [message_template_status_update.md](message_template_status_update.md) | High |
167
+ | `partner_solutions` | [partner_solutions.md](partner_solutions.md) | Low |
168
+ | `payment_configuration_update` | [payment_configuration_update.md](payment_configuration_update.md) | Moderate |
169
+ | `phone_number_name_update` | [phone_number_name_update.md](phone_number_name_update.md) | Moderate-high |
170
+ | `phone_number_quality_update` | [phone_number_quality_update.md](phone_number_quality_update.md) | Moderate |
171
+ | `security` | [security.md](security.md) | Low |
172
+ | `smb_app_state_sync` | [smb_app_state_sync.md](smb_app_state_sync.md) | High |
173
+ | `smb_message_echoes` | [smb_message_echoes.md](smb_message_echoes.md) | High |
174
+ | `template_category_update` | [template_category_update.md](template_category_update.md) | Moderate-high |
175
+
176
+ > **On confidence.** The five **Confirmed** fields each have a Meta-published JSON
177
+ > example, and `smb_message_echoes`/`smb_app_state_sync` have a published entry shape but
178
+ > an array key still inferred from the field name. The other 14 field classes are
179
+ > reconstructed from the one-line descriptions in Meta's docs plus general Cloud API knowledge. They are
180
+ > real, tested classes — but validate one against a real payload (App Dashboard → "send
181
+ > test payload") before depending on it in production.
182
+
183
+ ## Dispatching
184
+
185
+ `Change#field` is a plain String, which makes a `case` the natural handler:
186
+
187
+ ```ruby
188
+ notification.entry.each do |entry|
189
+ entry.changes.each do |change|
190
+ case change.field
191
+ when "messages"
192
+ change.value.messages.each { |m| handle_message(m) }
193
+ change.value.statuses.each { |s| handle_status(s) }
194
+ when "message_template_status_update"
195
+ Template.find_by(meta_id: change.value.message_template_id)
196
+ &.update!(status: change.value.event)
197
+ else
198
+ Rails.logger.info("Unhandled webhook field: #{change.field}")
199
+ end
200
+ end
201
+ end
202
+ ```
203
+
204
+ ## Conventions
205
+
206
+ Every class here follows the read-side pattern — no validations, no `serialize`, just
207
+ a class-level `.deserialize(data)`:
208
+
209
+ 1. Plain Ruby object, `attr_accessor` per field.
210
+ 2. `.deserialize(data)` tolerates a nil or partial hash (`data ||= {}`) and missing
211
+ arrays (`Array(data["key"]).map { ... }`).
212
+ 3. Optional nested objects return `nil` when their source key is absent, not an
213
+ all-nil instance — e.g. `Message::Context.deserialize(nil) # => nil`.
214
+
215
+ ---
216
+
217
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview>
@@ -0,0 +1,44 @@
1
+ # `account_alerts`
2
+
3
+ Operational alerts about the account — messaging-limit changes, policy notices,
4
+ capability warnings.
5
+
6
+ `Whatsapp::Webhook::AccountAlerts` · confidence: **moderate**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "account_alerts",
12
+ "value": {
13
+ "entity_type": "WABA",
14
+ "entity_id": "102290129340398",
15
+ "alert_severity": "INFO",
16
+ "alert_status": "ACTIVE",
17
+ "alert_description": "Messaging limit increased"
18
+ } }
19
+ ```
20
+
21
+ ## Accessors
22
+
23
+ | Accessor | Meaning |
24
+ | --- | --- |
25
+ | `entity_type` | What the alert is about, e.g. `WABA` |
26
+ | `entity_id` | That entity's ID |
27
+ | `alert_severity` | e.g. `INFO`, `WARNING`, `CRITICAL` |
28
+ | `alert_status` | e.g. `ACTIVE`, `RESOLVED` |
29
+ | `alert_description` | Human-readable text |
30
+
31
+ ## Handling it
32
+
33
+ ```ruby
34
+ when "account_alerts"
35
+ alert = change.value
36
+ Ops.notify(alert.alert_description) if alert.alert_severity != "INFO"
37
+ ```
38
+
39
+ > **Best-effort schema.** Meta publishes no JSON example for this field. Validate
40
+ > against a real payload before depending on it in production.
41
+
42
+ ---
43
+
44
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,29 @@
1
+ # `account_review_update`
2
+
3
+ The verdict on a WhatsApp Business Account review.
4
+
5
+ `Whatsapp::Webhook::AccountReviewUpdate` · confidence: **high** — one field, and Meta
6
+ documents it plainly
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "account_review_update", "value": { "decision": "APPROVED" } }
12
+ ```
13
+
14
+ ## Accessors
15
+
16
+ | Accessor | Meaning |
17
+ | --- | --- |
18
+ | `decision` | `APPROVED` or `REJECTED` |
19
+
20
+ ## Handling it
21
+
22
+ ```ruby
23
+ when "account_review_update"
24
+ Account.find_by(waba_id: entry.id)&.update!(review_decision: change.value.decision)
25
+ ```
26
+
27
+ ---
28
+
29
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,54 @@
1
+ # `account_update`
2
+
3
+ Something changed about the WhatsApp Business Account itself — most importantly, a ban
4
+ or a restriction. If you handle exactly one non-`messages` field, make it this one:
5
+ a banned WABA stops delivering everything.
6
+
7
+ `Whatsapp::Webhook::AccountUpdate` · confidence: **moderate**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "account_update",
13
+ "value": {
14
+ "phone_number": "15550783881",
15
+ "event": "DISABLED_UPDATE",
16
+ "ban_info": { "waba_ban_state": "DISABLE", "waba_ban_date": "2024-01-01" }
17
+ } }
18
+ ```
19
+
20
+ ## Accessors
21
+
22
+ | Accessor | Meaning |
23
+ | --- | --- |
24
+ | `phone_number` | The number the event concerns |
25
+ | `event` | e.g. `DISABLED_UPDATE`, `VERIFIED_ACCOUNT`, `ACCOUNT_RESTRICTION` |
26
+ | `ban_info` | `AccountUpdate::BanInfo` or `nil` |
27
+
28
+ ### `BanInfo`
29
+
30
+ | Accessor | Meaning |
31
+ | --- | --- |
32
+ | `waba_ban_state` | e.g. `DISABLE`, `WARN`, `REINSTATE` |
33
+ | `waba_ban_date` | When it takes effect |
34
+
35
+ ## Handling it
36
+
37
+ ```ruby
38
+ when "account_update"
39
+ update = change.value
40
+
41
+ if update.ban_info
42
+ Ops.page!(
43
+ "WABA #{entry.id} ban state #{update.ban_info.waba_ban_state} " \
44
+ "effective #{update.ban_info.waba_ban_date}"
45
+ )
46
+ end
47
+ ```
48
+
49
+ > **Best-effort schema.** Meta publishes no JSON example for this field. Validate
50
+ > against a real payload before depending on it in production.
51
+
52
+ ---
53
+
54
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,47 @@
1
+ # `automatic_events`
2
+
3
+ Conversion and engagement events Meta attributes to a message — a purchase, a signup —
4
+ used for ad measurement.
5
+
6
+ `Whatsapp::Webhook::AutomaticEvents` · confidence: **low**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "automatic_events",
12
+ "value": { "event_type": "purchase",
13
+ "message_id": "wamid.HBg...",
14
+ "event_data": { "value": 19.99, "currency": "USD" } } }
15
+ ```
16
+
17
+ ## Accessors
18
+
19
+ | Accessor | Meaning |
20
+ | --- | --- |
21
+ | `event_type` | e.g. `purchase` |
22
+ | `message_id` | The WAMID the event is attributed to |
23
+ | `event_data` | **Raw Hash**, defaults to `{}` |
24
+
25
+ `event_data` is deliberately left as a raw hash: its keys vary by `event_type` and Meta
26
+ publishes no schema, so typing it would be guesswork.
27
+
28
+ ## Handling it
29
+
30
+ ```ruby
31
+ when "automatic_events"
32
+ event = change.value
33
+
34
+ Conversion.create!(
35
+ kind: event.event_type,
36
+ wamid: event.message_id,
37
+ amount: event.event_data["value"],
38
+ currency: event.event_data["currency"]
39
+ )
40
+ ```
41
+
42
+ > **Best-effort schema, low confidence.** Validate against a real payload before
43
+ > depending on it in production.
44
+
45
+ ---
46
+
47
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,40 @@
1
+ # `business_capability_update`
2
+
3
+ Your account's capability limits changed — how many conversations each number may
4
+ start per day, and how many numbers the business may hold. A limit increase usually
5
+ follows sustained good quality; a decrease follows the opposite.
6
+
7
+ `Whatsapp::Webhook::BusinessCapabilityUpdate` · confidence: **moderate**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "business_capability_update",
13
+ "value": { "max_daily_conversation_per_phone": 100000,
14
+ "max_phone_numbers_per_business": 20 } }
15
+ ```
16
+
17
+ ## Accessors
18
+
19
+ | Accessor | Meaning |
20
+ | --- | --- |
21
+ | `max_daily_conversation_per_phone` | Business-initiated conversations per number per day |
22
+ | `max_phone_numbers_per_business` | Numbers this business may register |
23
+
24
+ ## Handling it
25
+
26
+ ```ruby
27
+ when "business_capability_update"
28
+ caps = change.value
29
+ Throttle.update!(daily_cap: caps.max_daily_conversation_per_phone)
30
+ ```
31
+
32
+ Useful for pacing an outbound campaign against the real ceiling rather than a
33
+ hard-coded guess.
34
+
35
+ > **Best-effort schema.** Meta publishes no JSON example for this field. Validate
36
+ > against a real payload before depending on it in production.
37
+
38
+ ---
39
+
40
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,55 @@
1
+ # `business_username_updates`
2
+
3
+ A business phone number's username status changed — approved, reserved, or deleted.
4
+ Subscribe to this field to stay in sync whenever a username you claimed moves through
5
+ review, or is removed via the WhatsApp Business app.
6
+
7
+ `Whatsapp::Webhook::BusinessUsernameUpdates` · confidence: **confirmed** — Meta
8
+ publishes a real JSON example
9
+
10
+ ## Payload
11
+
12
+ ```json
13
+ { "field": "business_username_updates",
14
+ "value": {
15
+ "display_phone_number": "15550783881",
16
+ "username": "jaspersmarket",
17
+ "status": "approved"
18
+ } }
19
+ ```
20
+
21
+ `username` is omitted when `status` is `"deleted"`.
22
+
23
+ ## Accessors
24
+
25
+ | Accessor | Meaning |
26
+ | --- | --- |
27
+ | `display_phone_number` | The business phone number's display number |
28
+ | `username` | The username the status concerns; `nil` when `status` is `"deleted"` |
29
+ | `status` | `"approved"`, `"reserved"`, or `"deleted"` |
30
+
31
+ ## Handling it
32
+
33
+ ```ruby
34
+ when "business_username_updates"
35
+ update = change.value
36
+ digits = update.display_phone_number.to_s.gsub(/\D/, "")
37
+ number = PhoneNumber.find_by(display_phone_number_digits: digits)
38
+
39
+ if number
40
+ number.update!(username: update.username, username_status: update.status)
41
+ else
42
+ Rails.logger.error("[whatsapp] no PhoneNumber matching #{update.display_phone_number.inspect}")
43
+ end
44
+ ```
45
+
46
+ This field carries no `phone_number_id`, so `display_phone_number` is the only join key
47
+ you get — and Meta formats it for display, inconsistently (`"15550783881"` in one payload,
48
+ `"+1 555-078-3881"` in another). Match on digits, and report a miss instead of letting
49
+ `&.update!` swallow it: a silent no-op means you never learn the username moved. Log or
50
+ alert; do not `raise`, or a permanent formatting mismatch puts Meta into a retry loop
51
+ against a request that can never succeed. `update.raw` carries the untouched payload.
52
+
53
+ ---
54
+
55
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#business_username_updates-webhook>
@@ -0,0 +1,107 @@
1
+ # `history`
2
+
3
+ Conversation history synced from the WhatsApp Business app when a number moves onto
4
+ Cloud API. It arrives in chunks, so `phase`, `chunk_order`, and `progress` are how you
5
+ track completeness.
6
+
7
+ `Whatsapp::Webhook::History` · confidence: **confirmed**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "history",
13
+ "value": {
14
+ "messaging_product": "whatsapp",
15
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
16
+ "history": [
17
+ { "metadata": { "phase": 2, "chunk_order": 1, "progress": 50 },
18
+ "threads": [
19
+ { "id": "16505551234",
20
+ "context": { "wa_id": "16505551234",
21
+ "user_id": "US.13491208655302741918",
22
+ "username": "pablomorales" },
23
+ "messages": [
24
+ { "from": "16505551234",
25
+ "from_user_id": "US.13491208655302741918",
26
+ "id": "wamid.HBg",
27
+ "timestamp": "1749416383",
28
+ "type": "text",
29
+ "text": { "body": "Does it come in another color?" },
30
+ "history_context": { "status": "DELIVERED" } }
31
+ ] }
32
+ ] }
33
+ ] } }
34
+ ```
35
+
36
+ Two `metadata` objects, and they are different things: the value-level one is the
37
+ ordinary business-phone-number `Metadata`, while each `history[]` entry has its own
38
+ carrying the sync counters.
39
+
40
+ ## Value accessors
41
+
42
+ | Accessor | Type |
43
+ | --- | --- |
44
+ | `messaging_product` | `String` |
45
+ | `metadata` | `Metadata` — the business phone number, **not** the sync counters |
46
+ | `chunks` | `Array<History::Chunk>` |
47
+ | `phase` / `chunk_order` / `progress` | Read through to `chunks.first`, since a delivery normally carries one chunk |
48
+ | `contacts` | `Array<Contact>` — populated only by the media-asset variant |
49
+ | `messages` | `Array<Message::*>` — populated only by the media-asset variant |
50
+ | `raw` | The untouched `value` hash |
51
+
52
+ ## Chunk accessors
53
+
54
+ | Accessor | Meaning |
55
+ | --- | --- |
56
+ | `phase` | Sync phase |
57
+ | `chunk_order` | This chunk's position within its phase |
58
+ | `progress` | Percent complete, 0–100 |
59
+ | `threads` | `Array<History::Thread>` |
60
+
61
+ ## Thread accessors
62
+
63
+ | Accessor | Meaning |
64
+ | --- | --- |
65
+ | `id` | The thread identifier — the user's phone number, omitted for a username adopter |
66
+ | `context` | A `Contact` — `wa_id`, `user_id`, `parent_user_id`, `username` |
67
+ | `messages` | `Array<Message::*>`, dispatched by `type` like any inbound message |
68
+ | `raw` | The untouched thread hash |
69
+
70
+ `context` reuses the same [`Contact`](messages.md#value-accessors) as every other contact
71
+ entry: its `username` read already falls back to a flat key, which is where Meta puts it
72
+ here, and `profile_name` is simply `nil` since a thread context carries no profile.
73
+
74
+ Each synced message also has a `history_context.status` — its delivery state at sync
75
+ time. That is deliberately **not** modelled: it would put a history-only field on
76
+ `Message::Base`, the envelope shared by every inbound message type, where it could never
77
+ appear. Read it off `thread.raw`:
78
+
79
+ ```ruby
80
+ thread.raw.dig("messages", 0, "history_context", "status") # => "DELIVERED"
81
+ ```
82
+
83
+ ## Handling it
84
+
85
+ ```ruby
86
+ when "history"
87
+ sync = change.value
88
+
89
+ sync.chunks.each do |chunk|
90
+ chunk.threads.each do |thread|
91
+ # A username adopter has no phone number here — the BSUID is the join key
92
+ customer = Customer.find_or_create_by!(bsuid: thread.context&.user_id)
93
+ customer.import_history!(thread.messages)
94
+ end
95
+
96
+ Importer.finish! if chunk.progress == 100
97
+ end
98
+ ```
99
+
100
+ > **Changed in 0.6.0.** `phase`/`chunk_order`/`progress` used to be read from
101
+ > `value["metadata"]` and threads from `value["threads"]` — one level too high, so every
102
+ > accessor was permanently empty. `threads` has moved onto `Chunk`, and the three
103
+ > counters now work.
104
+
105
+ ---
106
+
107
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
@@ -0,0 +1,46 @@
1
+ # `message_template_components_update`
2
+
3
+ A template's components changed — usually because Meta edited it, or because an edit
4
+ you submitted was applied.
5
+
6
+ `Whatsapp::Webhook::MessageTemplateComponentsUpdate` · confidence: **moderate**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "message_template_components_update",
12
+ "value": {
13
+ "message_template_id": "123",
14
+ "message_template_name": "order_confirmation",
15
+ "message_template_language": "en_US",
16
+ "message_template_element": "BODY"
17
+ } }
18
+ ```
19
+
20
+ ## Accessors
21
+
22
+ | Accessor | Meaning |
23
+ | --- | --- |
24
+ | `message_template_id` | The template's ID |
25
+ | `message_template_name` | Its name |
26
+ | `message_template_language` | Its locale |
27
+ | `message_template_element` | Which component changed |
28
+
29
+ ## Handling it
30
+
31
+ The notification says *that* something changed, not what it changed to. Re-read the
32
+ template to get the new shape:
33
+
34
+ ```ruby
35
+ when "message_template_components_update"
36
+ node = Whatsapp::MessageTemplates.new.find(template_id: change.value.message_template_id)
37
+ MessageTemplate.find_by(meta_id: node.id)&.update!(components: node.components)
38
+ ```
39
+
40
+ > **Best-effort schema.** Meta describes this field in one line and publishes no JSON
41
+ > example. Validate against a real payload (App Dashboard → "send test payload")
42
+ > before depending on it in production.
43
+
44
+ ---
45
+
46
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview