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,40 @@
1
+ # `payment_configuration_update`
2
+
3
+ A payment provider configuration changed — the plumbing behind in-chat payments,
4
+ currently a regional feature (India, Brazil, Singapore).
5
+
6
+ `Whatsapp::Webhook::PaymentConfigurationUpdate` · confidence: **moderate**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "payment_configuration_update",
12
+ "value": { "configuration_name": "default",
13
+ "provider_name": "razorpay",
14
+ "provider_mid": "mid.1",
15
+ "status": "ACTIVE" } }
16
+ ```
17
+
18
+ ## Accessors
19
+
20
+ | Accessor | Meaning |
21
+ | --- | --- |
22
+ | `configuration_name` | Your name for the configuration |
23
+ | `provider_name` | e.g. `razorpay`, `payu` |
24
+ | `provider_mid` | The provider's merchant ID |
25
+ | `status` | e.g. `ACTIVE`, `INACTIVE` |
26
+
27
+ ## Handling it
28
+
29
+ ```ruby
30
+ when "payment_configuration_update"
31
+ config = change.value
32
+ PaymentConfig.find_by(name: config.configuration_name)&.update!(status: config.status)
33
+ ```
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,44 @@
1
+ # `phone_number_name_update`
2
+
3
+ The verdict on a display-name change you requested for a phone number. The display
4
+ name is what customers see as the sender, and Meta reviews every change.
5
+
6
+ `Whatsapp::Webhook::PhoneNumberNameUpdate` · confidence: **moderate-high**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "phone_number_name_update",
12
+ "value": { "phone_number": "15550783881",
13
+ "decision": "REJECTED",
14
+ "requested_verified_name": "Acme Corp",
15
+ "rejection_reason": "INCLUDES_UNSUPPORTED_CHARACTERS" } }
16
+ ```
17
+
18
+ ## Accessors
19
+
20
+ | Accessor | Meaning |
21
+ | --- | --- |
22
+ | `phone_number` | The number affected |
23
+ | `decision` | `APPROVED` or `REJECTED` |
24
+ | `requested_verified_name` | The name you asked for |
25
+ | `rejection_reason` | Why it was refused, when it was |
26
+
27
+ ## Handling it
28
+
29
+ ```ruby
30
+ when "phone_number_name_update"
31
+ update = change.value
32
+
33
+ if update.decision == "REJECTED"
34
+ Ops.notify("Display name #{update.requested_verified_name.inspect} rejected: " \
35
+ "#{update.rejection_reason}")
36
+ end
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,40 @@
1
+ # `phone_number_quality_update`
2
+
3
+ A phone number's quality rating or messaging tier changed. A downgrade cuts how many
4
+ conversations that number may start per day, so this is worth alerting on.
5
+
6
+ `Whatsapp::Webhook::PhoneNumberQualityUpdate` · confidence: **moderate**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "phone_number_quality_update",
12
+ "value": { "display_phone_number": "15550783881",
13
+ "event": "DOWNGRADE",
14
+ "current_limit": "TIER_50" } }
15
+ ```
16
+
17
+ ## Accessors
18
+
19
+ | Accessor | Meaning |
20
+ | --- | --- |
21
+ | `display_phone_number` | The number affected |
22
+ | `event` | e.g. `UPGRADE`, `DOWNGRADE`, `FLAGGED`, `UNFLAGGED` |
23
+ | `current_limit` | e.g. `TIER_50`, `TIER_250`, `TIER_1K`, `TIER_UNLIMITED` |
24
+
25
+ ## Handling it
26
+
27
+ ```ruby
28
+ when "phone_number_quality_update"
29
+ quality = change.value
30
+
31
+ Ops.alert("#{quality.display_phone_number} downgraded to #{quality.current_limit}") \
32
+ if quality.event == "DOWNGRADE"
33
+ ```
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,43 @@
1
+ # `security`
2
+
3
+ Security events on a phone number — chiefly two-step verification being enabled or
4
+ disabled.
5
+
6
+ `Whatsapp::Webhook::Security` · confidence: **low**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "security",
12
+ "value": { "display_phone_number": "15550783881",
13
+ "event": "TWO_STEP_VERIFICATION_ENABLED",
14
+ "requester": "16505551234" } }
15
+ ```
16
+
17
+ ## Accessors
18
+
19
+ | Accessor | Meaning |
20
+ | --- | --- |
21
+ | `display_phone_number` | The number affected |
22
+ | `event` | e.g. `TWO_STEP_VERIFICATION_ENABLED` / `..._DISABLED` |
23
+ | `requester` | Who initiated the change |
24
+
25
+ ## Handling it
26
+
27
+ ```ruby
28
+ when "security"
29
+ event = change.value
30
+ AuditLog.create!(kind: event.event, subject: event.display_phone_number,
31
+ actor: event.requester)
32
+ ```
33
+
34
+ The two-step verification PIN this concerns is the same one
35
+ [`Register`](../business_phone_number/README.md#registering) consumes.
36
+
37
+ > **Best-effort schema, low confidence.** Meta describes this field in one line and
38
+ > publishes no JSON example. Validate against a real payload before depending on it in
39
+ > production.
40
+
41
+ ---
42
+
43
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,91 @@
1
+ # `smb_app_state_sync`
2
+
3
+ Contact-book and app-state changes synced from the WhatsApp Business app. Arrives
4
+ alongside [`history`](history.md) when a number moves onto Cloud API.
5
+
6
+ `Whatsapp::Webhook::SmbAppStateSync` · confidence: **high**
7
+
8
+ ## Payload
9
+
10
+ ```json
11
+ { "field": "smb_app_state_sync",
12
+ "value": {
13
+ "messaging_product": "whatsapp",
14
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
15
+ "state_sync": [
16
+ { "type": "contact",
17
+ "contact": { "full_name": "Pablo Morales",
18
+ "first_name": "Pablo",
19
+ "phone_number": "16505551234",
20
+ "user_id": "US.13491208655302741918",
21
+ "username": "pablomorales" },
22
+ "action": "add",
23
+ "metadata": { "timestamp": "1750030073" } }
24
+ ] } }
25
+ ```
26
+
27
+ ## Accessors
28
+
29
+ | Accessor | Type |
30
+ | --- | --- |
31
+ | `messaging_product` | `"whatsapp"` |
32
+ | `metadata` | `Metadata` — the business phone number whose address book this is |
33
+ | `state_sync` | `Array<SmbAppStateSync::StateSync>` |
34
+ | `raw` | The untouched `value` hash |
35
+
36
+ ### `StateSync`
37
+
38
+ | Accessor | Meaning |
39
+ | --- | --- |
40
+ | `type` | `"contact"` is the only documented kind |
41
+ | `action` | e.g. `add`, `update`, `remove` |
42
+ | `contact` | An `SmbAppStateSync::Contact`, or `nil` |
43
+ | `timestamp` | Flattened up from the entry's own `metadata.timestamp` |
44
+
45
+ The entry's `metadata` is **not** the value-level `Metadata` — it carries only a
46
+ timestamp, so the wrapper is dropped rather than typed.
47
+
48
+ ### `Contact`
49
+
50
+ | Accessor | Meaning |
51
+ | --- | --- |
52
+ | `full_name` | |
53
+ | `first_name` | |
54
+ | `phone_number` | Omitted once the contact adopts a username |
55
+ | `user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) |
56
+ | `parent_user_id` | Their parent BSUID, present only if enrolled |
57
+ | `username` | Set only if adopted. Read **flat** here, unlike `Contact#username` on `messages`, which Meta nests under `profile` |
58
+
59
+ This is the gem's third `Contact`, and none are interchangeable:
60
+ [`Webhook::Contact`](messages.md#value-accessors) is a notification's sender identity,
61
+ [`Message::Contacts::Contact`](messages.md#contacts) is a shared contact *card*, and this
62
+ is a device address-book row.
63
+
64
+ ## Handling it
65
+
66
+ ```ruby
67
+ when "smb_app_state_sync"
68
+ change.value.state_sync.each do |sync|
69
+ next unless sync.type == "contact"
70
+
71
+ case sync.action
72
+ when "add", "update"
73
+ Contact.upsert_from_meta(bsuid: sync.contact.user_id, phone: sync.contact.phone_number,
74
+ name: sync.contact.full_name, username: sync.contact.username)
75
+ when "remove"
76
+ Contact.find_by(bsuid: sync.contact.user_id)&.destroy
77
+ end
78
+ end
79
+ ```
80
+
81
+ > **Changed in 0.6.0.** `StateSync#contact` used to be a raw Hash, because Meta had
82
+ > published no shape for it. It is now a typed `Contact`, so `sync.contact["phone_number"]`
83
+ > becomes `sync.contact.phone_number`, and an entry carrying no contact returns `nil`
84
+ > rather than `{}`.
85
+
86
+ > The `state_sync` key itself is still inferred from the field name rather than confirmed
87
+ > by a published example — hence `raw`, so a drifted key leaves a trace.
88
+
89
+ ---
90
+
91
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
@@ -0,0 +1,91 @@
1
+ # `smb_message_echoes`
2
+
3
+ Echoes of messages sent **from the WhatsApp Business app** rather than through the API.
4
+ Without this, a number used by both your code and a human agent in the app would show
5
+ you only half the conversation. Messages the business *revoked* or *edited* from the app
6
+ arrive here too.
7
+
8
+ `Whatsapp::Webhook::SmbMessageEchoes` · confidence: **high**
9
+
10
+ ## Payload
11
+
12
+ ```json
13
+ { "field": "smb_message_echoes",
14
+ "value": {
15
+ "messaging_product": "whatsapp",
16
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
17
+ "contacts": [
18
+ { "profile": { "username": "pablomorales" },
19
+ "wa_id": "16505551234",
20
+ "user_id": "US.13491208655302741918" }
21
+ ],
22
+ "message_echoes": [
23
+ { "from": "15550783881",
24
+ "to": "16505551234",
25
+ "to_user_id": "US.13491208655302741918",
26
+ "id": "wamid.HBg",
27
+ "timestamp": "1750030073",
28
+ "type": "text",
29
+ "text": { "body": "Thanks for shopping with us!" } }
30
+ ] } }
31
+ ```
32
+
33
+ ## Accessors
34
+
35
+ | Accessor | Type |
36
+ | --- | --- |
37
+ | `messaging_product` | `"whatsapp"` |
38
+ | `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
39
+ | `contacts` | `Array<Contact>` — the customers these went to |
40
+ | `message_echoes` | `Array<SmbMessageEchoes::Echo>` |
41
+ | `raw` | The untouched `value` hash |
42
+
43
+ ### `Echo`
44
+
45
+ | Accessor | Meaning |
46
+ | --- | --- |
47
+ | `to` | The customer's phone number, or `nil` once they adopt a username |
48
+ | `to_user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) |
49
+ | `to_parent_user_id` | Their parent BSUID, present only if enrolled |
50
+ | `message` | The echoed `Message::*`, dispatched by `type` |
51
+
52
+ `Echo#message` goes through the same `Message.deserialize` dispatcher as
53
+ [`messages`](messages.md), so an echo is a fully typed `Message::Text`, `Message::Image`,
54
+ `Message::Revoke`, `Message::Edit`, and so on — with the same accessors.
55
+
56
+ Why a wrapper rather than three more accessors on `Message::Base`? Because an *inbound*
57
+ message can never carry a recipient. Putting `to`/`to_user_id`/`to_parent_user_id` on the
58
+ envelope shared by every message type would hand fifteen classes three permanently-`nil`
59
+ readers, and would turn the `NoMethodError` that currently tells you `message.to` is
60
+ meaningless into a silent `nil`.
61
+
62
+ ## Handling it
63
+
64
+ ```ruby
65
+ when "smb_message_echoes"
66
+ change.value.message_echoes.each do |echo|
67
+ # The customer may have no phone number; the BSUID always identifies them
68
+ conversation = Conversation.for(bsuid: echo.to_user_id, wa_id: echo.to)
69
+
70
+ conversation.messages.create!(
71
+ wamid: echo.message.id,
72
+ direction: :outbound,
73
+ source: :business_app,
74
+ body: echo.message.try(:body)
75
+ )
76
+ end
77
+ ```
78
+
79
+ Note `echo.message.from` is *your* business number, not the customer's — these are
80
+ messages you sent. The customer is on `echo.to*`.
81
+
82
+ > **Changed in 0.6.0.** `message_echoes` used to be an `Array<Message::*>` directly;
83
+ > each entry is now an `Echo` wrapping that message, so `echo.body` becomes
84
+ > `echo.message.body`. The wrapper is what carries the new recipient identifiers.
85
+
86
+ > The `message_echoes` key itself is still inferred from the field name rather than
87
+ > confirmed by a published example — hence `raw`, so a drifted key leaves a trace.
88
+
89
+ ---
90
+
91
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids
@@ -0,0 +1,52 @@
1
+ # `template_category_update`
2
+
3
+ Meta reclassified a template — most often moving something you filed as `UTILITY` into
4
+ `MARKETING`. This matters because category determines **how you are billed** and
5
+ whether the message is subject to marketing opt-outs.
6
+
7
+ `Whatsapp::Webhook::TemplateCategoryUpdate` · confidence: **moderate-high**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "template_category_update",
13
+ "value": {
14
+ "message_template_id": "123",
15
+ "message_template_name": "order_confirmation",
16
+ "message_template_language": "en_US",
17
+ "previous_category": "MARKETING",
18
+ "new_category": "UTILITY",
19
+ "correct_category": "UTILITY"
20
+ } }
21
+ ```
22
+
23
+ ## Accessors
24
+
25
+ | Accessor | Meaning |
26
+ | --- | --- |
27
+ | `message_template_id` | The template's ID |
28
+ | `message_template_name` | Its name |
29
+ | `message_template_language` | Its locale |
30
+ | `previous_category` | What it was |
31
+ | `new_category` | What it is now |
32
+ | `correct_category` | What Meta believes it should be |
33
+
34
+ ## Handling it
35
+
36
+ ```ruby
37
+ when "template_category_update"
38
+ update = change.value
39
+
40
+ MessageTemplate
41
+ .find_by(meta_id: update.message_template_id)
42
+ &.update!(category: update.new_category)
43
+
44
+ Billing.recheck!(update.message_template_name) if update.new_category == "MARKETING"
45
+ ```
46
+
47
+ > **Best-effort schema.** Meta publishes no JSON example for this field. Validate
48
+ > against a real payload before depending on it in production.
49
+
50
+ ---
51
+
52
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview
@@ -0,0 +1,84 @@
1
+ # `user_id_update`
2
+
3
+ A WhatsApp user's business-scoped user ID (BSUID) changed — most commonly because they
4
+ changed their phone number (see the [`system` message type's `user_changed_user_id`
5
+ event](messages.md#system) for the same event surfaced on the `messages` field).
6
+
7
+ `Whatsapp::Webhook::UserIdUpdate` · confidence: **confirmed** — Meta publishes a real
8
+ JSON example
9
+
10
+ ## Payload
11
+
12
+ ```json
13
+ { "field": "user_id_update",
14
+ "value": {
15
+ "messaging_product": "whatsapp",
16
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
17
+ "contacts": [{ "profile": { "name": "Jane Doe" }, "wa_id": "16505551234" }],
18
+ "user_id_update": [
19
+ {
20
+ "wa_id": "16505551234",
21
+ "detail": "User id for Jane Doe has been updated.",
22
+ "user_id": { "previous": "US.OLD", "current": "US.NEW" },
23
+ "parent_user_id": { "previous": "US.ENT.OLD", "current": "US.ENT.NEW" },
24
+ "timestamp": "1750030073"
25
+ }
26
+ ]
27
+ } }
28
+ ```
29
+
30
+ `parent_user_id` is present only if you have enrolled in [parent
31
+ BSUIDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#parent-business-scoped-user-ids).
32
+
33
+ ## Accessors
34
+
35
+ | Accessor | Meaning |
36
+ | --- | --- |
37
+ | `messaging_product` | `"whatsapp"` |
38
+ | `metadata` | `Metadata` — `display_phone_number`, `phone_number_id` |
39
+ | `contacts` | `Array<Contact>` — the [same BSUID-aware `Contact`](messages.md#business-scoped-user-ids-bsuid) used by `messages` |
40
+ | `updates` | `Array<UserIdUpdate::Entry>` |
41
+
42
+ ### `Entry`
43
+
44
+ | Accessor | Meaning |
45
+ | --- | --- |
46
+ | `wa_id` | The user's phone number, if available |
47
+ | `detail` | Human-readable description of the change |
48
+ | `user_id` | `UserIdUpdate::IdChange` or `nil` — the user's previous and current BSUID |
49
+ | `parent_user_id` | `UserIdUpdate::IdChange` or `nil` — previous and current parent BSUID |
50
+ | `timestamp` | Unix seconds, as a String |
51
+
52
+ ### `IdChange`
53
+
54
+ `Whatsapp::Webhook::UserIdUpdate::IdChange` — a sibling of `Entry`, not nested inside it.
55
+ One class serves both `user_id` and `parent_user_id`, since they are the same shape, and
56
+ each is `nil` rather than an all-nil object when its key is absent.
57
+
58
+ | Accessor | Meaning |
59
+ | --- | --- |
60
+ | `previous` | The BSUID before the change |
61
+ | `current` | The BSUID after the change |
62
+
63
+ ## Handling it
64
+
65
+ ```ruby
66
+ when "user_id_update"
67
+ change.value.updates.each do |update|
68
+ next unless update.user_id # nil when the entry carries only a parent BSUID change
69
+
70
+ user = User.find_by(bsuid: update.user_id.previous)
71
+ next Rails.logger.warn("[whatsapp] unknown BSUID #{update.user_id.previous}") unless user
72
+
73
+ user.update!(bsuid: update.user_id.current)
74
+ end
75
+ ```
76
+
77
+ Report a miss rather than letting `&.update!` swallow it — a rotation you never applied
78
+ leaves you writing to a dead BSUID with no diagnostics. Log or alert; do not `raise`, or
79
+ Meta will retry a request that can never succeed. `change.value.raw` carries the untouched
80
+ payload if you need to see what actually arrived.
81
+
82
+ ---
83
+
84
+ **Meta docs:** <https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids#user_id_update-webhooks>
@@ -0,0 +1,87 @@
1
+ # `user_preferences`
2
+
3
+ A customer changed their marketing preferences — most importantly, opting **out**.
4
+ Respecting this is not optional: continuing to send marketing to someone who stopped
5
+ you damages your quality rating and breaches Meta's policy.
6
+
7
+ `Whatsapp::Webhook::UserPreferences` · confidence: **confirmed**
8
+
9
+ ## Payload
10
+
11
+ ```json
12
+ { "field": "user_preferences",
13
+ "value": {
14
+ "messaging_product": "whatsapp",
15
+ "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
16
+ "contacts": [
17
+ { "profile": { "name": "Pablo M.", "username": "pablomorales" },
18
+ "wa_id": "16505551234",
19
+ "user_id": "US.13491208655302741918" }
20
+ ],
21
+ "user_preferences": [
22
+ { "wa_id": "16505551234",
23
+ "user_id": "US.13491208655302741918",
24
+ "detail": "User requested to stop marketing messages",
25
+ "category": "marketing_messages",
26
+ "value": "stop",
27
+ "timestamp": 1750030073 }
28
+ ] } }
29
+ ```
30
+
31
+ Note the nesting: the entries sit under a repeat of the field's own name, the same shape
32
+ [`user_id_update`](user_id_update.md) uses.
33
+
34
+ ## Value accessors
35
+
36
+ | Accessor | Type |
37
+ | --- | --- |
38
+ | `messaging_product` | `String` — always `"whatsapp"` |
39
+ | `metadata` | `Metadata` — the business phone number this arrived on |
40
+ | `contacts` | `Array<Contact>` — `profile_name`, `username`, `wa_id`, `user_id`, `parent_user_id` |
41
+ | `preferences` | `Array<UserPreferences::Entry>` |
42
+ | `raw` | The untouched `value` hash |
43
+
44
+ ## Entry accessors
45
+
46
+ | Accessor | Meaning |
47
+ | --- | --- |
48
+ | `wa_id` | The customer's phone number, or `nil` once they adopt a username |
49
+ | `user_id` | Their [BSUID](messages.md#business-scoped-user-ids-bsuid) — the identifier that is always there |
50
+ | `parent_user_id` | Their parent BSUID, present only if enrolled |
51
+ | `detail` | Human-readable description |
52
+ | `category` | `"marketing_messages"` is the only documented value |
53
+ | `value` | `"stop"` or `"resume"` |
54
+ | `timestamp` | Unix seconds. Meta sends a **number** here, unlike the String timestamps on `messages` |
55
+
56
+ > `Entry#value` sits inside the change's own `value` hash, so the full path reads
57
+ > `change.value.preferences.first.value`. Meta's naming, not ours.
58
+
59
+ ## Handling it
60
+
61
+ An opt-out from a username adopter may carry no phone number at all, so key off the
62
+ BSUID and fall back to `wa_id` — not the other way round:
63
+
64
+ ```ruby
65
+ when "user_preferences"
66
+ change.value.preferences.each do |preference|
67
+ customer =
68
+ Customer.find_by(bsuid: preference.user_id) ||
69
+ Customer.find_by(wa_id: preference.wa_id)
70
+
71
+ customer&.update!(
72
+ marketing_opt_in: preference.value != "stop",
73
+ marketing_opt_in_changed_at: Time.at(preference.timestamp.to_i)
74
+ )
75
+ end
76
+ ```
77
+
78
+ Gate every `MARKETING`-category [template send](../messages/template.md) on this flag.
79
+ `UTILITY` and `AUTHENTICATION` templates are unaffected.
80
+
81
+ > **Changed in 0.6.0.** This field used to expose `wa_id`/`detail`/`value`/`timestamp`
82
+ > directly on `change.value`. Meta nests them one level deeper than the gem assumed, so
83
+ > those accessors could never return anything but `nil`; they are now on `Entry`.
84
+
85
+ ---
86
+
87
+ **Meta docs:** https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids