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