ruby-whatsapp 0.4.2 → 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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +70 -7
  4. data/docs/README.md +4 -1
  5. data/docs/business_phone_number/README.md +12 -1
  6. data/docs/business_phone_number/contact_book.md +90 -0
  7. data/docs/business_phone_number/username.md +157 -0
  8. data/docs/configuration.md +6 -1
  9. data/docs/errors.md +12 -1
  10. data/docs/message_templates/README.md +1 -1
  11. data/docs/message_templates/components.md +37 -1
  12. data/docs/messages/README.md +86 -9
  13. data/docs/messages/address.md +1 -1
  14. data/docs/messages/audio.md +1 -1
  15. data/docs/messages/contacts.md +1 -1
  16. data/docs/messages/document.md +1 -1
  17. data/docs/messages/image.md +1 -1
  18. data/docs/messages/interactive.md +64 -3
  19. data/docs/messages/location.md +1 -1
  20. data/docs/messages/location_request.md +1 -1
  21. data/docs/messages/reaction.md +1 -1
  22. data/docs/messages/sticker.md +1 -1
  23. data/docs/messages/template.md +28 -1
  24. data/docs/messages/text.md +1 -1
  25. data/docs/messages/video.md +1 -1
  26. data/docs/schedule/README.md +183 -0
  27. data/docs/webhooks/README.md +20 -12
  28. data/docs/webhooks/business_username_updates.md +55 -0
  29. data/docs/webhooks/history.md +78 -15
  30. data/docs/webhooks/messages.md +141 -6
  31. data/docs/webhooks/smb_app_state_sync.md +50 -12
  32. data/docs/webhooks/smb_message_echoes.md +58 -21
  33. data/docs/webhooks/user_id_update.md +84 -0
  34. data/docs/webhooks/user_preferences.md +55 -16
  35. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  36. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  37. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  38. data/lib/ruby/whatsapp/business_phone_number/transport.rb +6 -5
  39. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  40. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  41. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  42. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  43. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  44. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  45. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  46. data/lib/ruby/whatsapp/business_phone_number.rb +9 -0
  47. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  48. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  49. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  50. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  51. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  52. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  53. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  54. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  55. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  56. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  57. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  58. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  59. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  60. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  61. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  62. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  63. data/lib/ruby/whatsapp/schedule.rb +15 -0
  64. data/lib/ruby/whatsapp/version.rb +1 -1
  65. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  66. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  67. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  68. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  69. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  70. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  71. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  72. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  73. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  74. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  75. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  76. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  77. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  78. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  79. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  80. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  81. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  82. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  83. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  84. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  85. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  86. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  87. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  88. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  89. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  90. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  91. metadata +38 -1
@@ -0,0 +1,183 @@
1
+ # Scheduling Campaigns
2
+
3
+ A **campaign schedule** sends an approved [template](../message_templates/README.md) to an
4
+ audience at a time you choose. `Whatsapp::Schedule` wraps the `schedules` edge, which
5
+ lists an account's schedules and creates new ones.
6
+
7
+ > **Schedules send templates; they don't create them.** The template behind `hsm_id`
8
+ > must already exist and be approved. Create and manage it with
9
+ > [`Whatsapp::MessageTemplates`](../message_templates/README.md).
10
+
11
+ Addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`), the same as
12
+ [template management](../message_templates/README.md) and
13
+ [subscribed apps](../subscribed_app/README.md).
14
+
15
+ ```ruby
16
+ Whatsapp.configure do |config|
17
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
18
+ config.waba_id = ENV.fetch("WHATSAPP_WABA_ID")
19
+ end
20
+
21
+ Whatsapp::Schedule::Create.call(
22
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
23
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
24
+ delivery_time: Time.now.to_i + 3600
25
+ ).id # => "9876543210"
26
+
27
+ Whatsapp::Schedule::List.call.map(&:name) # => ["Black Friday"]
28
+ ```
29
+
30
+ | Method | Request | Returns |
31
+ | --- | --- | --- |
32
+ | `List.call(client:, fields:, limit:, after:, before:)` | `GET /{waba_id}/schedules` | `Response::Collection` |
33
+ | `Create.call(hsm_id:, audience_id:, waba_cs_id:, name:, description:, delivery_time:, client:)` | `POST /{waba_id}/schedules` | `Response::Created` |
34
+
35
+ ## Creating
36
+
37
+ Every field is required:
38
+
39
+ | Field | Type | Meaning |
40
+ | --- | --- | --- |
41
+ | `hsm_id` | String | The approved template to send |
42
+ | `audience_id` | String | The audience to send it to |
43
+ | `waba_cs_id` | String | Meta: "WABA customer status ID (phone number)" |
44
+ | `name` | String | The schedule's name |
45
+ | `description` | String | The schedule's description |
46
+ | `delivery_time` | Integer | When to send, as a Unix timestamp |
47
+
48
+ The examples below share this hash:
49
+
50
+ ```ruby
51
+ attrs = {
52
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
53
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
54
+ delivery_time: 1_764_000_000,
55
+ }
56
+ ```
57
+
58
+ `delivery_time` must be an integer. For a `Time`, pass `time.to_i`:
59
+
60
+ ```ruby
61
+ Whatsapp::Schedule::Create.call(**attrs, delivery_time: Time.utc(2026, 11, 27, 9).to_i)
62
+ ```
63
+
64
+ All six are checked before the request, so a missing one costs nothing:
65
+
66
+ ```ruby
67
+ Whatsapp::Schedule::Create.call(**attrs, name: "")
68
+ # => ActiveModel::ValidationError: Validation failed: Name can't be blank
69
+
70
+ Whatsapp::Schedule::Create.call(**attrs, delivery_time: 1_764_000_000.5)
71
+ # => ActiveModel::ValidationError: Validation failed: Delivery time must be an integer
72
+ ```
73
+
74
+ Nothing beyond that is checked. Meta publishes no length limits and no rule that
75
+ `delivery_time` must be in the future; anything it rejects comes back as a
76
+ `Whatsapp::Schedule::Error`.
77
+
78
+ > `waba_cs_id` is passed through exactly as given. Meta's description doesn't say
79
+ > whether it means a phone number ID or the number itself.
80
+
81
+ To inspect the payload without sending it, build the object yourself:
82
+
83
+ ```ruby
84
+ Whatsapp::Schedule::Create.new(**attrs).serialize
85
+ # => { hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
86
+ # name: "Black Friday", description: "Black Friday promo to VIP customers",
87
+ # delivery_time: 1764000000 }
88
+ ```
89
+
90
+ ## Listing
91
+
92
+ ```ruby
93
+ schedules = Whatsapp::Schedule::List.call
94
+
95
+ schedules.map(&:name) # Collection is Enumerable
96
+ schedules.first.delivery_time # => 1764000000
97
+ schedules.first.status # => "SCHEDULED"
98
+ schedules.first.id # => nil (see below)
99
+ ```
100
+
101
+ ### Ask for `id` explicitly
102
+
103
+ **Meta's default fields leave out `id`.** The defaults are `name`, `description`,
104
+ `delivery_time`, and `status`. Request `id` with `fields:`, either as an Array or a
105
+ comma-joined String:
106
+
107
+ ```ruby
108
+ Whatsapp::Schedule::List.call(fields: Whatsapp::Schedule::List::Fields::ALL)
109
+ Whatsapp::Schedule::List.call(fields: %w[id name status])
110
+ Whatsapp::Schedule::List.call(fields: "id,name,status")
111
+ ```
112
+
113
+ `List::Fields::ALL` is a convenience, not a whitelist. A field Meta adds later works
114
+ without a gem release.
115
+
116
+ ### Paging
117
+
118
+ `limit:` sets the page size (Meta accepts 1–100). `after:` and `before:` take cursors.
119
+ Paging is exposed rather than followed automatically, so fetching every page is your
120
+ choice:
121
+
122
+ ```ruby
123
+ page = Whatsapp::Schedule::List.call(limit: 50)
124
+
125
+ while page.next_cursor
126
+ page = Whatsapp::Schedule::List.call(limit: 50, after: page.next_cursor)
127
+ end
128
+ ```
129
+
130
+ `Collection#paging` exposes both cursors (`paging.before`, `paging.after`). It is the
131
+ same Graph API paging object [template listing](../message_templates/README.md) returns.
132
+
133
+ ### Statuses
134
+
135
+ | Status | Predicate |
136
+ | --- | --- |
137
+ | `SCHEDULED` | `scheduled?` |
138
+ | `SENDING` | `sending?` |
139
+ | `COMPLETED` | `completed?` |
140
+ | `FAILED` | `failed?` |
141
+
142
+ ```ruby
143
+ Whatsapp::Schedule::List.call.select(&:failed?).map(&:name)
144
+ schedule.status == Whatsapp::Schedule::Statuses::SCHEDULED
145
+ ```
146
+
147
+ `status` is the raw string Meta sent and is never validated, so a status Meta adds later
148
+ still comes through. Every predicate just answers `false` for it.
149
+
150
+ ## Response shapes
151
+
152
+ | Class | Holds | From |
153
+ | --- | --- | --- |
154
+ | `Response::Collection` | `data: [Node]`, `paging`, `next_cursor`, `Enumerable` | `List` |
155
+ | `Response::Node` | `id`, `name`, `description`, `delivery_time`, `status` | each element of `List` |
156
+ | `Response::Created` | `id` | `Create` |
157
+
158
+ Every attribute tolerates being absent, since Meta returns only the fields you asked for.
159
+
160
+ ## Errors
161
+
162
+ ```ruby
163
+ Whatsapp::Schedule::List.call
164
+ # => Whatsapp::Schedule::Error: waba_id is required for schedules;
165
+ # set it via Whatsapp.configure or Client.new(waba_id:)
166
+
167
+ # non-2xx response
168
+ # => Whatsapp::Schedule::Error: Failed to create schedule: 403 Forbidden - {...}
169
+ ```
170
+
171
+ Every API failure raises `Whatsapp::Schedule::Error`, never the generic
172
+ `Whatsapp::RequestError`. Local validation raises `ActiveModel::ValidationError` before
173
+ any request. See [../errors.md](../errors.md).
174
+
175
+ ## Targeting a different account
176
+
177
+ ```ruby
178
+ Whatsapp::Schedule::List.call(client: Whatsapp::Client.new(waba_id: "OTHER_WABA_ID"))
179
+ ```
180
+
181
+ ---
182
+
183
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/schedules-api>
@@ -1,9 +1,14 @@
1
1
  # Webhooks
2
2
 
3
- Meta pushes inbound messages, delivery statuses, and ~18 other account and template
3
+ Meta pushes inbound messages, delivery statuses, and 20 other account and template
4
4
  notifications to a callback URL you register. This directory covers the *inbound*
5
5
  direction — the opposite of everything under [../messages/](../messages/README.md).
6
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
+
7
12
  > **Two different things.** `Whatsapp::Webhook` deserializes notifications once Meta is
8
13
  > already sending them. Turning that delivery **on** for a WhatsApp Business Account is
9
14
  > [`Whatsapp::SubscribedApp`](../subscribed_app/README.md). You need both.
@@ -128,7 +133,7 @@ change.field # => "messages"
128
133
  change.value # => #<Whatsapp::Webhook::Messages ...>
129
134
  ```
130
135
 
131
- `change.field` is one of Meta's 19 documented field names; `change.value` is that
136
+ `change.field` is one of Meta's 21 documented field names; `change.value` is that
132
137
  field's deserialized payload, resolved through a frozen `Change::FIELDS` registry —
133
138
  **never `const_get` on the field name**, since it comes straight off the internet.
134
139
 
@@ -142,17 +147,20 @@ change.value["some_key"] # reach into the raw hash
142
147
  change.value.to_h # the whole thing
143
148
  ```
144
149
 
145
- ## The 19 fields
150
+ ## The 21 fields
146
151
 
147
152
  | Field | Page | Confidence |
148
153
  | --- | --- | --- |
149
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 |
150
159
  | `account_alerts` | [account_alerts.md](account_alerts.md) | Moderate |
151
160
  | `account_review_update` | [account_review_update.md](account_review_update.md) | High |
152
161
  | `account_update` | [account_update.md](account_update.md) | Moderate |
153
162
  | `automatic_events` | [automatic_events.md](automatic_events.md) | Low |
154
163
  | `business_capability_update` | [business_capability_update.md](business_capability_update.md) | Moderate |
155
- | `history` | [history.md](history.md) | Low |
156
164
  | `message_template_components_update` | [message_template_components_update.md](message_template_components_update.md) | Moderate |
157
165
  | `message_template_quality_update` | [message_template_quality_update.md](message_template_quality_update.md) | Moderate-high |
158
166
  | `message_template_status_update` | [message_template_status_update.md](message_template_status_update.md) | High |
@@ -161,16 +169,16 @@ change.value.to_h # the whole thing
161
169
  | `phone_number_name_update` | [phone_number_name_update.md](phone_number_name_update.md) | Moderate-high |
162
170
  | `phone_number_quality_update` | [phone_number_quality_update.md](phone_number_quality_update.md) | Moderate |
163
171
  | `security` | [security.md](security.md) | Low |
164
- | `smb_app_state_sync` | [smb_app_state_sync.md](smb_app_state_sync.md) | Low-moderate |
165
- | `smb_message_echoes` | [smb_message_echoes.md](smb_message_echoes.md) | Moderate |
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 |
166
174
  | `template_category_update` | [template_category_update.md](template_category_update.md) | Moderate-high |
167
- | `user_preferences` | [user_preferences.md](user_preferences.md) | Moderate |
168
175
 
169
- > **On confidence.** Only `messages` has a Meta-published JSON example. The other 18
170
- > field classes are reconstructed from the one-line descriptions in Meta's docs plus
171
- > general Cloud API knowledge. They are real, tested classes — but validate one against
172
- > a real payload (App Dashboard → "send test payload") before depending on it in
173
- > production.
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.
174
182
 
175
183
  ## Dispatching
176
184
 
@@ -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>
@@ -4,27 +4,81 @@ Conversation history synced from the WhatsApp Business app when a number moves o
4
4
  Cloud API. It arrives in chunks, so `phase`, `chunk_order`, and `progress` are how you
5
5
  track completeness.
6
6
 
7
- `Whatsapp::Webhook::History` · confidence: **low**
7
+ `Whatsapp::Webhook::History` · confidence: **confirmed**
8
8
 
9
9
  ## Payload
10
10
 
11
11
  ```json
12
12
  { "field": "history",
13
- "value": { "metadata": { "phase": 1, "chunk_order": 1, "progress": 50 },
14
- "threads": [{ "id": "thread.1" }] } }
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
+ ] } }
15
34
  ```
16
35
 
17
- ## Accessors
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
18
53
 
19
54
  | Accessor | Meaning |
20
55
  | --- | --- |
21
- | `phase` | Sync phase (dug from `value.metadata`) |
22
- | `chunk_order` | This chunk's position (dug from `value.metadata`) |
23
- | `progress` | Percent complete, 0–100 (dug from `value.metadata`) |
24
- | `threads` | **Raw Array** of thread hashes |
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>` |
25
60
 
26
- Note the three scalars are flattened up from the nested `metadata` object, while
27
- `threads` is left raw — its element shape is undocumented.
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
+ ```
28
82
 
29
83
  ## Handling it
30
84
 
@@ -32,13 +86,22 @@ Note the three scalars are flattened up from the nested `metadata` object, while
32
86
  when "history"
33
87
  sync = change.value
34
88
 
35
- HistoryChunk.create!(phase: sync.phase, order: sync.chunk_order, threads: sync.threads)
36
- Importer.finish! if sync.progress == 100
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
37
98
  ```
38
99
 
39
- > **Best-effort schema, low confidence.** Validate against a real payload before
40
- > depending on it in production.
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.
41
104
 
42
105
  ---
43
106
 
44
- **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
107
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
@@ -1,9 +1,12 @@
1
1
  # `messages`
2
2
 
3
3
  Inbound customer messages and outbound delivery statuses — the field you will spend
4
- almost all your time on, and the only one with a Meta-published JSON schema.
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)).
5
7
 
6
- `Whatsapp::Webhook::Messages`
8
+ `Whatsapp::Webhook::Messages` · confidence: **confirmed** — Meta publishes a real JSON
9
+ example
7
10
 
8
11
  ## A full notification
9
12
 
@@ -49,7 +52,7 @@ value.statuses # => []
49
52
  | --- | --- |
50
53
  | `messaging_product` | `"whatsapp"` |
51
54
  | `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
52
- | `contacts` | `Array<Contact>` — `profile_name`, `wa_id` |
55
+ | `contacts` | `Array<Contact>` — `profile_name`, `username`, `wa_id`, `user_id`, `parent_user_id` |
53
56
  | `messages` | `Array<Message::*>` |
54
57
  | `statuses` | `Array<Status>` |
55
58
 
@@ -60,6 +63,32 @@ both accessors always return an array, so you can iterate either without a nil c
60
63
  > different thing from [`Message::Contacts`](#contacts) below, which is a contact
61
64
  > *card* someone shared with you.
62
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
+
63
92
  ---
64
93
 
65
94
  ## Message types
@@ -72,7 +101,10 @@ All message classes share this envelope from `Message::Base`:
72
101
 
73
102
  | Accessor | Meaning |
74
103
  | --- | --- |
75
- | `from` | The customer's WhatsApp ID |
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 |
76
108
  | `id` | The WAMID — pass it to [`mark_message_as_read!`](../messages/mark_message_as_read.md) |
77
109
  | `timestamp` | Unix seconds, as a String |
78
110
  | `type` | `"text"`, `"image"`, … |
@@ -169,6 +201,15 @@ card.phones.first.phone # => "+1"
169
201
  Nested objects mirror the outbound [contacts message](../messages/contacts.md)
170
202
  field-for-field: `Name`, `Phone`, `Email`, `Address`, `Org`, `Url`, plus `birthday`.
171
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
+
172
213
  ### Interactive
173
214
 
174
215
  A tap on [reply buttons or a list](../messages/interactive.md).
@@ -253,6 +294,14 @@ message.change_type # => "customer_changed_number"
253
294
 
254
295
  Note `change_type`, not `type` — `type` is taken by the message envelope.
255
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
+
256
305
  ### Reaction
257
306
 
258
307
  ```json
@@ -264,6 +313,52 @@ message.message_id # => "wamid.OLD" — the message being reacted to
264
313
  message.emoji # => "👍" — empty String when a reaction is removed
265
314
  ```
266
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
+
267
362
  ### Unknown
268
363
 
269
364
  The fallback for a type this gem doesn't model — including anything Meta flags as
@@ -289,7 +384,10 @@ Present when the customer replied to, or forwarded, a message.
289
384
 
290
385
  ```ruby
291
386
  message.context&.id # => "wamid.HBg..." — the message replied to
292
- message.context&.from # => the sender of that message
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
293
391
  message.context&.forwarded # => true | false
294
392
  message.context&.frequently_forwarded # => true | false
295
393
  ```
@@ -297,6 +395,12 @@ message.context&.frequently_forwarded # => true | false
297
395
  `context` is `nil` — not an all-nil object — when the key is absent, so `&.` is the
298
396
  right idiom.
299
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
+
300
404
  ## Referral
301
405
 
302
406
  Present when the conversation started from a click-to-WhatsApp ad or post.
@@ -336,15 +440,46 @@ status = value.statuses.first
336
440
  status.id # => "wamid.HBg" — matches what send! returned
337
441
  status.status # => "delivered" — sent | delivered | read | failed
338
442
  status.timestamp # => "1750263773"
339
- status.recipient_id # => "16505551234"
443
+ status.recipient_id # => "16505551234", or nil once sent to a BSUID (see below)
340
444
  ```
341
445
 
342
446
  | Accessor | Type |
343
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 |
344
450
  | `conversation` | `Status::Conversation` — `id`, `origin_type`, `expiration_timestamp` |
345
451
  | `pricing` | `Status::Pricing` — `billable`, `pricing_model`, `category` |
346
452
  | `errors` | `Array<Webhook::Error>` — populated on `"failed"` |
347
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
+
348
483
  ```ruby
349
484
  status.conversation.id # => "6ceb9d9"
350
485
  status.conversation.origin_type # => "service" | marketing | utility | authentication