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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/README.md +322 -617
- data/docs/README.md +76 -0
- data/docs/business_phone_number/README.md +243 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +177 -0
- data/docs/errors.md +219 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +215 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +212 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +342 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +193 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/schedule/README.md +183 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +217 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +107 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +520 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +91 -0
- data/docs/webhooks/smb_message_echoes.md +91 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +87 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- 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
|