ruby-whatsapp 0.3.1 → 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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -1
  3. data/README.md +292 -497
  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/deregister.rb +38 -0
  61. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  62. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  63. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  64. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  65. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  66. data/lib/ruby/whatsapp/business_phone_number/register.rb +105 -0
  67. data/lib/ruby/whatsapp/business_phone_number/request_code.rb +85 -0
  68. data/lib/ruby/whatsapp/business_phone_number/response.rb +39 -0
  69. data/lib/ruby/whatsapp/business_phone_number/transport.rb +27 -0
  70. data/lib/ruby/whatsapp/business_phone_number/verify_code.rb +63 -0
  71. data/lib/ruby/whatsapp/business_phone_number.rb +29 -0
  72. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  73. data/lib/ruby/whatsapp/path_building.rb +33 -0
  74. data/lib/ruby/whatsapp/subscribed_app/list.rb +28 -0
  75. data/lib/ruby/whatsapp/subscribed_app/response/app.rb +57 -0
  76. data/lib/ruby/whatsapp/subscribed_app/response/collection.rb +41 -0
  77. data/lib/ruby/whatsapp/subscribed_app/response/subscription.rb +49 -0
  78. data/lib/ruby/whatsapp/subscribed_app/response/unsubscription.rb +31 -0
  79. data/lib/ruby/whatsapp/subscribed_app/response.rb +19 -0
  80. data/lib/ruby/whatsapp/subscribed_app/subscribe.rb +33 -0
  81. data/lib/ruby/whatsapp/subscribed_app/transport.rb +24 -0
  82. data/lib/ruby/whatsapp/subscribed_app/unsubscribe.rb +26 -0
  83. data/lib/ruby/whatsapp/subscribed_app.rb +15 -0
  84. data/lib/ruby/whatsapp/version.rb +1 -1
  85. metadata +80 -1
data/README.md CHANGED
@@ -1,643 +1,438 @@
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
15
+ ---
10
16
 
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
- - [Webhooks](#webhooks)
34
- - [Development](#development)
35
- - [Contributing](#contributing)
36
- - [License](#license)
37
-
38
- ## Installation
39
-
40
- Add to your Gemfile:
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.
41
23
 
42
24
  ```ruby
43
- gem "ruby-whatsapp"
44
- ```
45
-
46
- Then run `bundle install`. Or install directly:
47
-
48
- ```bash
49
- gem install ruby-whatsapp
50
- ```
25
+ photo = "https://example.com/new-arrivals.jpg"
51
26
 
52
- ## Configuration
27
+ # Send it 📸
28
+ Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "Just landed")
53
29
 
54
- ```ruby
55
- Whatsapp.configure do |config|
56
- config.api_key = ENV.fetch("WHATSAPP_TOKEN") # a Meta system-user / app access token
57
- config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
58
- # optional overrides:
59
- # config.version = "v24.0"
60
- # config.host = "https://graph.facebook.com"
61
- # config.waba_id = ENV.fetch("WHATSAPP_WABA_ID") # for template management, see below
62
- # config.verify_token = ENV.fetch("WHATSAPP_VERIFY_TOKEN") # for webhooks, see below
63
- # config.app_secret = ENV.fetch("WHATSAPP_APP_SECRET") # for webhooks, see below
64
- end
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)
65
33
  ```
66
34
 
67
- `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.
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.
68
38
 
69
- ## Quick Start
70
-
71
- ```ruby
72
- require "ruby/whatsapp"
73
-
74
- Whatsapp.configure do |config|
75
- config.api_key = ENV.fetch("WHATSAPP_TOKEN")
76
- config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
77
- end
78
-
79
- response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
80
-
81
- response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
82
- ```
83
-
84
- ## Features
85
-
86
- - **Typed, validated message classes** for every Cloud API message kind — invalid payloads raise before any HTTP request is made.
87
- - **Persistent HTTP connections** via `HTTP.persistent`, reused across requests.
88
- - **Pluggable instrumentation** — pass a `Logger` to `Whatsapp::Client.new` to log every request/response.
89
- - **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.
90
- - **Structured response parsing** — `Whatsapp::Messages::Response` exposes typed `#contacts` and `#messages` instead of raw JSON.
91
- - **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.
92
- - **Inbound webhook parsing** — a typed object tree for all 19 documented Meta notification field types, plus signature verification.
93
-
94
- ## Sending Messages
95
-
96
- Every registered message kind gets its own `Whatsapp::Messages.send_<kind>!` class method — pass the recipient and the kind-specific fields as keyword arguments:
39
+ ## Table of Contents
97
40
 
98
- ```ruby
99
- Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!")
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"
100
85
  ```
101
86
 
102
- 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.
103
-
104
- If the kind is only known at runtime, drop down to the underlying factory these methods are built on:
105
-
106
- ```ruby
107
- Whatsapp::Messages.new(
108
- kind: :text, # or a variable
109
- payload: { to: "+15551234567", body: "Hello!" },
110
- client: Whatsapp::Client.new # optional — defaults to a new Client built from Whatsapp.configuration
111
- ).send!
87
+ ```bash
88
+ bundle install
112
89
  ```
113
90
 
114
- This form raises `Whatsapp::Messages::PayloadError` for an unknown `kind` (in addition to the same validation/request errors above).
91
+ Or standalone:
115
92
 
116
- | Method | Sends |
117
- | --- | --- |
118
- | [`send_text!`](#text) | Plain text with an optional link preview |
119
- | [`send_image!`](#image) | An image with an optional caption |
120
- | [`send_video!`](#video) | A video with an optional caption |
121
- | [`send_audio!`](#audio) | An audio clip |
122
- | [`send_document!`](#document) | A file with an optional caption and filename |
123
- | [`send_sticker!`](#sticker) | A sticker |
124
- | [`send_reaction!`](#reaction) | An emoji reaction to a previous message |
125
- | [`send_location!`](#location) | A latitude/longitude pin |
126
- | [`send_contacts!`](#contacts) | A rich contact card |
127
- | [`send_address!`](#address) | A delivery-address request/confirmation form (India & Singapore only) |
128
- | [`send_location_request!`](#location-request) | A prompt asking the user to share their location |
129
- | [`send_template!`](#template) | A pre-approved marketing/utility/authentication template |
130
- | [`send_interactive!`](#interactive) | Reply buttons, lists, CTA URLs, or carousels |
131
-
132
- ### Text
133
-
134
- ```ruby
135
- Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!", preview_url: false)
93
+ ```bash
94
+ gem install ruby-whatsapp
136
95
  ```
137
96
 
138
- `body` is required (max 4096 characters). `preview_url` defaults to `true`.
139
-
140
- ### Image
97
+ ## 🔧 Configuration
141
98
 
142
99
  ```ruby
143
- Whatsapp::Messages.send_image!(
144
- to: "+15551234567",
145
- link: "https://example.com/photo.jpg",
146
- caption: "Our new product"
147
- )
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
148
104
  ```
149
105
 
150
- Either `id` (an uploaded [media](#media) ID) or `link` is required. `caption` is optional (max 1024 characters).
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 |
151
116
 
152
- ### Video
117
+ **Which ID addresses what** — the most common source of confusion:
153
118
 
154
- ```ruby
155
- Whatsapp::Messages.send_video!(to: "+15551234567", id: "1234567890", caption: "Demo video")
156
119
  ```
120
+ phone_id ──► Messages · Media · BusinessPhoneNumber
121
+ permission: whatsapp_business_messaging
157
122
 
158
- Either `id` or `link` is required. `caption` is optional (max 1024 characters).
159
-
160
- ### Audio
161
-
162
- ```ruby
163
- Whatsapp::Messages.send_audio!(to: "+15551234567", link: "https://example.com/clip.mp3")
123
+ waba_id ──► MessageTemplates · SubscribedApp · BusinessPhoneNumber::Account
124
+ permission: whatsapp_business_management
164
125
  ```
165
126
 
166
- Either `id` or `link` is required. Audio messages do not support captions.
167
-
168
- ### Document
127
+ `BusinessPhoneNumber` appears on both sides: its onboarding actions address a phone
128
+ number, its `Account` actions the account that number belongs to.
169
129
 
170
- ```ruby
171
- Whatsapp::Messages.send_document!(
172
- to: "+15551234567",
173
- link: "https://example.com/invoice.pdf",
174
- filename: "invoice.pdf"
175
- )
176
- ```
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.
177
132
 
178
- Either `id` or `link` is required. `caption` (max 1024 characters) and `filename` are optional.
133
+ → **[Full configuration reference](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md)**
179
134
 
180
- ### Sticker
135
+ ## 🚀 Quick Start
181
136
 
182
137
  ```ruby
183
- Whatsapp::Messages.send_sticker!(to: "+15551234567", id: "STICKER_MEDIA_ID")
184
- ```
138
+ require "ruby/whatsapp"
185
139
 
186
- Either `id` or `link` is required — prefer `id` (from [`Media#upload`](#media)) over `link` for performance.
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
187
144
 
188
- ### Reaction
145
+ response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
189
146
 
190
- ```ruby
191
- Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "👍")
147
+ response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
148
+ response.contacts.first.wa_id # => "15551234567"
192
149
  ```
193
150
 
194
- `message_id` and `emoji` are required. Pass `emoji: ""` to remove a reaction you sent previously.
151
+ ## 📚 Documentation
195
152
 
196
- ### Location
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 |
197
165
 
198
- ```ruby
199
- Whatsapp::Messages.send_location!(to: "+15551234567", latitude: 37.4847, longitude: -122.1477, name: "Meta HQ")
200
- ```
166
+ Or start at the [documentation index](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/README.md).
201
167
 
202
- `latitude` and `longitude` are required. `name` and `address` are optional.
168
+ ## 💬 Sending Messages
203
169
 
204
- ### Contacts
170
+ Every registered kind gets its own `Whatsapp::Messages.send_<kind>!` class method:
205
171
 
206
172
  ```ruby
207
- Whatsapp::Messages.send_contacts!(
173
+ Whatsapp::Messages.send_interactive!(
208
174
  to: "+15551234567",
209
- contacts: [
210
- {
211
- name: { formatted_name: "Jane Doe", first_name: "Jane", last_name: "Doe" },
212
- phones: [{ phone: "+15550001111", type: "WORK" }],
213
- emails: [{ email: "jane@example.com", type: "WORK" }],
214
- org: { company: "Acme Inc." },
215
- birthday: "1990-05-12",
216
- },
217
- ]
175
+ type: :reply_buttons,
176
+ body: "Would you like to confirm your order?",
177
+ action: { buttons: [{ id: "confirm", title: "Confirm" },
178
+ { id: "cancel", title: "Cancel" }] }
218
179
  )
219
180
  ```
220
181
 
221
- `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.
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 |
222
198
 
223
- ### Address
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.
224
202
 
225
- ```ruby
226
- Whatsapp::Messages.send_address!(
227
- to: "+15551234567",
228
- body: "Please share your delivery address",
229
- country: "IN",
230
- footer: "Thanks for shopping with us",
231
- values: { name: "Jane Doe", city: "Bangalore" }
232
- )
233
- ```
203
+ → **[Sending messages](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md)**
234
204
 
235
- 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.
205
+ ## 📋 Managing Templates
236
206
 
237
- ### Location Request
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.
238
210
 
239
211
  ```ruby
240
- Whatsapp::Messages.send_location_request!(to: "+15551234567", body: "Can you share your delivery location?")
241
- ```
212
+ templates = Whatsapp::MessageTemplates.new # needs waba_id
242
213
 
243
- Prompts the user to share their current location. `body` is required.
244
-
245
- ### Template
246
-
247
- ```ruby
248
- Whatsapp::Messages.send_template!(
249
- to: "+15551234567",
250
- name: "order_confirmation",
251
- language: { code: "en_US" },
214
+ created = templates.create(
215
+ name: "order_confirmation", language: "en_US", category: "UTILITY",
252
216
  components: [
253
- { type: "body", parameters: [{ type: "text", text: "Jane" }, { type: "text", text: "#1234" }] },
217
+ { type: :body,
218
+ text: "Thank you, {{1}}! Your order number is {{2}}.",
219
+ example: ["Pablo", "860198-230332"] },
220
+ { type: :buttons, buttons: [
221
+ { type: :url, text: "Track order", url: "https://example.com/orders/{{1}}", example: "1234" },
222
+ ] },
254
223
  ]
255
224
  )
256
- ```
257
-
258
- `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`).
259
-
260
- ### Interactive
261
-
262
- 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:`.
263
225
 
264
- **Reply buttons** — up to 3 quick-reply buttons:
265
-
266
- ```ruby
267
- Whatsapp::Messages.send_interactive!(
268
- to: "+15551234567",
269
- type: :reply_buttons,
270
- body: "Would you like to confirm your order?",
271
- action: { buttons: [{ id: "confirm", title: "Confirm" }, { id: "cancel", title: "Cancel" }] }
272
- )
226
+ created.status # => "PENDING" — Meta reviews asynchronously, up to 24 hours
273
227
  ```
274
228
 
275
- **List** — a button that expands into up to 10 sections of up to 10 rows each:
229
+ Meta's rules are checked before the request, so a mistake raises immediately instead of
230
+ costing a review cycle:
276
231
 
277
232
  ```ruby
278
- Whatsapp::Messages.send_interactive!(
279
- to: "+15551234567",
280
- type: :list_buttons,
281
- body: "Choose a drink",
282
- action: {
283
- button: "Menu",
284
- sections: [
285
- { title: "Coffee", rows: [{ id: "espresso", title: "Espresso", description: "Strong & short" }] },
286
- ],
287
- }
288
- )
233
+ templates.create(name: "Order Confirmation", ...)
234
+ # => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
235
+ # characters and underscores
289
236
  ```
290
237
 
291
- **CTA URL button** — surfaces a link as a button instead of raw text:
292
-
293
- ```ruby
294
- Whatsapp::Messages.send_interactive!(
295
- to: "+15551234567",
296
- type: :url_button,
297
- body: "Check out our new arrivals",
298
- action: { name: "cta_url", display_text: "Shop now", url: "https://example.com/new" }
299
- )
300
- ```
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.
301
243
 
302
- **Media carousel** — 2 to 10 swipeable cards, each with its own header/body/CTA/quick-replies:
244
+ → **[Managing templates](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md)**
303
245
 
304
- ```ruby
305
- Whatsapp::Messages.send_interactive!(
306
- to: "+15551234567",
307
- type: :media_carousel,
308
- body: "Today's picks",
309
- action: {
310
- cards: [
311
- {
312
- header: { type: "image", link: "https://example.com/1.jpg" },
313
- body: "Item one",
314
- action: { name: "cta_url", display_text: "Buy", url: "https://example.com/1" },
315
- buttons: [{ quick_reply: { id: "q1", title: "Details" } }],
316
- },
317
- {
318
- header: { type: "image", link: "https://example.com/2.jpg" },
319
- body: "Item two",
320
- action: { name: "cta_url", display_text: "Buy", url: "https://example.com/2" },
321
- buttons: [{ quick_reply: { id: "q2", title: "Details" } }],
322
- },
323
- ],
324
- }
325
- )
326
- ```
246
+ ## 🔔 Webhooks
327
247
 
328
- **Product carousel** — 2 to 10 cards referencing products in your Meta catalog:
248
+ Meta pushes inbound messages, delivery statuses, and ~18 other notification types to a
249
+ callback URL you register. Inside a Rails app:
329
250
 
330
- ```ruby
331
- Whatsapp::Messages.send_interactive!(
332
- to: "+15551234567",
333
- type: :product_carousel,
334
- body: "Recommended for you",
335
- action: {
336
- cards: [
337
- { catalog_id: "123456789", product_retailer_id: "SKU-1" },
338
- { catalog_id: "123456789", product_retailer_id: "SKU-2" },
339
- ],
340
- }
341
- )
251
+ ```bash
252
+ bundle exec rake whatsapp:install:webhook
342
253
  ```
343
254
 
344
- > **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.
345
-
346
- ### Mark Message As Read
255
+ That copies a personalizable controller to
256
+ `app/controllers/whatsapp/webhooks_controller.rb` and prints the routes to add:
347
257
 
348
258
  ```ruby
349
- Whatsapp::Messages.mark_message_as_read!(message_id: "wamid.HBgLMTU1NTU1NTU1NTUV...")
350
- ```
351
-
352
- 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.
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
+ )
353
264
 
354
- ## Handling Responses
265
+ notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
355
266
 
356
- A successful `send!` returns a `Whatsapp::Messages::Response`, built from a payload shaped like:
267
+ notification.entry.each do |entry|
268
+ entry.changes.each { |change| WebhookJob.perform_later(change) }
269
+ end
357
270
 
358
- ```json
359
- {
360
- "messaging_product": "whatsapp",
361
- "contacts": [{ "input": "+15551234567", "wa_id": "15551234567" }],
362
- "messages": [{ "id": "wamid.HBgLMTU1NTU1NTU1NTUV..." }]
363
- }
271
+ head :ok
272
+ end
364
273
  ```
365
274
 
275
+ Every notification deserializes into typed objects — no raw hash spelunking:
276
+
366
277
  ```ruby
367
- response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
278
+ message = change.value.messages.first
368
279
 
369
- response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
370
- response.contacts.first.wa_id # => "15551234567"
280
+ message.from # => "16505551234"
281
+ message.id # => "wamid.HBg..."
282
+ message.body # => "Does it come in another color?"
371
283
  ```
372
284
 
373
- 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.
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.
287
+
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)
374
290
 
375
- ## Media
291
+ ## 📷 Media
376
292
 
377
293
  ```ruby
378
294
  media = Whatsapp::Media.new
379
295
 
380
296
  media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
381
- info = media.get_url(media_id: media_id) # => { "url" => ..., ... }
382
- media.download(url: info["url"], save_to: "photo.jpg") # only HTTPS + allowlisted Meta hosts
383
- media.delete(media_id: media_id) # => true
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
384
300
  ```
385
301
 
386
302
  `download` refuses to attach the API token to a non-HTTPS URL or a host that is not on
387
- `Configuration#media_host_allowlist`, so a token is never sent to an attacker-influenced URL.
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.
388
305
 
389
- ## Managing Templates
306
+ → **[Media](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md)**
390
307
 
391
- [Sending a template](#template) requires one that already exists and has been approved by
392
- Meta. `Whatsapp::MessageTemplates` creates and manages those templates, so they can live in
393
- your codebase and ship from CI instead of being clicked together in WhatsApp Manager.
308
+ ## 🔌 Subscribed Apps
394
309
 
395
- This is a different API from sending: it addresses your **WhatsApp Business Account**
396
- (`waba_id`, not `phone_id`) and needs the `whatsapp_business_management` permission.
310
+ Before your app receives any webhook notifications for a business account, it has to be
311
+ subscribed to it:
397
312
 
398
313
  ```ruby
399
- Whatsapp.configure do |config|
400
- config.api_key = ENV["WHATSAPP_API_KEY"]
401
- config.waba_id = ENV["WHATSAPP_WABA_ID"]
402
- end
403
-
404
- templates = Whatsapp::MessageTemplates.new
314
+ Whatsapp::SubscribedApp::Subscribe.call # start webhook delivery
315
+ Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
316
+ Whatsapp::SubscribedApp::Unsubscribe.call # stop it
405
317
  ```
406
318
 
407
- ### Creating
319
+ Tech Providers routing several accounts to different callback URLs pass an
320
+ `override_callback_uri:`.
408
321
 
409
- ```ruby
410
- created = templates.create(
411
- name: "order_confirmation", # lowercase alphanumerics and underscores only
412
- language: "en_US",
413
- category: "UTILITY", # UTILITY | MARKETING | AUTHENTICATION
414
- components: [
415
- { type: :header, format: "TEXT", text: "Order {{1}} confirmed", example: ["#1234"] },
416
- { type: :body,
417
- text: "Thank you, {{1}}! Your order number is {{2}}.",
418
- example: ["Pablo", "860198-230332"] },
419
- { type: :footer, text: "Thanks for shopping with us" },
420
- { type: :buttons, buttons: [
421
- { type: :phone_number, text: "Call", phone_number: "15550051310" },
422
- { type: :url, text: "Track order", url: "https://example.com/orders/{{1}}", example: "1234" },
423
- ] },
424
- ]
425
- )
322
+ → **[Subscribed apps](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md)**
426
323
 
427
- created.id # => "1259544702043867"
428
- created.status # => "PENDING" — Meta reviews asynchronously, up to 24 hours
429
- created.pending? # => true
430
- ```
324
+ ## 📞 Business Phone Numbers
431
325
 
432
- Named parameters read better than positional ones for anything non-trivial:
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.
433
328
 
434
- ```ruby
435
- templates.create(
436
- name: "order_confirmation", language: "en_US", category: "UTILITY",
437
- parameter_format: "NAMED",
438
- components: [
439
- { type: :body,
440
- text: "Thank you, {{first_name}}! Your order number is {{order_number}}.",
441
- example: { first_name: "Pablo", order_number: "860198-230332" } },
442
- ]
443
- )
444
329
  ```
330
+ RequestCode -> VerifyCode -> Register (onboarding)
331
+ (send OTP) (confirm it) (activate on Cloud API)
445
332
 
446
- Meta's rules are checked before the request, so a mistake raises immediately instead of
447
- costing a review cycle:
448
-
449
- ```ruby
450
- templates.create(name: "Order Confirmation", ...)
451
- # => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
452
- # characters and underscores
453
-
454
- templates.create(..., components: [{ type: :body, text: "Hi {{1}} and {{2}}", example: ["Pablo"] }])
455
- # => ActiveModel::ValidationError: Example does not match the body text:
456
- # 2 placeholders but 1 example
333
+ Deregister (the reverse switch)
457
334
  ```
458
335
 
459
- ### Listing, reading, editing, deleting
460
-
461
336
  ```ruby
462
- page = templates.list(status: %w[APPROVED], fields: %w[name category status], limit: 25)
463
- page.select(&:approved?).map(&:name) # Collection is Enumerable
464
- page.remaining # headroom against your account's template cap
465
- templates.list(after: page.next_cursor) if page.next_cursor
466
-
467
- template = templates.find(template_id: "1259544702043867")
468
- template.status # => "APPROVED"
469
- template.editable? # => true (APPROVED, REJECTED and PAUSED templates can be edited)
470
-
471
- templates.update(template_id: template.id, category: "MARKETING") # => true
472
- templates.update(template_id: template.id, components: [...]) # full replacement
473
-
474
- templates.delete(name: "order_confirmation") # every language variant
475
- templates.delete(hsm_id: "1407680676729941", name: "order_confirmation")
476
- templates.delete(hsm_ids: %w[1387372356726668 1304694804498707]) # up to 100
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
477
341
  ```
478
342
 
479
- Editing an approved template re-submits it for review but it keeps working meanwhile.
480
- Approved templates allow 10 edits per 30 days and 1 per 24 hours. Deleting an approved
481
- template blocks reuse of its name for 30 days.
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.
482
346
 
483
- ### Other template kinds
484
-
485
- **Authentication (OTP)** templates invert the usual shape — Meta supplies and localises the
486
- wording, so you pass flags rather than text, and `upsert` creates every language at once:
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`:
487
349
 
488
350
  ```ruby
489
- templates.upsert(
490
- name: "authentication_code", languages: %w[en_US es_ES fr], category: "AUTHENTICATION",
491
- components: [
492
- { type: :body, add_security_recommendation: true },
493
- { type: :footer, code_expiration_minutes: 15 },
494
- { type: :buttons, buttons: [{ type: :otp, otp_type: "COPY_CODE" }] },
495
- ]
496
- )
497
- ```
351
+ details = Whatsapp::BusinessPhoneNumber::Account::Get.call(fields: %w[name account_review_status])
498
352
 
499
- **Marketing carousels** take 2–10 cards that must all share the same structure:
353
+ details.name # => "Acme Corp"
354
+ details.approved? # => true
500
355
 
501
- ```ruby
502
- card = {
503
- header: { format: "IMAGE", header_handle: "4::aW..." },
504
- body: { text: "Rare {{1}} in stock!", example: ["Tulips"] },
505
- buttons: [{ type: :quick_reply, text: "More like this" }],
506
- }
507
-
508
- templates.create(
509
- name: "summer_carousel", language: "en_US", category: "MARKETING",
510
- components: [
511
- { type: :body, text: "Summer is here, {{1}}!", example: ["Pablo"] },
512
- { type: :carousel, cards: [card, card] },
513
- ]
514
- )
356
+ Whatsapp::BusinessPhoneNumber::Account::Update.call(name: "Acme Corporation").success # => true
515
357
  ```
516
358
 
517
- **Limited-time offers** add a countdown and a coupon code (marketing only; footers are not
518
- allowed and the body drops to 600 characters):
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:
519
361
 
520
362
  ```ruby
521
- templates.create(
522
- name: "spring_offer", language: "en_US", category: "MARKETING",
523
- components: [
524
- { type: :header, format: "IMAGE", header_handle: "4::aW..." },
525
- { type: :limited_time_offer, text: "Expiring offer!", has_expiration: true },
526
- { type: :body, text: "Good news, {{1}}! Use code {{2}} for 25% off.",
527
- example: ["Pablo", "SPRING25"] },
528
- { type: :buttons, buttons: [
529
- { type: :copy_code, example: "SPRING25" },
530
- { type: :url, text: "Book now!", url: "https://example.com/o?c={{1}}", example: "n3mtql" },
531
- ] },
532
- ]
533
- )
534
- ```
535
-
536
- **Library templates** are pre-written and pre-approved by Meta, so they usually come back
537
- `APPROVED` immediately:
363
+ Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
364
+ # => "Open daily 9-5"
538
365
 
539
- ```ruby
540
- templates.create_from_library(
541
- name: "my_delivery_update", language: "en_US", category: "UTILITY",
542
- library_template_name: "delivery_update_1",
543
- library_template_button_inputs: [
544
- { type: "URL", url: { base_url: "https://example.com/{{1}}",
545
- url_suffix_example: "https://example.com/order_update" } },
546
- ]
547
- )
366
+ Whatsapp::BusinessPhoneNumber::Profile::Update.call(
367
+ about: "Open daily 9-6", vertical: "RETAIL", websites: ["https://acme.test"]
368
+ ).success
369
+ # => true
548
370
  ```
549
371
 
550
- ### Notes
551
-
552
- - **Media headers** take a `header_handle` you already hold. Producing one needs Meta's
553
- Resumable Upload API, which this gem does not wrap — note it is a different flow from
554
- [`Media#upload`](#media), whose media IDs are for *sending*, not template creation.
555
- - **Review outcomes arrive by webhook**, not by polling: see
556
- `message_template_status_update` and friends under [Webhooks](#webhooks).
557
- - Text containing `#{{1}}` needs single quotes in Ruby, or `#{` starts interpolation.
372
+ Character limits, the 21-value vertical enum, and the two-website cap are all checked
373
+ before the request goes out.
558
374
 
559
- ## Webhooks
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)
560
378
 
561
- Meta pushes inbound messages, delivery statuses, and ~18 other account/template
562
- notification types to a callback URL you register. Inside a Rails app:
379
+ ## 🚨 Errors
563
380
 
564
- ```bash
565
- bundle exec rake whatsapp:install:webhook
566
- ```
381
+ Everything descends from `Whatsapp::Error`, and each module raises its own subclass so
382
+ you can rescue narrowly:
567
383
 
568
- This copies a personalizable controller to `app/controllers/whatsapp/webhooks_controller.rb`
569
- and prints the routes and configuration you still need to add by hand:
570
-
571
- ```ruby
572
- # config/routes.rb
573
- get "/whatsapp/webhooks", to: "whatsapp/webhooks#verify"
574
- post "/whatsapp/webhooks", to: "whatsapp/webhooks#receive"
575
-
576
- # config/initializers/whatsapp.rb
577
- Whatsapp.configure do |config|
578
- config.verify_token = Rails.application.credentials.whatsapp_verify_token
579
- config.app_secret = Rails.application.credentials.whatsapp_app_secret
580
- end
581
- ```
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` |
582
392
 
583
- The generated controller is yours to edit — it deserializes every notification into typed
584
- objects and leaves a `# TODO` where your own handling goes:
393
+ Local validation failures raise `ActiveModel::ValidationError` instead — they happen at
394
+ construction time, before any network call, and carry per-attribute detail:
585
395
 
586
396
  ```ruby
587
- class Whatsapp::WebhooksController < ApplicationController
588
- def verify
589
- challenge = Whatsapp::Webhook::Verification.call(params: params)
590
- challenge ? render(plain: challenge) : head(:forbidden)
591
- end
592
-
593
- def receive
594
- raw_body = request.body.read
595
- return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
596
- payload: raw_body, header: request.headers["X-Hub-Signature-256"]
597
- )
598
-
599
- notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
600
- # notification.entry.each { |entry| entry.changes.each { |change| WebhookJob.perform_later(change) } }
601
-
602
- head :ok
603
- end
604
- end
397
+ rescue ActiveModel::ValidationError => e
398
+ e.model.errors.full_messages
399
+ # => ["Caption is too long (maximum is 1024 characters)"]
605
400
  ```
606
401
 
607
- `notification.entry.first.changes.first` gives you a `field` (e.g. `"messages"`) and a typed
608
- `value` — for the `messages` field, `value.messages` and `value.statuses` are arrays of typed
609
- message/status objects (`Whatsapp::Webhook::Message::Text`, `Whatsapp::Webhook::Status`, etc.).
610
- The other ~18 documented fields (`account_alerts`, `message_template_status_update`, and so on)
611
- each deserialize into their own best-effort typed class — see
612
- [`lib/ruby/whatsapp/webhook/CLAUDE.md`](lib/ruby/whatsapp/webhook/CLAUDE.md) for the full field
613
- reference and confidence notes, since Meta's docs don't publish a JSON schema for most of them.
402
+ → **[Errors](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md)**
614
403
 
615
- **Multi-tenant apps** (many customers, each with their own Meta App) pass `verify_token:`/
616
- `app_secret:` explicitly instead of relying on the global config default:
404
+ ## 🧩 Compatibility
617
405
 
618
- ```ruby
619
- account = Account.find_by!(slug: params[:account_slug])
620
- Whatsapp::Webhook::Verification.call(params:, verify_token: account.verify_token)
621
- Whatsapp::Webhook::Signature.valid?(payload: raw_body, header:, app_secret: account.app_secret)
622
- ```
623
-
624
- 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` |
625
412
 
626
- ## Development
413
+ ## 🔨 Development
627
414
 
628
415
  After checking out the repo, run `bundle install`, then:
629
416
 
630
417
  ```bash
631
- bundle exec rake # run the specs and RuboCop (the default task)
418
+ bundle exec rake # specs + RuboCop (the default task)
632
419
  bundle exec rspec # specs only
633
420
  bundle exec rubocop # lint only
634
421
  bin/console # interactive prompt
635
422
  ```
636
423
 
637
- ## 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
638
432
 
639
- 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).
640
435
 
641
- ## License
436
+ ---
642
437
 
643
- 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)