ruby-whatsapp 0.4.0 → 0.4.2
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 +9 -0
- data/README.md +276 -634
- data/docs/README.md +73 -0
- data/docs/business_phone_number/README.md +232 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/configuration.md +172 -0
- data/docs/errors.md +208 -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 +179 -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 +135 -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 +281 -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 +166 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +209 -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/history.md +44 -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 +385 -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 +53 -0
- data/docs/webhooks/smb_message_echoes.md +54 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_preferences.md +48 -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/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 +11 -12
- data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- metadata +63 -1
data/README.md
CHANGED
|
@@ -1,450 +1,229 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">ruby-whatsapp</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>A small, dependency-light Ruby client for the Meta WhatsApp Cloud API.</strong>
|
|
5
|
+
</p>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/ruby-whatsapp"><img alt="Gem Version" src="https://img.shields.io/gem/v/ruby-whatsapp.svg"></a>
|
|
9
|
+
<a href="https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml"><img alt="Build Status" src="https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml/badge.svg"></a>
|
|
10
|
+
<a href="https://rubygems.org/gems/ruby-whatsapp"><img alt="Downloads" src="https://img.shields.io/gem/dt/ruby-whatsapp.svg"></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
|
|
12
|
+
<a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg"></a>
|
|
13
|
+
</p>
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- [Installation](#installation)
|
|
12
|
-
- [Configuration](#configuration)
|
|
13
|
-
- [Quick Start](#quick-start)
|
|
14
|
-
- [Features](#features)
|
|
15
|
-
- [Sending Messages](#sending-messages)
|
|
16
|
-
- [Text](#text)
|
|
17
|
-
- [Image](#image)
|
|
18
|
-
- [Video](#video)
|
|
19
|
-
- [Audio](#audio)
|
|
20
|
-
- [Document](#document)
|
|
21
|
-
- [Sticker](#sticker)
|
|
22
|
-
- [Reaction](#reaction)
|
|
23
|
-
- [Location](#location)
|
|
24
|
-
- [Contacts](#contacts)
|
|
25
|
-
- [Address](#address)
|
|
26
|
-
- [Location Request](#location-request)
|
|
27
|
-
- [Template](#template)
|
|
28
|
-
- [Interactive](#interactive)
|
|
29
|
-
- [Mark Message As Read](#mark-message-as-read)
|
|
30
|
-
- [Handling Responses](#handling-responses)
|
|
31
|
-
- [Media](#media)
|
|
32
|
-
- [Managing Templates](#managing-templates)
|
|
33
|
-
- [Managing Subscribed Apps](#managing-subscribed-apps)
|
|
34
|
-
- [Registering Business Phone Numbers](#registering-business-phone-numbers)
|
|
35
|
-
- [Webhooks](#webhooks)
|
|
36
|
-
- [Development](#development)
|
|
37
|
-
- [Contributing](#contributing)
|
|
38
|
-
- [License](#license)
|
|
39
|
-
|
|
40
|
-
## Installation
|
|
41
|
-
|
|
42
|
-
Add to your Gemfile:
|
|
43
|
-
|
|
44
|
-
```ruby
|
|
45
|
-
gem "ruby-whatsapp"
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Then run `bundle install`. Or install directly:
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
gem install ruby-whatsapp
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Configuration
|
|
55
|
-
|
|
56
|
-
```ruby
|
|
57
|
-
Whatsapp.configure do |config|
|
|
58
|
-
config.api_key = ENV.fetch("WHATSAPP_TOKEN") # a Meta system-user / app access token
|
|
59
|
-
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
|
|
60
|
-
# optional overrides:
|
|
61
|
-
# config.version = "v24.0"
|
|
62
|
-
# config.host = "https://graph.facebook.com"
|
|
63
|
-
# config.waba_id = ENV.fetch("WHATSAPP_WABA_ID") # for template management, see below
|
|
64
|
-
# config.verify_token = ENV.fetch("WHATSAPP_VERIFY_TOKEN") # for webhooks, see below
|
|
65
|
-
# config.app_secret = ENV.fetch("WHATSAPP_APP_SECRET") # for webhooks, see below
|
|
66
|
-
end
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect` and `Client#inspect`, so they will not leak into logs or error reports.
|
|
15
|
+
---
|
|
70
16
|
|
|
71
|
-
|
|
17
|
+
Every message type the Cloud API supports — text, media, location, contacts,
|
|
18
|
+
templates, and the full family of interactive messages — is modeled as its own
|
|
19
|
+
`ActiveModel`-validated Ruby class. A malformed payload raises **in your own process,
|
|
20
|
+
naming the field that's wrong**, instead of travelling to Meta and coming back as
|
|
21
|
+
error `131009` — the catch-all code the Cloud API returns for a too-long caption, an
|
|
22
|
+
unsupported message type, a bad message ID, and a dozen other unrelated mistakes.
|
|
72
23
|
|
|
73
24
|
```ruby
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Whatsapp.configure do |config|
|
|
77
|
-
config.api_key = ENV.fetch("WHATSAPP_TOKEN")
|
|
78
|
-
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
|
|
82
|
-
|
|
83
|
-
response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
## Features
|
|
25
|
+
photo = "https://example.com/new-arrivals.jpg"
|
|
87
26
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
- **Pluggable instrumentation** — pass a `Logger` to `Whatsapp::Client.new` to log every request/response.
|
|
91
|
-
- **Hardened media downloads** — `Media#download` refuses to attach the bearer token to non-HTTPS URLs or hosts outside an allowlist, so a token can never leak to an attacker-influenced URL.
|
|
92
|
-
- **Structured response parsing** — `Whatsapp::Messages::Response` exposes typed `#contacts` and `#messages` instead of raw JSON.
|
|
93
|
-
- **Template management** — create, list, edit and delete the message templates on your WhatsApp Business Account from Ruby, with Meta's documented rules checked client-side so a rejection costs a validation error instead of a 24-hour review cycle.
|
|
94
|
-
- **Subscribed apps management** — subscribe or unsubscribe this app from a WhatsApp Business Account's webhook notifications, and list who's currently subscribed, straight from Ruby.
|
|
95
|
-
- **Business phone number onboarding** — request and verify a phone number's verification code, then register or deregister it with Cloud API, with the 6-digit two-step verification PIN and local-storage region checked client-side before any request is made.
|
|
96
|
-
- **Inbound webhook parsing** — a typed object tree for all 19 documented Meta notification field types, plus signature verification.
|
|
27
|
+
# Send it 📸
|
|
28
|
+
Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "Just landed")
|
|
97
29
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
```ruby
|
|
103
|
-
Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!")
|
|
30
|
+
# Get a field wrong, and you find out here — not from Meta, three seconds later
|
|
31
|
+
Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "x" * 2000)
|
|
32
|
+
# => ActiveModel::ValidationError: Caption is too long (maximum is 1024 characters)
|
|
104
33
|
```
|
|
105
34
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
35
|
+
Beyond sending, the gem covers the whole surface: media upload and download, template
|
|
36
|
+
creation and management, inbound webhook parsing, webhook subscription, phone number
|
|
37
|
+
onboarding, and reading or updating the business account itself.
|
|
109
38
|
|
|
110
|
-
|
|
111
|
-
Whatsapp::Messages.new(
|
|
112
|
-
kind: :text, # or a variable
|
|
113
|
-
payload: { to: "+15551234567", body: "Hello!" },
|
|
114
|
-
client: Whatsapp::Client.new # optional — defaults to a new Client built from Whatsapp.configuration
|
|
115
|
-
).send!
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
This form raises `Whatsapp::Messages::PayloadError` for an unknown `kind` (in addition to the same validation/request errors above).
|
|
119
|
-
|
|
120
|
-
| Method | Sends |
|
|
121
|
-
| --- | --- |
|
|
122
|
-
| [`send_text!`](#text) | Plain text with an optional link preview |
|
|
123
|
-
| [`send_image!`](#image) | An image with an optional caption |
|
|
124
|
-
| [`send_video!`](#video) | A video with an optional caption |
|
|
125
|
-
| [`send_audio!`](#audio) | An audio clip |
|
|
126
|
-
| [`send_document!`](#document) | A file with an optional caption and filename |
|
|
127
|
-
| [`send_sticker!`](#sticker) | A sticker |
|
|
128
|
-
| [`send_reaction!`](#reaction) | An emoji reaction to a previous message |
|
|
129
|
-
| [`send_location!`](#location) | A latitude/longitude pin |
|
|
130
|
-
| [`send_contacts!`](#contacts) | A rich contact card |
|
|
131
|
-
| [`send_address!`](#address) | A delivery-address request/confirmation form (India & Singapore only) |
|
|
132
|
-
| [`send_location_request!`](#location-request) | A prompt asking the user to share their location |
|
|
133
|
-
| [`send_template!`](#template) | A pre-approved marketing/utility/authentication template |
|
|
134
|
-
| [`send_interactive!`](#interactive) | Reply buttons, lists, CTA URLs, or carousels |
|
|
135
|
-
|
|
136
|
-
### Text
|
|
137
|
-
|
|
138
|
-
```ruby
|
|
139
|
-
Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!", preview_url: false)
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
`body` is required (max 4096 characters). `preview_url` defaults to `true`.
|
|
143
|
-
|
|
144
|
-
### Image
|
|
145
|
-
|
|
146
|
-
```ruby
|
|
147
|
-
Whatsapp::Messages.send_image!(
|
|
148
|
-
to: "+15551234567",
|
|
149
|
-
link: "https://example.com/photo.jpg",
|
|
150
|
-
caption: "Our new product"
|
|
151
|
-
)
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
Either `id` (an uploaded [media](#media) ID) or `link` is required. `caption` is optional (max 1024 characters).
|
|
155
|
-
|
|
156
|
-
### Video
|
|
39
|
+
## Table of Contents
|
|
157
40
|
|
|
158
|
-
|
|
159
|
-
|
|
41
|
+
- [✨ Why ruby-whatsapp](#-why-ruby-whatsapp)
|
|
42
|
+
- [📦 Installation](#-installation)
|
|
43
|
+
- [🔧 Configuration](#-configuration)
|
|
44
|
+
- [🚀 Quick Start](#-quick-start)
|
|
45
|
+
- [📚 Documentation](#-documentation)
|
|
46
|
+
- [💬 Sending Messages](#-sending-messages)
|
|
47
|
+
- [📋 Managing Templates](#-managing-templates)
|
|
48
|
+
- [🔔 Webhooks](#-webhooks)
|
|
49
|
+
- [📷 Media](#-media)
|
|
50
|
+
- [🔌 Subscribed Apps](#-subscribed-apps)
|
|
51
|
+
- [📞 Business Phone Numbers](#-business-phone-numbers)
|
|
52
|
+
- [🚨 Errors](#-errors)
|
|
53
|
+
- [🧩 Compatibility](#-compatibility)
|
|
54
|
+
- [🔨 Development](#-development)
|
|
55
|
+
- [🤝 Contributing](#-contributing)
|
|
56
|
+
- [📄 License](#-license)
|
|
57
|
+
|
|
58
|
+
## ✨ Why ruby-whatsapp
|
|
59
|
+
|
|
60
|
+
- **Typed, validated message classes** for every Cloud API message kind — invalid
|
|
61
|
+
payloads raise before any HTTP request is made, with the failing attribute named.
|
|
62
|
+
- **Template rules checked client-side.** Meta's documented constraints — name format,
|
|
63
|
+
character limits, placeholder/example matching, quick-reply contiguity, carousel
|
|
64
|
+
structure — are enforced locally, so a mistake costs a validation error instead of a
|
|
65
|
+
24-hour review cycle.
|
|
66
|
+
- **Hardened media downloads.** `Media#download` refuses to attach your bearer token to
|
|
67
|
+
a non-HTTPS URL or a host outside an allowlist, so a token can never leak to an
|
|
68
|
+
attacker-influenced URL.
|
|
69
|
+
- **Secrets stay out of logs.** `api_key`, `app_secret`, and `verify_token` are
|
|
70
|
+
redacted from every `#inspect`, including credentials in transit like a
|
|
71
|
+
registration PIN.
|
|
72
|
+
- **Inbound webhooks, fully typed.** An object tree for all 19 documented Meta
|
|
73
|
+
notification fields, plus HMAC signature verification and a Rails controller
|
|
74
|
+
generator.
|
|
75
|
+
- **Persistent HTTP connections** via `HTTP.persistent`, reused across requests, with
|
|
76
|
+
pluggable logging.
|
|
77
|
+
- **Four runtime dependencies**, no Rails requirement. Rails integration activates
|
|
78
|
+
itself when Rails is present.
|
|
79
|
+
|
|
80
|
+
## 📦 Installation
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
# Gemfile
|
|
84
|
+
gem "ruby-whatsapp"
|
|
160
85
|
```
|
|
161
86
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
### Audio
|
|
165
|
-
|
|
166
|
-
```ruby
|
|
167
|
-
Whatsapp::Messages.send_audio!(to: "+15551234567", link: "https://example.com/clip.mp3")
|
|
87
|
+
```bash
|
|
88
|
+
bundle install
|
|
168
89
|
```
|
|
169
90
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
### Document
|
|
91
|
+
Or standalone:
|
|
173
92
|
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
to: "+15551234567",
|
|
177
|
-
link: "https://example.com/invoice.pdf",
|
|
178
|
-
filename: "invoice.pdf"
|
|
179
|
-
)
|
|
93
|
+
```bash
|
|
94
|
+
gem install ruby-whatsapp
|
|
180
95
|
```
|
|
181
96
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
### Sticker
|
|
97
|
+
## 🔧 Configuration
|
|
185
98
|
|
|
186
99
|
```ruby
|
|
187
|
-
Whatsapp
|
|
100
|
+
Whatsapp.configure do |config|
|
|
101
|
+
config.api_key = ENV.fetch("WHATSAPP_API_KEY") # a Meta system-user / app access token
|
|
102
|
+
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
|
|
103
|
+
end
|
|
188
104
|
```
|
|
189
105
|
|
|
190
|
-
|
|
106
|
+
| Option | Default | Needed for |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `api_key` | — | Everything |
|
|
109
|
+
| `phone_id` | — | Messages, media, phone-number onboarding |
|
|
110
|
+
| `waba_id` | — | Template management, subscribed apps, the business account |
|
|
111
|
+
| `verify_token` | — | The webhook GET handshake |
|
|
112
|
+
| `app_secret` | — | Webhook signature verification |
|
|
113
|
+
| `host` | `https://graph.facebook.com` | Overriding the API host |
|
|
114
|
+
| `version` | `v24.0` | Pinning a Graph API version |
|
|
115
|
+
| `media_host_allowlist` | 3 Meta hosts | Media download safety |
|
|
191
116
|
|
|
192
|
-
|
|
117
|
+
**Which ID addresses what** — the most common source of confusion:
|
|
193
118
|
|
|
194
|
-
```ruby
|
|
195
|
-
Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "👍")
|
|
196
119
|
```
|
|
120
|
+
phone_id ──► Messages · Media · BusinessPhoneNumber
|
|
121
|
+
permission: whatsapp_business_messaging
|
|
197
122
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
### Location
|
|
201
|
-
|
|
202
|
-
```ruby
|
|
203
|
-
Whatsapp::Messages.send_location!(to: "+15551234567", latitude: 37.4847, longitude: -122.1477, name: "Meta HQ")
|
|
123
|
+
waba_id ──► MessageTemplates · SubscribedApp · BusinessPhoneNumber::Account
|
|
124
|
+
permission: whatsapp_business_management
|
|
204
125
|
```
|
|
205
126
|
|
|
206
|
-
`
|
|
207
|
-
|
|
208
|
-
### Contacts
|
|
127
|
+
`BusinessPhoneNumber` appears on both sides: its onboarding actions address a phone
|
|
128
|
+
number, its `Account` actions the account that number belongs to.
|
|
209
129
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
to: "+15551234567",
|
|
213
|
-
contacts: [
|
|
214
|
-
{
|
|
215
|
-
name: { formatted_name: "Jane Doe", first_name: "Jane", last_name: "Doe" },
|
|
216
|
-
phones: [{ phone: "+15550001111", type: "WORK" }],
|
|
217
|
-
emails: [{ email: "jane@example.com", type: "WORK" }],
|
|
218
|
-
org: { company: "Acme Inc." },
|
|
219
|
-
birthday: "1990-05-12",
|
|
220
|
-
},
|
|
221
|
-
]
|
|
222
|
-
)
|
|
223
|
-
```
|
|
130
|
+
`api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect`
|
|
131
|
+
and `Client#inspect`, so they will not leak into logs or error reports.
|
|
224
132
|
|
|
225
|
-
|
|
133
|
+
→ **[Full configuration reference](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md)**
|
|
226
134
|
|
|
227
|
-
|
|
135
|
+
## 🚀 Quick Start
|
|
228
136
|
|
|
229
137
|
```ruby
|
|
230
|
-
|
|
231
|
-
to: "+15551234567",
|
|
232
|
-
body: "Please share your delivery address",
|
|
233
|
-
country: "IN",
|
|
234
|
-
footer: "Thanks for shopping with us",
|
|
235
|
-
values: { name: "Jane Doe", city: "Bangalore" }
|
|
236
|
-
)
|
|
237
|
-
```
|
|
138
|
+
require "ruby/whatsapp"
|
|
238
139
|
|
|
239
|
-
|
|
140
|
+
Whatsapp.configure do |config|
|
|
141
|
+
config.api_key = ENV.fetch("WHATSAPP_API_KEY")
|
|
142
|
+
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
|
|
143
|
+
end
|
|
240
144
|
|
|
241
|
-
|
|
145
|
+
response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
|
|
242
146
|
|
|
243
|
-
|
|
244
|
-
|
|
147
|
+
response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
|
|
148
|
+
response.contacts.first.wa_id # => "15551234567"
|
|
245
149
|
```
|
|
246
150
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
### Template
|
|
250
|
-
|
|
251
|
-
```ruby
|
|
252
|
-
Whatsapp::Messages.send_template!(
|
|
253
|
-
to: "+15551234567",
|
|
254
|
-
name: "order_confirmation",
|
|
255
|
-
language: { code: "en_US" },
|
|
256
|
-
components: [
|
|
257
|
-
{ type: "body", parameters: [{ type: "text", text: "Jane" }, { type: "text", text: "#1234" }] },
|
|
258
|
-
]
|
|
259
|
-
)
|
|
260
|
-
```
|
|
151
|
+
## 📚 Documentation
|
|
261
152
|
|
|
262
|
-
|
|
153
|
+
| Area | What it covers |
|
|
154
|
+
| --- | --- |
|
|
155
|
+
| [**Configuration**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md) | Credentials, the client, connection reuse, instrumentation |
|
|
156
|
+
| [**Messages**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md) | Every message kind, one page each, with exact payloads |
|
|
157
|
+
| [**Message Templates**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md) | Creating and managing templates: standard, auth, carousel, offers, library |
|
|
158
|
+
| [**Webhooks**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md) | Install, verification, signatures, and all 19 notification fields |
|
|
159
|
+
| [**Media**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md) | Upload, download, delete, and the token-safety allowlist |
|
|
160
|
+
| [**Subscribed Apps**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md) | Turning webhook delivery on and off for an account |
|
|
161
|
+
| [**Business Phone Numbers**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md) | Onboarding: request code → verify → register |
|
|
162
|
+
| [**Business Account**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/account.md) | Reading and updating the account: name, timezone, review status |
|
|
163
|
+
| [**Business Profile**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/profile.md) | The profile a user sees: about, description, address, vertical, picture |
|
|
164
|
+
| [**Errors**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md) | The exception hierarchy and retry strategy |
|
|
263
165
|
|
|
264
|
-
|
|
166
|
+
Or start at the [documentation index](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/README.md).
|
|
265
167
|
|
|
266
|
-
|
|
168
|
+
## 💬 Sending Messages
|
|
267
169
|
|
|
268
|
-
|
|
170
|
+
Every registered kind gets its own `Whatsapp::Messages.send_<kind>!` class method:
|
|
269
171
|
|
|
270
172
|
```ruby
|
|
271
173
|
Whatsapp::Messages.send_interactive!(
|
|
272
174
|
to: "+15551234567",
|
|
273
175
|
type: :reply_buttons,
|
|
274
176
|
body: "Would you like to confirm your order?",
|
|
275
|
-
action: { buttons: [{ id: "confirm", title: "Confirm" },
|
|
276
|
-
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
**List** — a button that expands into up to 10 sections of up to 10 rows each:
|
|
280
|
-
|
|
281
|
-
```ruby
|
|
282
|
-
Whatsapp::Messages.send_interactive!(
|
|
283
|
-
to: "+15551234567",
|
|
284
|
-
type: :list_buttons,
|
|
285
|
-
body: "Choose a drink",
|
|
286
|
-
action: {
|
|
287
|
-
button: "Menu",
|
|
288
|
-
sections: [
|
|
289
|
-
{ title: "Coffee", rows: [{ id: "espresso", title: "Espresso", description: "Strong & short" }] },
|
|
290
|
-
],
|
|
291
|
-
}
|
|
177
|
+
action: { buttons: [{ id: "confirm", title: "Confirm" },
|
|
178
|
+
{ id: "cancel", title: "Cancel" }] }
|
|
292
179
|
)
|
|
293
180
|
```
|
|
294
181
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
header: { type: "image", link: "https://example.com/1.jpg" },
|
|
317
|
-
body: "Item one",
|
|
318
|
-
action: { name: "cta_url", display_text: "Buy", url: "https://example.com/1" },
|
|
319
|
-
buttons: [{ quick_reply: { id: "q1", title: "Details" } }],
|
|
320
|
-
},
|
|
321
|
-
{
|
|
322
|
-
header: { type: "image", link: "https://example.com/2.jpg" },
|
|
323
|
-
body: "Item two",
|
|
324
|
-
action: { name: "cta_url", display_text: "Buy", url: "https://example.com/2" },
|
|
325
|
-
buttons: [{ quick_reply: { id: "q2", title: "Details" } }],
|
|
326
|
-
},
|
|
327
|
-
],
|
|
328
|
-
}
|
|
329
|
-
)
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
**Product carousel** — 2 to 10 cards referencing products in your Meta catalog:
|
|
333
|
-
|
|
334
|
-
```ruby
|
|
335
|
-
Whatsapp::Messages.send_interactive!(
|
|
336
|
-
to: "+15551234567",
|
|
337
|
-
type: :product_carousel,
|
|
338
|
-
body: "Recommended for you",
|
|
339
|
-
action: {
|
|
340
|
-
cards: [
|
|
341
|
-
{ catalog_id: "123456789", product_retailer_id: "SKU-1" },
|
|
342
|
-
{ catalog_id: "123456789", product_retailer_id: "SKU-2" },
|
|
343
|
-
],
|
|
344
|
-
}
|
|
345
|
-
)
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
> **Note:** the wire values for the carousel types (`"carousel"` and `"product_list"`) are flagged in the source as worth double-checking against Meta's docs for your specific API version before relying on them in production.
|
|
349
|
-
|
|
350
|
-
### Mark Message As Read
|
|
351
|
-
|
|
352
|
-
```ruby
|
|
353
|
-
Whatsapp::Messages.mark_message_as_read!(message_id: "wamid.HBgLMTU1NTU1NTU1NTUV...")
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
Marks an inbound message — and every earlier message in that conversation — as read, powering the "seen" checkmarks on the user's side. Must be called within 30 days of receipt. Unlike every other kind above, this isn't sent through `Messages.new(kind:, payload:).send!`: there's no recipient or `type` envelope, just `message_id:`, so it has its own dedicated method instead of a `kind:` in the factory.
|
|
357
|
-
|
|
358
|
-
## Handling Responses
|
|
359
|
-
|
|
360
|
-
A successful `send!` returns a `Whatsapp::Messages::Response`, built from a payload shaped like:
|
|
361
|
-
|
|
362
|
-
```json
|
|
363
|
-
{
|
|
364
|
-
"messaging_product": "whatsapp",
|
|
365
|
-
"contacts": [{ "input": "+15551234567", "wa_id": "15551234567" }],
|
|
366
|
-
"messages": [{ "id": "wamid.HBgLMTU1NTU1NTU1NTUV..." }]
|
|
367
|
-
}
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
```ruby
|
|
371
|
-
response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
|
|
372
|
-
|
|
373
|
-
response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
|
|
374
|
-
response.contacts.first.wa_id # => "15551234567"
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
Sending raises `ActiveModel::ValidationError` if a field fails local validation, `Whatsapp::Messages::PayloadError` if `kind:` is unrecognized (only reachable through the `Messages.new(kind:, payload:).send!` form), and `Whatsapp::RequestError` if the API responds with a non-2xx status.
|
|
378
|
-
|
|
379
|
-
## Media
|
|
380
|
-
|
|
381
|
-
```ruby
|
|
382
|
-
media = Whatsapp::Media.new
|
|
383
|
-
|
|
384
|
-
media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
385
|
-
info = media.get_url(media_id: media_id) # => { "url" => ..., ... }
|
|
386
|
-
media.download(url: info["url"], save_to: "photo.jpg") # only HTTPS + allowlisted Meta hosts
|
|
387
|
-
media.delete(media_id: media_id) # => true
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
`download` refuses to attach the API token to a non-HTTPS URL or a host that is not on
|
|
391
|
-
`Configuration#media_host_allowlist`, so a token is never sent to an attacker-influenced URL.
|
|
182
|
+
| Method | Sends |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| [`send_text!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/text.md) | Plain text with an optional link preview |
|
|
185
|
+
| [`send_image!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/image.md) | An image with an optional caption |
|
|
186
|
+
| [`send_video!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/video.md) | A video with an optional caption |
|
|
187
|
+
| [`send_audio!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/audio.md) | A voice note or audio clip |
|
|
188
|
+
| [`send_document!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/document.md) | A file with an optional caption and filename |
|
|
189
|
+
| [`send_sticker!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/sticker.md) | A sticker |
|
|
190
|
+
| [`send_reaction!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/reaction.md) | An emoji reaction to a previous message |
|
|
191
|
+
| [`send_location!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/location.md) | A latitude/longitude pin |
|
|
192
|
+
| [`send_contacts!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/contacts.md) | A rich, vCard-like contact card |
|
|
193
|
+
| [`send_address!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/address.md) | A delivery-address form (India & Singapore only) |
|
|
194
|
+
| [`send_location_request!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/location_request.md) | A prompt asking the user to share their location |
|
|
195
|
+
| [`send_template!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/template.md) | A pre-approved marketing/utility/authentication template |
|
|
196
|
+
| [`send_interactive!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/interactive.md) | Reply buttons, lists, CTA URLs, or carousels |
|
|
197
|
+
| [`mark_message_as_read!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/mark_message_as_read.md) | Closes the read-receipt loop on an inbound message |
|
|
198
|
+
|
|
199
|
+
Each accepts an optional `client:` and returns a `Whatsapp::Messages::Response` with
|
|
200
|
+
typed `#contacts` and `#messages`. Invalid input raises
|
|
201
|
+
`ActiveModel::ValidationError` before any request is made.
|
|
392
202
|
|
|
393
|
-
|
|
203
|
+
→ **[Sending messages](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md)**
|
|
394
204
|
|
|
395
|
-
|
|
396
|
-
Meta. `Whatsapp::MessageTemplates` creates and manages those templates, so they can live in
|
|
397
|
-
your codebase and ship from CI instead of being clicked together in WhatsApp Manager.
|
|
205
|
+
## 📋 Managing Templates
|
|
398
206
|
|
|
399
|
-
|
|
400
|
-
|
|
207
|
+
Sending a template requires one that already exists and has been approved by Meta.
|
|
208
|
+
`Whatsapp::MessageTemplates` creates and manages those, so they live in your codebase
|
|
209
|
+
and ship from CI instead of being clicked together in WhatsApp Manager.
|
|
401
210
|
|
|
402
211
|
```ruby
|
|
403
|
-
Whatsapp.
|
|
404
|
-
config.api_key = ENV["WHATSAPP_API_KEY"]
|
|
405
|
-
config.waba_id = ENV["WHATSAPP_WABA_ID"]
|
|
406
|
-
end
|
|
212
|
+
templates = Whatsapp::MessageTemplates.new # needs waba_id
|
|
407
213
|
|
|
408
|
-
templates = Whatsapp::MessageTemplates.new
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
### Creating
|
|
412
|
-
|
|
413
|
-
```ruby
|
|
414
214
|
created = templates.create(
|
|
415
|
-
name: "order_confirmation",
|
|
416
|
-
language: "en_US",
|
|
417
|
-
category: "UTILITY", # UTILITY | MARKETING | AUTHENTICATION
|
|
215
|
+
name: "order_confirmation", language: "en_US", category: "UTILITY",
|
|
418
216
|
components: [
|
|
419
|
-
{ type: :header, format: "TEXT", text: "Order {{1}} confirmed", example: ["#1234"] },
|
|
420
217
|
{ type: :body,
|
|
421
218
|
text: "Thank you, {{1}}! Your order number is {{2}}.",
|
|
422
219
|
example: ["Pablo", "860198-230332"] },
|
|
423
|
-
{ type: :footer, text: "Thanks for shopping with us" },
|
|
424
220
|
{ type: :buttons, buttons: [
|
|
425
|
-
{ type: :phone_number, text: "Call", phone_number: "15550051310" },
|
|
426
221
|
{ type: :url, text: "Track order", url: "https://example.com/orders/{{1}}", example: "1234" },
|
|
427
222
|
] },
|
|
428
223
|
]
|
|
429
224
|
)
|
|
430
225
|
|
|
431
|
-
created.id # => "1259544702043867"
|
|
432
226
|
created.status # => "PENDING" — Meta reviews asynchronously, up to 24 hours
|
|
433
|
-
created.pending? # => true
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
Named parameters read better than positional ones for anything non-trivial:
|
|
437
|
-
|
|
438
|
-
```ruby
|
|
439
|
-
templates.create(
|
|
440
|
-
name: "order_confirmation", language: "en_US", category: "UTILITY",
|
|
441
|
-
parameter_format: "NAMED",
|
|
442
|
-
components: [
|
|
443
|
-
{ type: :body,
|
|
444
|
-
text: "Thank you, {{first_name}}! Your order number is {{order_number}}.",
|
|
445
|
-
example: { first_name: "Pablo", order_number: "860198-230332" } },
|
|
446
|
-
]
|
|
447
|
-
)
|
|
448
227
|
```
|
|
449
228
|
|
|
450
229
|
Meta's rules are checked before the request, so a mistake raises immediately instead of
|
|
@@ -454,180 +233,98 @@ costing a review cycle:
|
|
|
454
233
|
templates.create(name: "Order Confirmation", ...)
|
|
455
234
|
# => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
|
|
456
235
|
# characters and underscores
|
|
457
|
-
|
|
458
|
-
templates.create(..., components: [{ type: :body, text: "Hi {{1}} and {{2}}", example: ["Pablo"] }])
|
|
459
|
-
# => ActiveModel::ValidationError: Example does not match the body text:
|
|
460
|
-
# 2 placeholders but 1 example
|
|
461
236
|
```
|
|
462
237
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
page.remaining # headroom against your account's template cap
|
|
469
|
-
templates.list(after: page.next_cursor) if page.next_cursor
|
|
470
|
-
|
|
471
|
-
template = templates.find(template_id: "1259544702043867")
|
|
472
|
-
template.status # => "APPROVED"
|
|
473
|
-
template.editable? # => true (APPROVED, REJECTED and PAUSED templates can be edited)
|
|
238
|
+
Covers standard, [authentication/OTP](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/authentication.md),
|
|
239
|
+
[carousel](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/carousel.md),
|
|
240
|
+
[limited-time offer](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/limited_time_offer.md), and
|
|
241
|
+
[library](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/library.md) templates,
|
|
242
|
+
plus list, find, update, and delete.
|
|
474
243
|
|
|
475
|
-
templates
|
|
476
|
-
templates.update(template_id: template.id, components: [...]) # full replacement
|
|
477
|
-
|
|
478
|
-
templates.delete(name: "order_confirmation") # every language variant
|
|
479
|
-
templates.delete(hsm_id: "1407680676729941", name: "order_confirmation")
|
|
480
|
-
templates.delete(hsm_ids: %w[1387372356726668 1304694804498707]) # up to 100
|
|
481
|
-
```
|
|
244
|
+
→ **[Managing templates](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md)**
|
|
482
245
|
|
|
483
|
-
|
|
484
|
-
Approved templates allow 10 edits per 30 days and 1 per 24 hours. Deleting an approved
|
|
485
|
-
template blocks reuse of its name for 30 days.
|
|
246
|
+
## 🔔 Webhooks
|
|
486
247
|
|
|
487
|
-
|
|
248
|
+
Meta pushes inbound messages, delivery statuses, and ~18 other notification types to a
|
|
249
|
+
callback URL you register. Inside a Rails app:
|
|
488
250
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
```ruby
|
|
493
|
-
templates.upsert(
|
|
494
|
-
name: "authentication_code", languages: %w[en_US es_ES fr], category: "AUTHENTICATION",
|
|
495
|
-
components: [
|
|
496
|
-
{ type: :body, add_security_recommendation: true },
|
|
497
|
-
{ type: :footer, code_expiration_minutes: 15 },
|
|
498
|
-
{ type: :buttons, buttons: [{ type: :otp, otp_type: "COPY_CODE" }] },
|
|
499
|
-
]
|
|
500
|
-
)
|
|
251
|
+
```bash
|
|
252
|
+
bundle exec rake whatsapp:install:webhook
|
|
501
253
|
```
|
|
502
254
|
|
|
503
|
-
|
|
255
|
+
That copies a personalizable controller to
|
|
256
|
+
`app/controllers/whatsapp/webhooks_controller.rb` and prints the routes to add:
|
|
504
257
|
|
|
505
258
|
```ruby
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
templates.create(
|
|
513
|
-
name: "summer_carousel", language: "en_US", category: "MARKETING",
|
|
514
|
-
components: [
|
|
515
|
-
{ type: :body, text: "Summer is here, {{1}}!", example: ["Pablo"] },
|
|
516
|
-
{ type: :carousel, cards: [card, card] },
|
|
517
|
-
]
|
|
518
|
-
)
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
**Limited-time offers** add a countdown and a coupon code (marketing only; footers are not
|
|
522
|
-
allowed and the body drops to 600 characters):
|
|
259
|
+
def receive
|
|
260
|
+
raw_body = request.body.read
|
|
261
|
+
return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
|
|
262
|
+
payload: raw_body, header: request.headers["X-Hub-Signature-256"]
|
|
263
|
+
)
|
|
523
264
|
|
|
524
|
-
|
|
525
|
-
templates.create(
|
|
526
|
-
name: "spring_offer", language: "en_US", category: "MARKETING",
|
|
527
|
-
components: [
|
|
528
|
-
{ type: :header, format: "IMAGE", header_handle: "4::aW..." },
|
|
529
|
-
{ type: :limited_time_offer, text: "Expiring offer!", has_expiration: true },
|
|
530
|
-
{ type: :body, text: "Good news, {{1}}! Use code {{2}} for 25% off.",
|
|
531
|
-
example: ["Pablo", "SPRING25"] },
|
|
532
|
-
{ type: :buttons, buttons: [
|
|
533
|
-
{ type: :copy_code, example: "SPRING25" },
|
|
534
|
-
{ type: :url, text: "Book now!", url: "https://example.com/o?c={{1}}", example: "n3mtql" },
|
|
535
|
-
] },
|
|
536
|
-
]
|
|
537
|
-
)
|
|
538
|
-
```
|
|
265
|
+
notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
|
|
539
266
|
|
|
540
|
-
|
|
541
|
-
|
|
267
|
+
notification.entry.each do |entry|
|
|
268
|
+
entry.changes.each { |change| WebhookJob.perform_later(change) }
|
|
269
|
+
end
|
|
542
270
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
name: "my_delivery_update", language: "en_US", category: "UTILITY",
|
|
546
|
-
library_template_name: "delivery_update_1",
|
|
547
|
-
library_template_button_inputs: [
|
|
548
|
-
{ type: "URL", url: { base_url: "https://example.com/{{1}}",
|
|
549
|
-
url_suffix_example: "https://example.com/order_update" } },
|
|
550
|
-
]
|
|
551
|
-
)
|
|
271
|
+
head :ok
|
|
272
|
+
end
|
|
552
273
|
```
|
|
553
274
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
- **Media headers** take a `header_handle` you already hold. Producing one needs Meta's
|
|
557
|
-
Resumable Upload API, which this gem does not wrap — note it is a different flow from
|
|
558
|
-
[`Media#upload`](#media), whose media IDs are for *sending*, not template creation.
|
|
559
|
-
- **Review outcomes arrive by webhook**, not by polling: see
|
|
560
|
-
`message_template_status_update` and friends under [Webhooks](#webhooks).
|
|
561
|
-
- Text containing `#{{1}}` needs single quotes in Ruby, or `#{` starts interpolation.
|
|
562
|
-
|
|
563
|
-
## Managing Subscribed Apps
|
|
564
|
-
|
|
565
|
-
Before your app receives any [webhook](#webhooks) notifications for a WhatsApp
|
|
566
|
-
Business Account, it needs to be subscribed to it. `Whatsapp::SubscribedApp` wraps the
|
|
567
|
-
`subscribed_apps` edge: one class per action, since — unlike templates — these three
|
|
568
|
-
actions don't share an identity or validation rules to justify one combined class.
|
|
569
|
-
|
|
570
|
-
This addresses your **WhatsApp Business Account** (`waba_id`, not `phone_id`) and needs
|
|
571
|
-
the `whatsapp_business_management` permission, same as [template management](#managing-templates).
|
|
275
|
+
Every notification deserializes into typed objects — no raw hash spelunking:
|
|
572
276
|
|
|
573
277
|
```ruby
|
|
574
|
-
|
|
575
|
-
config.api_key = ENV["WHATSAPP_API_KEY"]
|
|
576
|
-
config.waba_id = ENV["WHATSAPP_WABA_ID"]
|
|
577
|
-
end
|
|
278
|
+
message = change.value.messages.first
|
|
578
279
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
280
|
+
message.from # => "16505551234"
|
|
281
|
+
message.id # => "wamid.HBg..."
|
|
282
|
+
message.body # => "Does it come in another color?"
|
|
582
283
|
```
|
|
583
284
|
|
|
584
|
-
|
|
285
|
+
All 19 documented fields get a class. Only `messages` has a Meta-published schema; the
|
|
286
|
+
other 18 are best-effort and flagged as such, per field.
|
|
585
287
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
result.success # => true
|
|
589
|
-
result.map(&:name) # => ["My App"] — every app now subscribed, Meta's own echo
|
|
590
|
-
```
|
|
288
|
+
→ **[Webhooks](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md)**
|
|
289
|
+
· [inbound messages & statuses](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/messages.md)
|
|
591
290
|
|
|
592
|
-
|
|
593
|
-
override instead of relying on the one callback URL configured on the app itself:
|
|
291
|
+
## 📷 Media
|
|
594
292
|
|
|
595
293
|
```ruby
|
|
596
|
-
Whatsapp::
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
)
|
|
294
|
+
media = Whatsapp::Media.new
|
|
295
|
+
|
|
296
|
+
media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
|
|
297
|
+
info = media.get_url(media_id: media_id)
|
|
298
|
+
media.download(url: info["url"], save_to: "photo.jpg")
|
|
299
|
+
media.delete(media_id: media_id) # => true
|
|
600
300
|
```
|
|
601
301
|
|
|
602
|
-
|
|
302
|
+
`download` refuses to attach the API token to a non-HTTPS URL or a host that is not on
|
|
303
|
+
`Configuration#media_host_allowlist`, so a token is never sent to an
|
|
304
|
+
attacker-influenced URL. Bodies stream to disk rather than buffering in memory.
|
|
603
305
|
|
|
604
|
-
|
|
605
|
-
apps = Whatsapp::SubscribedApp::List.call
|
|
606
|
-
apps.map(&:name) # Collection is Enumerable
|
|
607
|
-
apps.first.link # => "https://www.facebook.com/games/?app_id=..."
|
|
306
|
+
→ **[Media](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md)**
|
|
608
307
|
|
|
609
|
-
|
|
610
|
-
```
|
|
308
|
+
## 🔌 Subscribed Apps
|
|
611
309
|
|
|
612
|
-
|
|
310
|
+
Before your app receives any webhook notifications for a business account, it has to be
|
|
311
|
+
subscribed to it:
|
|
613
312
|
|
|
614
313
|
```ruby
|
|
615
|
-
Whatsapp::SubscribedApp::
|
|
314
|
+
Whatsapp::SubscribedApp::Subscribe.call # start webhook delivery
|
|
315
|
+
Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
|
|
316
|
+
Whatsapp::SubscribedApp::Unsubscribe.call # stop it
|
|
616
317
|
```
|
|
617
318
|
|
|
618
|
-
|
|
319
|
+
Tech Providers routing several accounts to different callback URLs pass an
|
|
320
|
+
`override_callback_uri:`.
|
|
619
321
|
|
|
620
|
-
|
|
621
|
-
`Whatsapp::Client` built from `Whatsapp.configuration`), and raises
|
|
622
|
-
`Whatsapp::SubscribedApp::Error` if no `waba_id` is configured or the API rejects the
|
|
623
|
-
request.
|
|
322
|
+
→ **[Subscribed apps](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md)**
|
|
624
323
|
|
|
625
|
-
##
|
|
324
|
+
## 📞 Business Phone Numbers
|
|
626
325
|
|
|
627
|
-
A
|
|
628
|
-
|
|
629
|
-
number at all. `Whatsapp::BusinessPhoneNumber` wraps all four endpoints in that
|
|
630
|
-
onboarding flow:
|
|
326
|
+
A phone number is unusable with Cloud API until it is **registered** — the prerequisite
|
|
327
|
+
that makes sending, media, and templates work for it at all.
|
|
631
328
|
|
|
632
329
|
```
|
|
633
330
|
RequestCode -> VerifyCode -> Register (onboarding)
|
|
@@ -636,161 +333,106 @@ RequestCode -> VerifyCode -> Register (onboarding)
|
|
|
636
333
|
Deregister (the reverse switch)
|
|
637
334
|
```
|
|
638
335
|
|
|
639
|
-
This addresses your **phone number** (`phone_id`, not `waba_id`), like
|
|
640
|
-
[messages](#sending-messages) and [media](#media), and needs the
|
|
641
|
-
`whatsapp_business_messaging` and `whatsapp_business_management` permissions.
|
|
642
|
-
|
|
643
|
-
```ruby
|
|
644
|
-
Whatsapp.configure do |config|
|
|
645
|
-
config.api_key = ENV["WHATSAPP_API_KEY"]
|
|
646
|
-
config.phone_id = ENV["WHATSAPP_PHONE_ID"]
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US") # send an OTP
|
|
650
|
-
Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456") # confirm it
|
|
651
|
-
Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834") # register with your two-step verification PIN
|
|
652
|
-
Whatsapp::BusinessPhoneNumber::Deregister.call # release the number from Cloud API
|
|
653
|
-
```
|
|
654
|
-
|
|
655
|
-
### Requesting a verification code
|
|
656
|
-
|
|
657
336
|
```ruby
|
|
658
|
-
Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US")
|
|
337
|
+
Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US")
|
|
338
|
+
Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456")
|
|
339
|
+
Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
|
|
340
|
+
Whatsapp::BusinessPhoneNumber::Deregister.call
|
|
659
341
|
```
|
|
660
342
|
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
343
|
+
The 6-digit two-step verification PIN and the local-storage region are validated
|
|
344
|
+
client-side — which matters here, because a rejected attempt still counts against a
|
|
345
|
+
rate limit of 10 requests per number per 72-hour window.
|
|
664
346
|
|
|
665
|
-
|
|
347
|
+
The account that number belongs to is readable and writable too — the one part of this
|
|
348
|
+
module that addresses `waba_id` rather than `phone_id`:
|
|
666
349
|
|
|
667
350
|
```ruby
|
|
668
|
-
|
|
669
|
-
result.success # => true
|
|
670
|
-
result.id # => the phone number ID, when Meta returns one
|
|
671
|
-
```
|
|
351
|
+
details = Whatsapp::BusinessPhoneNumber::Account::Get.call(fields: %w[name account_review_status])
|
|
672
352
|
|
|
673
|
-
|
|
353
|
+
details.name # => "Acme Corp"
|
|
354
|
+
details.approved? # => true
|
|
674
355
|
|
|
675
|
-
|
|
676
|
-
result = Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
|
|
677
|
-
result.success # => true
|
|
356
|
+
Whatsapp::BusinessPhoneNumber::Account::Update.call(name: "Acme Corporation").success # => true
|
|
678
357
|
```
|
|
679
358
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
`ActiveModel::ValidationError` before any request is made.
|
|
683
|
-
|
|
684
|
-
Pass `data_localization_region` to enable local storage in one of Meta's supported
|
|
685
|
-
regions (`AU ID IN JP SG KR DE CH GB BR BH ZA AE CA`):
|
|
359
|
+
And so is the **business profile** — the card a user sees before they reply. It addresses
|
|
360
|
+
the same `phone_id` as the onboarding actions:
|
|
686
361
|
|
|
687
362
|
```ruby
|
|
688
|
-
Whatsapp::BusinessPhoneNumber::
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
### Deregistering
|
|
363
|
+
Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
|
|
364
|
+
# => "Open daily 9-5"
|
|
692
365
|
|
|
693
|
-
|
|
694
|
-
|
|
366
|
+
Whatsapp::BusinessPhoneNumber::Profile::Update.call(
|
|
367
|
+
about: "Open daily 9-6", vertical: "RETAIL", websites: ["https://acme.test"]
|
|
368
|
+
).success
|
|
369
|
+
# => true
|
|
695
370
|
```
|
|
696
371
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
API and the WhatsApp Business app cannot be deregistered.
|
|
700
|
-
|
|
701
|
-
Every action accepts an optional `client:` keyword (defaults to a new
|
|
702
|
-
`Whatsapp::Client` built from `Whatsapp.configuration` — pass one with a different
|
|
703
|
-
`phone_id:` to target another number), and raises
|
|
704
|
-
`Whatsapp::BusinessPhoneNumber::Error` if no `phone_id` is configured or the API rejects
|
|
705
|
-
the request.
|
|
372
|
+
Character limits, the 21-value vertical enum, and the two-website cap are all checked
|
|
373
|
+
before the request goes out.
|
|
706
374
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
> Graph API rate limits" plus possible additional throttling, with no published number.
|
|
375
|
+
→ **[Business phone numbers](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md)**
|
|
376
|
+
· [the business account](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/account.md)
|
|
377
|
+
· [the business profile](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/profile.md)
|
|
711
378
|
|
|
712
|
-
##
|
|
379
|
+
## 🚨 Errors
|
|
713
380
|
|
|
714
|
-
|
|
715
|
-
|
|
381
|
+
Everything descends from `Whatsapp::Error`, and each module raises its own subclass so
|
|
382
|
+
you can rescue narrowly:
|
|
716
383
|
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
# config/routes.rb
|
|
726
|
-
get "/whatsapp/webhooks", to: "whatsapp/webhooks#verify"
|
|
727
|
-
post "/whatsapp/webhooks", to: "whatsapp/webhooks#receive"
|
|
728
|
-
|
|
729
|
-
# config/initializers/whatsapp.rb
|
|
730
|
-
Whatsapp.configure do |config|
|
|
731
|
-
config.verify_token = Rails.application.credentials.whatsapp_verify_token
|
|
732
|
-
config.app_secret = Rails.application.credentials.whatsapp_app_secret
|
|
733
|
-
end
|
|
734
|
-
```
|
|
384
|
+
| Class | Raised by |
|
|
385
|
+
| --- | --- |
|
|
386
|
+
| `Whatsapp::RequestError` | A failed message send |
|
|
387
|
+
| `Whatsapp::Messages::PayloadError` | An unknown message kind |
|
|
388
|
+
| `Whatsapp::Media::MediaError` | Anything in `Media` |
|
|
389
|
+
| `Whatsapp::MessageTemplates::TemplateError` | Anything in `MessageTemplates` |
|
|
390
|
+
| `Whatsapp::SubscribedApp::Error` | Anything in `SubscribedApp` |
|
|
391
|
+
| `Whatsapp::BusinessPhoneNumber::Error` | Anything in `BusinessPhoneNumber`, including `Account` |
|
|
735
392
|
|
|
736
|
-
|
|
737
|
-
|
|
393
|
+
Local validation failures raise `ActiveModel::ValidationError` instead — they happen at
|
|
394
|
+
construction time, before any network call, and carry per-attribute detail:
|
|
738
395
|
|
|
739
396
|
```ruby
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
challenge ? render(plain: challenge) : head(:forbidden)
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
def receive
|
|
747
|
-
raw_body = request.body.read
|
|
748
|
-
return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
|
|
749
|
-
payload: raw_body, header: request.headers["X-Hub-Signature-256"]
|
|
750
|
-
)
|
|
751
|
-
|
|
752
|
-
notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
|
|
753
|
-
# notification.entry.each { |entry| entry.changes.each { |change| WebhookJob.perform_later(change) } }
|
|
754
|
-
|
|
755
|
-
head :ok
|
|
756
|
-
end
|
|
757
|
-
end
|
|
397
|
+
rescue ActiveModel::ValidationError => e
|
|
398
|
+
e.model.errors.full_messages
|
|
399
|
+
# => ["Caption is too long (maximum is 1024 characters)"]
|
|
758
400
|
```
|
|
759
401
|
|
|
760
|
-
|
|
761
|
-
`value` — for the `messages` field, `value.messages` and `value.statuses` are arrays of typed
|
|
762
|
-
message/status objects (`Whatsapp::Webhook::Message::Text`, `Whatsapp::Webhook::Status`, etc.).
|
|
763
|
-
The other ~18 documented fields (`account_alerts`, `message_template_status_update`, and so on)
|
|
764
|
-
each deserialize into their own best-effort typed class — see
|
|
765
|
-
[`lib/ruby/whatsapp/webhook/CLAUDE.md`](lib/ruby/whatsapp/webhook/CLAUDE.md) for the full field
|
|
766
|
-
reference and confidence notes, since Meta's docs don't publish a JSON schema for most of them.
|
|
767
|
-
|
|
768
|
-
**Multi-tenant apps** (many customers, each with their own Meta App) pass `verify_token:`/
|
|
769
|
-
`app_secret:` explicitly instead of relying on the global config default:
|
|
402
|
+
→ **[Errors](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md)**
|
|
770
403
|
|
|
771
|
-
|
|
772
|
-
account = Account.find_by!(slug: params[:account_slug])
|
|
773
|
-
Whatsapp::Webhook::Verification.call(params:, verify_token: account.verify_token)
|
|
774
|
-
Whatsapp::Webhook::Signature.valid?(payload: raw_body, header:, app_secret: account.app_secret)
|
|
775
|
-
```
|
|
404
|
+
## 🧩 Compatibility
|
|
776
405
|
|
|
777
|
-
|
|
406
|
+
| | |
|
|
407
|
+
| --- | --- |
|
|
408
|
+
| Ruby | >= 3.2 (CI runs 3.2 and 3.4) |
|
|
409
|
+
| Graph API | `v24.0` by default, overridable |
|
|
410
|
+
| Rails | Optional. Webhook controller generator activates when Rails is loaded |
|
|
411
|
+
| Dependencies | `activemodel`, `http`, `logger`, `zeitwerk` |
|
|
778
412
|
|
|
779
|
-
## Development
|
|
413
|
+
## 🔨 Development
|
|
780
414
|
|
|
781
415
|
After checking out the repo, run `bundle install`, then:
|
|
782
416
|
|
|
783
417
|
```bash
|
|
784
|
-
bundle exec rake #
|
|
418
|
+
bundle exec rake # specs + RuboCop (the default task)
|
|
785
419
|
bundle exec rspec # specs only
|
|
786
420
|
bundle exec rubocop # lint only
|
|
787
421
|
bin/console # interactive prompt
|
|
788
422
|
```
|
|
789
423
|
|
|
790
|
-
## Contributing
|
|
424
|
+
## 🤝 Contributing
|
|
425
|
+
|
|
426
|
+
Bug reports and pull requests are welcome at
|
|
427
|
+
<https://github.com/saleszera/ruby-whatsapp>. Please write the failing spec first —
|
|
428
|
+
this gem is developed test-first — and make sure `bundle exec rake` is green before
|
|
429
|
+
opening a PR.
|
|
430
|
+
|
|
431
|
+
## 📄 License
|
|
791
432
|
|
|
792
|
-
|
|
433
|
+
Available as open source under the terms of the
|
|
434
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
793
435
|
|
|
794
|
-
|
|
436
|
+
---
|
|
795
437
|
|
|
796
|
-
|
|
438
|
+

|