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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +276 -634
  4. data/docs/README.md +73 -0
  5. data/docs/business_phone_number/README.md +232 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/profile.md +220 -0
  8. data/docs/configuration.md +172 -0
  9. data/docs/errors.md +208 -0
  10. data/docs/media/README.md +161 -0
  11. data/docs/message_templates/README.md +206 -0
  12. data/docs/message_templates/authentication.md +107 -0
  13. data/docs/message_templates/carousel.md +103 -0
  14. data/docs/message_templates/components.md +179 -0
  15. data/docs/message_templates/library.md +98 -0
  16. data/docs/message_templates/limited_time_offer.md +94 -0
  17. data/docs/message_templates/responses.md +173 -0
  18. data/docs/message_templates/standard.md +138 -0
  19. data/docs/messages/README.md +135 -0
  20. data/docs/messages/address.md +86 -0
  21. data/docs/messages/audio.md +43 -0
  22. data/docs/messages/contacts.md +129 -0
  23. data/docs/messages/document.md +49 -0
  24. data/docs/messages/image.md +59 -0
  25. data/docs/messages/interactive.md +281 -0
  26. data/docs/messages/location.md +51 -0
  27. data/docs/messages/location_request.md +59 -0
  28. data/docs/messages/mark_message_as_read.md +84 -0
  29. data/docs/messages/reaction.md +59 -0
  30. data/docs/messages/sticker.md +40 -0
  31. data/docs/messages/template.md +166 -0
  32. data/docs/messages/text.md +55 -0
  33. data/docs/messages/video.md +41 -0
  34. data/docs/subscribed_app/README.md +139 -0
  35. data/docs/webhooks/README.md +209 -0
  36. data/docs/webhooks/account_alerts.md +44 -0
  37. data/docs/webhooks/account_review_update.md +29 -0
  38. data/docs/webhooks/account_update.md +54 -0
  39. data/docs/webhooks/automatic_events.md +47 -0
  40. data/docs/webhooks/business_capability_update.md +40 -0
  41. data/docs/webhooks/history.md +44 -0
  42. data/docs/webhooks/message_template_components_update.md +46 -0
  43. data/docs/webhooks/message_template_quality_update.md +49 -0
  44. data/docs/webhooks/message_template_status_update.md +55 -0
  45. data/docs/webhooks/messages.md +385 -0
  46. data/docs/webhooks/partner_solutions.md +35 -0
  47. data/docs/webhooks/payment_configuration_update.md +40 -0
  48. data/docs/webhooks/phone_number_name_update.md +44 -0
  49. data/docs/webhooks/phone_number_quality_update.md +40 -0
  50. data/docs/webhooks/security.md +43 -0
  51. data/docs/webhooks/smb_app_state_sync.md +53 -0
  52. data/docs/webhooks/smb_message_echoes.md +54 -0
  53. data/docs/webhooks/template_category_update.md +52 -0
  54. data/docs/webhooks/user_preferences.md +48 -0
  55. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  56. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  57. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  58. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  59. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  60. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  61. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  62. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  63. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  64. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  65. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  66. data/lib/ruby/whatsapp/business_phone_number/transport.rb +11 -12
  67. data/lib/ruby/whatsapp/business_phone_number.rb +11 -4
  68. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  69. data/lib/ruby/whatsapp/path_building.rb +33 -0
  70. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  71. data/lib/ruby/whatsapp/version.rb +1 -1
  72. metadata +63 -1
data/README.md CHANGED
@@ -1,450 +1,229 @@
1
- # ruby-whatsapp
1
+ <h1 align="center">ruby-whatsapp</h1>
2
2
 
3
- [![Gem Version](https://img.shields.io/gem/v/ruby-whatsapp.svg)](https://rubygems.org/gems/ruby-whatsapp)
4
- [![Build Status](https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml/badge.svg)](https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml)
5
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
3
+ <p align="center">
4
+ <strong>A small, dependency-light Ruby client for the Meta WhatsApp Cloud API.</strong>
5
+ </p>
6
6
 
7
- A small, dependency-light Ruby client for the [Meta WhatsApp Cloud API](https://developers.facebook.com/documentation/business-messaging/whatsapp). Every message type the Cloud API supports — text, media, location, contacts, templates, and the full family of interactive messages (reply buttons, lists, CTA URLs, carousels) — is modeled as its own `ActiveModel`-validated Ruby class, so a malformed payload raises locally instead of round-tripping to Meta's servers first. The gem also handles media upload/download and parses API responses into typed objects.
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
- ## Table of Contents
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
- ## Quick Start
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
- require "ruby/whatsapp"
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
- - **Typed, validated message classes** for every Cloud API message kind — invalid payloads raise before any HTTP request is made.
89
- - **Persistent HTTP connections** via `HTTP.persistent`, reused across requests.
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
- ## Sending Messages
99
-
100
- Every registered message kind gets its own `Whatsapp::Messages.send_<kind>!` class method pass the recipient and the kind-specific fields as keyword arguments:
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
- Each `send_<kind>!` method accepts an optional `client:` keyword (defaults to a new `Whatsapp::Client` built from `Whatsapp.configuration`) and returns a `Whatsapp::Messages::Response` (see [Handling Responses](#handling-responses)). It raises `ActiveModel::ValidationError` if any field fails validation, and `Whatsapp::RequestError` on a non-2xx API response.
107
-
108
- If the kind is only known at runtime, drop down to the underlying factory these methods are built on:
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
- ```ruby
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
- ```ruby
159
- Whatsapp::Messages.send_video!(to: "+15551234567", id: "1234567890", caption: "Demo video")
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
- Either `id` or `link` is required. `caption` is optional (max 1024 characters).
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
- Either `id` or `link` is required. Audio messages do not support captions.
171
-
172
- ### Document
91
+ Or standalone:
173
92
 
174
- ```ruby
175
- Whatsapp::Messages.send_document!(
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
- Either `id` or `link` is required. `caption` (max 1024 characters) and `filename` are optional.
183
-
184
- ### Sticker
97
+ ## 🔧 Configuration
185
98
 
186
99
  ```ruby
187
- Whatsapp::Messages.send_sticker!(to: "+15551234567", id: "STICKER_MEDIA_ID")
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
- Either `id` or `link` is required — prefer `id` (from [`Media#upload`](#media)) over `link` for performance.
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
- ### Reaction
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
- `message_id` and `emoji` are required. Pass `emoji: ""` to remove a reaction you sent previously.
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
- `latitude` and `longitude` are required. `name` and `address` are optional.
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
- ```ruby
211
- Whatsapp::Messages.send_contacts!(
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
- `contacts` must contain exactly one contact — the Cloud API currently allows only one per message. Each contact requires a `name.formatted_name`; `phones`, `emails`, `addresses`, `org`, `urls`, and `birthday` are all optional.
133
+ **[Full configuration reference](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md)**
226
134
 
227
- ### Address
135
+ ## 🚀 Quick Start
228
136
 
229
137
  ```ruby
230
- Whatsapp::Messages.send_address!(
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
- Only available for businesses based in India (`country: "IN"`) or Singapore (`country: "SG"`). `body` and `country` are required; `footer`, `values`, and `saved_addresses` are optional.
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
- ### Location Request
145
+ response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
242
146
 
243
- ```ruby
244
- Whatsapp::Messages.send_location_request!(to: "+15551234567", body: "Can you share your delivery location?")
147
+ response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
148
+ response.contacts.first.wa_id # => "15551234567"
245
149
  ```
246
150
 
247
- Prompts the user to share their current location. `body` is required.
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
- `name` and `language` are required `language.code` is validated against `Whatsapp::Utils::LanguageCodes`. `components` is optional and follows the Cloud API's `header`/`body`/`button` component shape, each with typed `parameters` (`text`, `currency`, `date_time`, `image`, `document`, `video`, `location`, or `payload`).
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
- ### Interactive
166
+ Or start at the [documentation index](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/README.md).
265
167
 
266
- Interactive messages share one class, `Whatsapp::Messages::Interactive`; the `type:` you pass selects which action shape `action:` must match. All variants accept an optional `header:` (`text`, `image`, `video`, or `document`) and `footer:`.
168
+ ## 💬 Sending Messages
267
169
 
268
- **Reply buttons** up to 3 quick-reply buttons:
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" }, { id: "cancel", title: "Cancel" }] }
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
- **CTA URL button** surfaces a link as a button instead of raw text:
296
-
297
- ```ruby
298
- Whatsapp::Messages.send_interactive!(
299
- to: "+15551234567",
300
- type: :url_button,
301
- body: "Check out our new arrivals",
302
- action: { name: "cta_url", display_text: "Shop now", url: "https://example.com/new" }
303
- )
304
- ```
305
-
306
- **Media carousel** 2 to 10 swipeable cards, each with its own header/body/CTA/quick-replies:
307
-
308
- ```ruby
309
- Whatsapp::Messages.send_interactive!(
310
- to: "+15551234567",
311
- type: :media_carousel,
312
- body: "Today's picks",
313
- action: {
314
- cards: [
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
- ## Managing Templates
203
+ **[Sending messages](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md)**
394
204
 
395
- [Sending a template](#template) requires one that already exists and has been approved by
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
- This is a different API from sending: it addresses your **WhatsApp Business Account**
400
- (`waba_id`, not `phone_id`) and needs the `whatsapp_business_management` permission.
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.configure do |config|
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", # lowercase alphanumerics and underscores only
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
- ### Listing, reading, editing, deleting
464
-
465
- ```ruby
466
- page = templates.list(status: %w[APPROVED], fields: %w[name category status], limit: 25)
467
- page.select(&:approved?).map(&:name) # Collection is Enumerable
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.update(template_id: template.id, category: "MARKETING") # => true
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
- Editing an approved template re-submits it for review but it keeps working meanwhile.
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
- ### Other template kinds
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
- **Authentication (OTP)** templates invert the usual shape — Meta supplies and localises the
490
- wording, so you pass flags rather than text, and `upsert` creates every language at once:
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
- **Marketing carousels** take 2–10 cards that must all share the same structure:
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
- card = {
507
- header: { format: "IMAGE", header_handle: "4::aW..." },
508
- body: { text: "Rare {{1}} in stock!", example: ["Tulips"] },
509
- buttons: [{ type: :quick_reply, text: "More like this" }],
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
- ```ruby
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
- **Library templates** are pre-written and pre-approved by Meta, so they usually come back
541
- `APPROVED` immediately:
267
+ notification.entry.each do |entry|
268
+ entry.changes.each { |change| WebhookJob.perform_later(change) }
269
+ end
542
270
 
543
- ```ruby
544
- templates.create_from_library(
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
- ### Notes
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
- Whatsapp.configure do |config|
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
- Whatsapp::SubscribedApp::Subscribe.call # subscribe this app
580
- Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
581
- Whatsapp::SubscribedApp::Unsubscribe.call # stop all webhook delivery
280
+ message.from # => "16505551234"
281
+ message.id # => "wamid.HBg..."
282
+ message.body # => "Does it come in another color?"
582
283
  ```
583
284
 
584
- ### Subscribing
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
- ```ruby
587
- result = Whatsapp::SubscribedApp::Subscribe.call
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
- Tech Providers routing several WABAs' notifications to different callback URLs pass an
593
- override instead of relying on the one callback URL configured on the app itself:
291
+ ## 📷 Media
594
292
 
595
293
  ```ruby
596
- Whatsapp::SubscribedApp::Subscribe.call(
597
- override_callback_uri: "https://example.com/webhooks/acme_corp",
598
- verify_token: "a-per-account-secret"
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
- ### Listing
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
- ```ruby
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
- Whatsapp::SubscribedApp::List.call(fields: %w[id name]) # restrict the fields returned
610
- ```
308
+ ## 🔌 Subscribed Apps
611
309
 
612
- ### Unsubscribing
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::Unsubscribe.call.success # => true
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
- Stops all webhook deliveries for this WABA immediately.
319
+ Tech Providers routing several accounts to different callback URLs pass an
320
+ `override_callback_uri:`.
619
321
 
620
- Every action accepts an optional `client:` keyword (defaults to a new
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
- ## Registering Business Phone Numbers
324
+ ## 📞 Business Phone Numbers
626
325
 
627
- A business phone number is unusable with Cloud API until it is **registered** —
628
- registration is the prerequisite that makes sending, media, and templates work for that
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").success # => true
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
- `code_method` must be `"SMS"` or `"VOICE"`; `language` is the locale for the
662
- verification message (e.g. `"en_US"`). Both are required an invalid or missing value
663
- raises `ActiveModel::ValidationError` before any request is made.
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
- ### Verifying the code
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
- result = Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456")
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
- ### Registering
353
+ details.name # => "Acme Corp"
354
+ details.approved? # => true
674
355
 
675
- ```ruby
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
- `pin` must be exactly 6 digits — the existing two-step verification PIN if one is
681
- already set, otherwise the PIN to set. An invalid PIN raises
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::Register.call(pin: "212834", data_localization_region: "CH")
689
- ```
690
-
691
- ### Deregistering
363
+ Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
364
+ # => "Open daily 9-5"
692
365
 
693
- ```ruby
694
- Whatsapp::BusinessPhoneNumber::Deregister.call.success # => true
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
- Deregistering makes the number unusable with Cloud API and disables local storage — it
698
- does **not** delete the number or its message history. A number in use with both Cloud
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
- > **Rate limits:** `Register`/`Deregister` are capped at 10 requests per business number
708
- > in a 72-hour moving window; exceeding it returns error `133016` and blocks the
709
- > operation for the next 72 hours. `RequestCode`/`VerifyCode` document only "standard
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
- ## Webhooks
379
+ ## 🚨 Errors
713
380
 
714
- Meta pushes inbound messages, delivery statuses, and ~18 other account/template
715
- notification types to a callback URL you register. Inside a Rails app:
381
+ Everything descends from `Whatsapp::Error`, and each module raises its own subclass so
382
+ you can rescue narrowly:
716
383
 
717
- ```bash
718
- bundle exec rake whatsapp:install:webhook
719
- ```
720
-
721
- This copies a personalizable controller to `app/controllers/whatsapp/webhooks_controller.rb`
722
- and prints the routes and configuration you still need to add by hand:
723
-
724
- ```ruby
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
- The generated controller is yours to edit it deserializes every notification into typed
737
- objects and leaves a `# TODO` where your own handling goes:
393
+ Local validation failures raise `ActiveModel::ValidationError` insteadthey happen at
394
+ construction time, before any network call, and carry per-attribute detail:
738
395
 
739
396
  ```ruby
740
- class Whatsapp::WebhooksController < ApplicationController
741
- def verify
742
- challenge = Whatsapp::Webhook::Verification.call(params: params)
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
- `notification.entry.first.changes.first` gives you a `field` (e.g. `"messages"`) and a typed
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
- ```ruby
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
- See Meta's [webhook documentation](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview) for the full notification catalog, retry behavior, and signature details.
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 # run the specs and RuboCop (the default task)
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
- Bug reports and pull requests are welcome on GitHub at https://github.com/saleszera/ruby-whatsapp.
433
+ Available as open source under the terms of the
434
+ [MIT License](https://opensource.org/licenses/MIT).
793
435
 
794
- ## License
436
+ ---
795
437
 
796
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
438
+ ![that's all folks](https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExaWlsc3ZkcGhiNWlmMXgwZmNhdnAwaWFleDM5YjZlZmRqa2MxcnM0NCZlcD12MV9naWZzX3NlYXJjaCZjdD1n/xUPOqo6E1XvWXwlCyQ/giphy.gif)