ruby-whatsapp 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +152 -0
  3. data/README.md +322 -617
  4. data/docs/README.md +76 -0
  5. data/docs/business_phone_number/README.md +243 -0
  6. data/docs/business_phone_number/account.md +170 -0
  7. data/docs/business_phone_number/contact_book.md +90 -0
  8. data/docs/business_phone_number/profile.md +220 -0
  9. data/docs/business_phone_number/username.md +157 -0
  10. data/docs/configuration.md +177 -0
  11. data/docs/errors.md +219 -0
  12. data/docs/media/README.md +161 -0
  13. data/docs/message_templates/README.md +206 -0
  14. data/docs/message_templates/authentication.md +107 -0
  15. data/docs/message_templates/carousel.md +103 -0
  16. data/docs/message_templates/components.md +215 -0
  17. data/docs/message_templates/library.md +98 -0
  18. data/docs/message_templates/limited_time_offer.md +94 -0
  19. data/docs/message_templates/responses.md +173 -0
  20. data/docs/message_templates/standard.md +138 -0
  21. data/docs/messages/README.md +212 -0
  22. data/docs/messages/address.md +86 -0
  23. data/docs/messages/audio.md +43 -0
  24. data/docs/messages/contacts.md +129 -0
  25. data/docs/messages/document.md +49 -0
  26. data/docs/messages/image.md +59 -0
  27. data/docs/messages/interactive.md +342 -0
  28. data/docs/messages/location.md +51 -0
  29. data/docs/messages/location_request.md +59 -0
  30. data/docs/messages/mark_message_as_read.md +84 -0
  31. data/docs/messages/reaction.md +59 -0
  32. data/docs/messages/sticker.md +40 -0
  33. data/docs/messages/template.md +193 -0
  34. data/docs/messages/text.md +55 -0
  35. data/docs/messages/video.md +41 -0
  36. data/docs/schedule/README.md +183 -0
  37. data/docs/subscribed_app/README.md +139 -0
  38. data/docs/webhooks/README.md +217 -0
  39. data/docs/webhooks/account_alerts.md +44 -0
  40. data/docs/webhooks/account_review_update.md +29 -0
  41. data/docs/webhooks/account_update.md +54 -0
  42. data/docs/webhooks/automatic_events.md +47 -0
  43. data/docs/webhooks/business_capability_update.md +40 -0
  44. data/docs/webhooks/business_username_updates.md +55 -0
  45. data/docs/webhooks/history.md +107 -0
  46. data/docs/webhooks/message_template_components_update.md +46 -0
  47. data/docs/webhooks/message_template_quality_update.md +49 -0
  48. data/docs/webhooks/message_template_status_update.md +55 -0
  49. data/docs/webhooks/messages.md +520 -0
  50. data/docs/webhooks/partner_solutions.md +35 -0
  51. data/docs/webhooks/payment_configuration_update.md +40 -0
  52. data/docs/webhooks/phone_number_name_update.md +44 -0
  53. data/docs/webhooks/phone_number_quality_update.md +40 -0
  54. data/docs/webhooks/security.md +43 -0
  55. data/docs/webhooks/smb_app_state_sync.md +91 -0
  56. data/docs/webhooks/smb_message_echoes.md +91 -0
  57. data/docs/webhooks/template_category_update.md +52 -0
  58. data/docs/webhooks/user_id_update.md +84 -0
  59. data/docs/webhooks/user_preferences.md +87 -0
  60. data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
  61. data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
  62. data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
  63. data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
  64. data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
  65. data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
  66. data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
  67. data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
  68. data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
  69. data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
  70. data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
  71. data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
  72. data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
  73. data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
  74. data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
  75. data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
  76. data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
  77. data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
  78. data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
  79. data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
  80. data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
  81. data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
  82. data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
  83. data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
  84. data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
  85. data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
  86. data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
  87. data/lib/ruby/whatsapp/message_templates.rb +2 -6
  88. data/lib/ruby/whatsapp/messages/base.rb +47 -5
  89. data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
  90. data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
  91. data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
  92. data/lib/ruby/whatsapp/path_building.rb +33 -0
  93. data/lib/ruby/whatsapp/schedule/create.rb +101 -0
  94. data/lib/ruby/whatsapp/schedule/list.rb +50 -0
  95. data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
  96. data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
  97. data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
  98. data/lib/ruby/whatsapp/schedule/response.rb +18 -0
  99. data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
  100. data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
  101. data/lib/ruby/whatsapp/schedule.rb +15 -0
  102. data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
  103. data/lib/ruby/whatsapp/version.rb +1 -1
  104. data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
  105. data/lib/ruby/whatsapp/webhook/change.rb +3 -1
  106. data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
  107. data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
  108. data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
  109. data/lib/ruby/whatsapp/webhook/history.rb +73 -34
  110. data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
  111. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
  112. data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
  113. data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
  114. data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
  115. data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
  116. data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
  117. data/lib/ruby/whatsapp/webhook/message.rb +3 -0
  118. data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
  119. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
  120. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
  121. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
  122. data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
  123. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
  124. data/lib/ruby/whatsapp/webhook/status.rb +23 -2
  125. data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
  126. data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
  127. data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
  128. data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
  129. data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
  130. metadata +100 -1
data/README.md CHANGED
@@ -1,450 +1,245 @@
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.
70
-
71
- ## Quick Start
72
-
73
- ```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
- ```
15
+ ---
85
16
 
86
- ## Features
87
-
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.
97
-
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:
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.
101
23
 
102
24
  ```ruby
103
- Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!")
104
- ```
25
+ photo = "https://example.com/new-arrivals.jpg"
105
26
 
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.
27
+ # Send it 📸
28
+ Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "Just landed")
107
29
 
108
- If the kind is only known at runtime, drop down to the underlying factory these methods are built on:
109
-
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!
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)
116
33
  ```
117
34
 
118
- This form raises `Whatsapp::Messages::PayloadError` for an unknown `kind` (in addition to the same validation/request errors above).
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.
119
38
 
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
39
+ ## Table of Contents
145
40
 
146
- ```ruby
147
- Whatsapp::Messages.send_image!(
148
- to: "+15551234567",
149
- link: "https://example.com/photo.jpg",
150
- caption: "Our new product"
151
- )
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
+ - [📅 Scheduling Campaigns](#-scheduling-campaigns)
52
+ - [📞 Business Phone Numbers](#-business-phone-numbers)
53
+ - [🚨 Errors](#-errors)
54
+ - [🧩 Compatibility](#-compatibility)
55
+ - [🔨 Development](#-development)
56
+ - [🤝 Contributing](#-contributing)
57
+ - [📄 License](#-license)
58
+
59
+ ## ✨ Why ruby-whatsapp
60
+
61
+ - **Typed, validated message classes** for every Cloud API message kind — invalid
62
+ payloads raise before any HTTP request is made, with the failing attribute named.
63
+ - **Template rules checked client-side.** Meta's documented constraints — name format,
64
+ character limits, placeholder/example matching, quick-reply contiguity, carousel
65
+ structure — are enforced locally, so a mistake costs a validation error instead of a
66
+ 24-hour review cycle.
67
+ - **Hardened media downloads.** `Media#download` refuses to attach your bearer token to
68
+ a non-HTTPS URL or a host outside an allowlist, so a token can never leak to an
69
+ attacker-influenced URL.
70
+ - **Secrets stay out of logs.** `api_key`, `app_secret`, and `verify_token` are
71
+ redacted from every `#inspect`, including credentials in transit like a
72
+ registration PIN.
73
+ - **Inbound webhooks, fully typed.** An object tree for all 21 documented Meta
74
+ notification fields, plus HMAC signature verification and a Rails controller
75
+ generator.
76
+ - **Usernames and BSUIDs, both directions.** Once a WhatsApp user adopts a username,
77
+ their phone number can vanish from your payloads. Inbound notifications still identify
78
+ their sender through a Business-Scoped User ID, and `recipient:` sends straight back to
79
+ one — so a BSUID read off a webhook can be replied to without ever knowing the number.
80
+ Your own business username is managed too, along with Meta's contact book.
81
+ - **Persistent HTTP connections** via `HTTP.persistent`, reused across requests, with
82
+ pluggable logging.
83
+ - **Four runtime dependencies**, no Rails requirement. Rails integration activates
84
+ itself when Rails is present.
85
+
86
+ ## 📦 Installation
87
+
88
+ ```ruby
89
+ # Gemfile
90
+ gem "ruby-whatsapp"
152
91
  ```
153
92
 
154
- Either `id` (an uploaded [media](#media) ID) or `link` is required. `caption` is optional (max 1024 characters).
155
-
156
- ### Video
157
-
158
- ```ruby
159
- Whatsapp::Messages.send_video!(to: "+15551234567", id: "1234567890", caption: "Demo video")
93
+ ```bash
94
+ bundle install
160
95
  ```
161
96
 
162
- Either `id` or `link` is required. `caption` is optional (max 1024 characters).
163
-
164
- ### Audio
97
+ Or standalone:
165
98
 
166
- ```ruby
167
- Whatsapp::Messages.send_audio!(to: "+15551234567", link: "https://example.com/clip.mp3")
99
+ ```bash
100
+ gem install ruby-whatsapp
168
101
  ```
169
102
 
170
- Either `id` or `link` is required. Audio messages do not support captions.
171
-
172
- ### Document
103
+ ## 🔧 Configuration
173
104
 
174
105
  ```ruby
175
- Whatsapp::Messages.send_document!(
176
- to: "+15551234567",
177
- link: "https://example.com/invoice.pdf",
178
- filename: "invoice.pdf"
179
- )
106
+ Whatsapp.configure do |config|
107
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY") # a Meta system-user / app access token
108
+ config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
109
+ end
180
110
  ```
181
111
 
182
- Either `id` or `link` is required. `caption` (max 1024 characters) and `filename` are optional.
112
+ | Option | Default | Needed for |
113
+ | --- | --- | --- |
114
+ | `api_key` | — | Everything |
115
+ | `phone_id` | — | Messages, media, phone-number onboarding |
116
+ | `waba_id` | — | Template management, subscribed apps, the business account |
117
+ | `verify_token` | — | The webhook GET handshake |
118
+ | `app_secret` | — | Webhook signature verification |
119
+ | `host` | `https://graph.facebook.com` | Overriding the API host |
120
+ | `version` | `v24.0` | Pinning a Graph API version |
121
+ | `media_host_allowlist` | 3 Meta hosts | Media download safety |
183
122
 
184
- ### Sticker
123
+ **Which ID addresses what** — the most common source of confusion:
185
124
 
186
- ```ruby
187
- Whatsapp::Messages.send_sticker!(to: "+15551234567", id: "STICKER_MEDIA_ID")
188
125
  ```
126
+ phone_id ──► Messages · Media · BusinessPhoneNumber
127
+ permission: whatsapp_business_messaging
189
128
 
190
- Either `id` or `link` is required — prefer `id` (from [`Media#upload`](#media)) over `link` for performance.
191
-
192
- ### Reaction
193
-
194
- ```ruby
195
- Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "👍")
129
+ waba_id ──► MessageTemplates · SubscribedApp · Schedule · BusinessPhoneNumber::Account
130
+ permission: whatsapp_business_management
196
131
  ```
197
132
 
198
- `message_id` and `emoji` are required. Pass `emoji: ""` to remove a reaction you sent previously.
133
+ `BusinessPhoneNumber` appears on both sides: its onboarding actions address a phone
134
+ number, its `Account` actions the account that number belongs to.
199
135
 
200
- ### Location
136
+ `Schedule`'s permission is an assumption: Meta's Schedules reference names none, so it
137
+ is grouped under `whatsapp_business_management` only because it addresses the WABA.
201
138
 
202
- ```ruby
203
- Whatsapp::Messages.send_location!(to: "+15551234567", latitude: 37.4847, longitude: -122.1477, name: "Meta HQ")
204
- ```
139
+ `api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect`
140
+ and `Client#inspect`, so they will not leak into logs or error reports.
205
141
 
206
- `latitude` and `longitude` are required. `name` and `address` are optional.
142
+ → **[Full configuration reference](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md)**
207
143
 
208
- ### Contacts
144
+ ## 🚀 Quick Start
209
145
 
210
146
  ```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
- ```
147
+ require "ruby/whatsapp"
224
148
 
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.
149
+ Whatsapp.configure do |config|
150
+ config.api_key = ENV.fetch("WHATSAPP_API_KEY")
151
+ config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
152
+ end
226
153
 
227
- ### Address
154
+ response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
228
155
 
229
- ```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
- )
156
+ response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
157
+ response.contacts.first.wa_id # => "15551234567"
237
158
  ```
238
159
 
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.
160
+ ## 📚 Documentation
240
161
 
241
- ### Location Request
162
+ | Area | What it covers |
163
+ | --- | --- |
164
+ | [**Configuration**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md) | Credentials, the client, connection reuse, instrumentation |
165
+ | [**Messages**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md) | Every message kind, one page each, with exact payloads |
166
+ | [**Message Templates**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md) | Creating and managing templates: standard, auth, carousel, offers, library |
167
+ | [**Webhooks**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md) | Install, verification, signatures, and all 21 notification fields |
168
+ | [**Media**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md) | Upload, download, delete, and the token-safety allowlist |
169
+ | [**Subscribed Apps**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md) | Turning webhook delivery on and off for an account |
170
+ | [**Campaign Schedules**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/schedule/README.md) | Sending an approved template to an audience at a set time |
171
+ | [**Business Phone Numbers**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md) | Onboarding: request code → verify → register |
172
+ | [**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 |
173
+ | [**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 |
174
+ | [**Errors**](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md) | The exception hierarchy and retry strategy |
242
175
 
243
- ```ruby
244
- Whatsapp::Messages.send_location_request!(to: "+15551234567", body: "Can you share your delivery location?")
245
- ```
176
+ Or start at the [documentation index](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/README.md).
246
177
 
247
- Prompts the user to share their current location. `body` is required.
178
+ ## 💬 Sending Messages
248
179
 
249
- ### Template
180
+ Every registered kind gets its own `Whatsapp::Messages.send_<kind>!` class method.
181
+ Address a recipient by phone number, by BSUID, or both — at least one is required:
250
182
 
251
183
  ```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
- )
184
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi") # phone number
185
+ Whatsapp::Messages.send_text!(recipient: "US.13491208655302741918", body: "Hi") # BSUID
260
186
  ```
261
187
 
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`).
263
-
264
- ### Interactive
265
-
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:`.
267
-
268
- **Reply buttons** — up to 3 quick-reply buttons:
269
-
270
188
  ```ruby
271
189
  Whatsapp::Messages.send_interactive!(
272
190
  to: "+15551234567",
273
191
  type: :reply_buttons,
274
192
  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
- }
292
- )
293
- ```
294
-
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
- }
193
+ action: { buttons: [{ id: "confirm", title: "Confirm" },
194
+ { id: "cancel", title: "Cancel" }] }
345
195
  )
346
196
  ```
347
197
 
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.
198
+ | Method | Sends |
199
+ | --- | --- |
200
+ | [`send_text!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/text.md) | Plain text with an optional link preview |
201
+ | [`send_image!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/image.md) | An image with an optional caption |
202
+ | [`send_video!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/video.md) | A video with an optional caption |
203
+ | [`send_audio!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/audio.md) | A voice note or audio clip |
204
+ | [`send_document!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/document.md) | A file with an optional caption and filename |
205
+ | [`send_sticker!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/sticker.md) | A sticker |
206
+ | [`send_reaction!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/reaction.md) | An emoji reaction to a previous message |
207
+ | [`send_location!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/location.md) | A latitude/longitude pin |
208
+ | [`send_contacts!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/contacts.md) | A rich, vCard-like contact card |
209
+ | [`send_address!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/address.md) | A delivery-address form (India & Singapore only) |
210
+ | [`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 |
211
+ | [`send_template!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/template.md) | A pre-approved marketing/utility/authentication template |
212
+ | [`send_interactive!`](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/interactive.md) | Reply buttons, lists, CTA URLs, or carousels |
213
+ | [`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 |
214
+
215
+ Each accepts an optional `client:` and returns a `Whatsapp::Messages::Response` with
216
+ typed `#contacts` and `#messages`. Invalid input raises
217
+ `ActiveModel::ValidationError` before any request is made.
392
218
 
393
- ## Managing Templates
219
+ → **[Sending messages](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md)**
394
220
 
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.
221
+ ## 📋 Managing Templates
398
222
 
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.
223
+ Sending a template requires one that already exists and has been approved by Meta.
224
+ `Whatsapp::MessageTemplates` creates and manages those, so they live in your codebase
225
+ and ship from CI instead of being clicked together in WhatsApp Manager.
401
226
 
402
227
  ```ruby
403
- Whatsapp.configure do |config|
404
- config.api_key = ENV["WHATSAPP_API_KEY"]
405
- config.waba_id = ENV["WHATSAPP_WABA_ID"]
406
- end
228
+ templates = Whatsapp::MessageTemplates.new # needs waba_id
407
229
 
408
- templates = Whatsapp::MessageTemplates.new
409
- ```
410
-
411
- ### Creating
412
-
413
- ```ruby
414
230
  created = templates.create(
415
- name: "order_confirmation", # lowercase alphanumerics and underscores only
416
- language: "en_US",
417
- category: "UTILITY", # UTILITY | MARKETING | AUTHENTICATION
231
+ name: "order_confirmation", language: "en_US", category: "UTILITY",
418
232
  components: [
419
- { type: :header, format: "TEXT", text: "Order {{1}} confirmed", example: ["#1234"] },
420
233
  { type: :body,
421
234
  text: "Thank you, {{1}}! Your order number is {{2}}.",
422
235
  example: ["Pablo", "860198-230332"] },
423
- { type: :footer, text: "Thanks for shopping with us" },
424
236
  { type: :buttons, buttons: [
425
- { type: :phone_number, text: "Call", phone_number: "15550051310" },
426
237
  { type: :url, text: "Track order", url: "https://example.com/orders/{{1}}", example: "1234" },
427
238
  ] },
428
239
  ]
429
240
  )
430
241
 
431
- created.id # => "1259544702043867"
432
242
  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
243
  ```
449
244
 
450
245
  Meta's rules are checked before the request, so a mistake raises immediately instead of
@@ -454,180 +249,119 @@ costing a review cycle:
454
249
  templates.create(name: "Order Confirmation", ...)
455
250
  # => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
456
251
  # 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
252
  ```
462
253
 
463
- ### Listing, reading, editing, deleting
254
+ Covers standard, [authentication/OTP](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/authentication.md),
255
+ [carousel](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/carousel.md),
256
+ [limited-time offer](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/limited_time_offer.md), and
257
+ [library](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/library.md) templates,
258
+ plus list, find, update, and delete.
464
259
 
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
260
+ → **[Managing templates](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/message_templates/README.md)**
470
261
 
471
- template = templates.find(template_id: "1259544702043867")
472
- template.status # => "APPROVED"
473
- template.editable? # => true (APPROVED, REJECTED and PAUSED templates can be edited)
262
+ ## 🔔 Webhooks
474
263
 
475
- templates.update(template_id: template.id, category: "MARKETING") # => true
476
- templates.update(template_id: template.id, components: [...]) # full replacement
264
+ Meta pushes inbound messages, delivery statuses, and 20 other notification types to a
265
+ callback URL you register. Inside a Rails app:
477
266
 
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
267
+ ```bash
268
+ bundle exec rake whatsapp:install:webhook
481
269
  ```
482
270
 
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.
486
-
487
- ### Other template kinds
488
-
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:
271
+ That copies a personalizable controller to
272
+ `app/controllers/whatsapp/webhooks_controller.rb` and prints the routes to add:
491
273
 
492
274
  ```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
- )
501
- ```
502
-
503
- **Marketing carousels** take 2–10 cards that must all share the same structure:
275
+ def receive
276
+ raw_body = request.body.read
277
+ return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
278
+ payload: raw_body, header: request.headers["X-Hub-Signature-256"]
279
+ )
504
280
 
505
- ```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
- ```
281
+ notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
520
282
 
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):
283
+ notification.entry.each do |entry|
284
+ entry.changes.each { |change| WebhookJob.perform_later(change) }
285
+ end
523
286
 
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
- )
287
+ head :ok
288
+ end
538
289
  ```
539
290
 
540
- **Library templates** are pre-written and pre-approved by Meta, so they usually come back
541
- `APPROVED` immediately:
291
+ Every notification deserializes into typed objects — no raw hash spelunking:
542
292
 
543
293
  ```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
- )
552
- ```
553
-
554
- ### Notes
294
+ message = change.value.messages.first
555
295
 
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.
296
+ message.from # => "16505551234"
297
+ message.id # => "wamid.HBg..."
298
+ message.body # => "Does it come in another color?"
299
+ ```
562
300
 
563
- ## Managing Subscribed Apps
301
+ All 21 documented fields get a class. `messages`, `user_id_update`, and
302
+ `business_username_updates`, `user_preferences`, and `history` have a Meta-published schema; the other 16 are best-effort
303
+ and flagged as such, per field.
564
304
 
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.
305
+ → **[Webhooks](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/README.md)**
306
+ · [inbound messages & statuses](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/webhooks/messages.md)
569
307
 
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).
308
+ ## 📷 Media
572
309
 
573
310
  ```ruby
574
- Whatsapp.configure do |config|
575
- config.api_key = ENV["WHATSAPP_API_KEY"]
576
- config.waba_id = ENV["WHATSAPP_WABA_ID"]
577
- end
311
+ media = Whatsapp::Media.new
578
312
 
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
313
+ media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
314
+ info = media.get_url(media_id: media_id)
315
+ media.download(url: info["url"], save_to: "photo.jpg")
316
+ media.delete(media_id: media_id) # => true
582
317
  ```
583
318
 
584
- ### Subscribing
319
+ `download` refuses to attach the API token to a non-HTTPS URL or a host that is not on
320
+ `Configuration#media_host_allowlist`, so a token is never sent to an
321
+ attacker-influenced URL. Bodies stream to disk rather than buffering in memory.
585
322
 
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
- ```
323
+ → **[Media](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/media/README.md)**
324
+
325
+ ## 🔌 Subscribed Apps
591
326
 
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:
327
+ Before your app receives any webhook notifications for a business account, it has to be
328
+ subscribed to it:
594
329
 
595
330
  ```ruby
596
- Whatsapp::SubscribedApp::Subscribe.call(
597
- override_callback_uri: "https://example.com/webhooks/acme_corp",
598
- verify_token: "a-per-account-secret"
599
- )
331
+ Whatsapp::SubscribedApp::Subscribe.call # start webhook delivery
332
+ Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
333
+ Whatsapp::SubscribedApp::Unsubscribe.call # stop it
600
334
  ```
601
335
 
602
- ### Listing
336
+ Tech Providers routing several accounts to different callback URLs pass an
337
+ `override_callback_uri:`.
603
338
 
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=..."
339
+ → **[Subscribed apps](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md)**
608
340
 
609
- Whatsapp::SubscribedApp::List.call(fields: %w[id name]) # restrict the fields returned
610
- ```
341
+ ## 📅 Scheduling Campaigns
611
342
 
612
- ### Unsubscribing
343
+ Send an approved template to an audience at a set time, and list what is scheduled:
613
344
 
614
345
  ```ruby
615
- Whatsapp::SubscribedApp::Unsubscribe.call.success # => true
346
+ Whatsapp::Schedule::Create.call(
347
+ hsm_id: "1234567890", audience_id: "2345678901", waba_cs_id: "3456789012",
348
+ name: "Black Friday", description: "Black Friday promo to VIP customers",
349
+ delivery_time: Time.now.to_i + 3600
350
+ ).id # => "9876543210"
351
+
352
+ page = Whatsapp::Schedule::List.call(fields: Whatsapp::Schedule::List::Fields::ALL, limit: 50)
353
+ page.select(&:scheduled?).map(&:name) # => ["Black Friday"]
354
+ Whatsapp::Schedule::List.call(after: page.next_cursor) if page.next_cursor
616
355
  ```
617
356
 
618
- Stops all webhook deliveries for this WABA immediately.
357
+ Meta's default fields leave out `id`, so ask for it with `fields:` when you need it.
619
358
 
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.
359
+ → **[Campaign schedules](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/schedule/README.md)**
624
360
 
625
- ## Registering Business Phone Numbers
361
+ ## 📞 Business Phone Numbers
626
362
 
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:
363
+ A phone number is unusable with Cloud API until it is **registered** — the prerequisite
364
+ that makes sending, media, and templates work for it at all.
631
365
 
632
366
  ```
633
367
  RequestCode -> VerifyCode -> Register (onboarding)
@@ -636,161 +370,132 @@ RequestCode -> VerifyCode -> Register (onboarding)
636
370
  Deregister (the reverse switch)
637
371
  ```
638
372
 
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
373
  ```ruby
658
- Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US").success # => true
374
+ Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US")
375
+ Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456")
376
+ Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
377
+ Whatsapp::BusinessPhoneNumber::Deregister.call
659
378
  ```
660
379
 
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.
380
+ The 6-digit two-step verification PIN and the local-storage region are validated
381
+ client-side — which matters here, because a rejected attempt still counts against a
382
+ rate limit of 10 requests per number per 72-hour window.
664
383
 
665
- ### Verifying the code
384
+ The account that number belongs to is readable and writable too — the one part of this
385
+ module that addresses `waba_id` rather than `phone_id`:
666
386
 
667
387
  ```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
- ```
388
+ details = Whatsapp::BusinessPhoneNumber::Account::Get.call(fields: %w[name account_review_status])
672
389
 
673
- ### Registering
390
+ details.name # => "Acme Corp"
391
+ details.approved? # => true
674
392
 
675
- ```ruby
676
- result = Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
677
- result.success # => true
393
+ Whatsapp::BusinessPhoneNumber::Account::Update.call(name: "Acme Corporation").success # => true
678
394
  ```
679
395
 
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`):
396
+ And so is the **business profile** — the card a user sees before they reply. It addresses
397
+ the same `phone_id` as the onboarding actions:
686
398
 
687
399
  ```ruby
688
- Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834", data_localization_region: "CH")
689
- ```
400
+ Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
401
+ # => "Open daily 9-5"
690
402
 
691
- ### Deregistering
692
-
693
- ```ruby
694
- Whatsapp::BusinessPhoneNumber::Deregister.call.success # => true
403
+ Whatsapp::BusinessPhoneNumber::Profile::Update.call(
404
+ about: "Open daily 9-6", vertical: "RETAIL", websites: ["https://acme.test"]
405
+ ).success
406
+ # => true
695
407
  ```
696
408
 
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.
409
+ Character limits, the 21-value vertical enum, and the two-website cap are all checked
410
+ before the request goes out.
700
411
 
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.
412
+ The **business username** — the searchable `@name` a user can find you by — is managed on
413
+ the same `phone_id`:
706
414
 
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.
711
-
712
- ## Webhooks
415
+ ```ruby
416
+ Whatsapp::BusinessPhoneNumber::Username::Suggest.call.to_a
417
+ # => ["jaspersmarket", "jaspers_market"] — names Meta reserved for you
713
418
 
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:
419
+ Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: "jaspersmarket").reserved?
420
+ # => true, until Meta makes the feature generally available
716
421
 
717
- ```bash
718
- bundle exec rake whatsapp:install:webhook
422
+ Whatsapp::BusinessPhoneNumber::Username::Get.call.approved? # => false, for now
719
423
  ```
720
424
 
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:
425
+ Every one of Meta's format rules is checked locally first, so a name that was never going
426
+ to be accepted doesn't cost you a rate-limited attempt. And Meta's **contact book** — the
427
+ store that keeps putting a username adopter's phone number in your webhooks — can be
428
+ pruned:
723
429
 
724
430
  ```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
431
+ result = Whatsapp::BusinessPhoneNumber::ContactBook::Delete.call(bsuid: "US.13491208655302741918")
432
+ result.deleted # => false means there was simply nothing stored, not a failure
734
433
  ```
735
434
 
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:
738
-
739
- ```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
435
+ → **[Business phone numbers](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/README.md)**
436
+ · [the business account](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/account.md)
437
+ · [the business profile](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/profile.md)
438
+ · [business usernames](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/username.md)
439
+ · [the contact book](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/business_phone_number/contact_book.md)
745
440
 
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
- )
441
+ ## 🚨 Errors
751
442
 
752
- notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
753
- # notification.entry.each { |entry| entry.changes.each { |change| WebhookJob.perform_later(change) } }
443
+ Everything descends from `Whatsapp::Error`, and each module raises its own subclass so
444
+ you can rescue narrowly:
754
445
 
755
- head :ok
756
- end
757
- end
758
- ```
759
-
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.
446
+ | Class | Raised by |
447
+ | --- | --- |
448
+ | `Whatsapp::RequestError` | A failed message send |
449
+ | `Whatsapp::Messages::PayloadError` | An unknown message kind |
450
+ | `Whatsapp::Media::MediaError` | Anything in `Media` |
451
+ | `Whatsapp::MessageTemplates::TemplateError` | Anything in `MessageTemplates` |
452
+ | `Whatsapp::SubscribedApp::Error` | Anything in `SubscribedApp` |
453
+ | `Whatsapp::Schedule::Error` | Anything in `Schedule` |
454
+ | `Whatsapp::BusinessPhoneNumber::Error` | Anything in `BusinessPhoneNumber`, including `Account` |
767
455
 
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:
456
+ Local validation failures raise `ActiveModel::ValidationError` instead — they happen at
457
+ construction time, before any network call, and carry per-attribute detail:
770
458
 
771
459
  ```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)
460
+ rescue ActiveModel::ValidationError => e
461
+ e.model.errors.full_messages
462
+ # => ["Caption is too long (maximum is 1024 characters)"]
775
463
  ```
776
464
 
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.
465
+ → **[Errors](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md)**
778
466
 
779
- ## Development
467
+ ## 🧩 Compatibility
468
+
469
+ | | |
470
+ | --- | --- |
471
+ | Ruby | >= 3.2 (CI runs 3.2 and 3.4) |
472
+ | Graph API | `v24.0` by default, overridable |
473
+ | Rails | Optional. Webhook controller generator activates when Rails is loaded |
474
+ | Dependencies | `activemodel`, `http`, `logger`, `zeitwerk` |
475
+
476
+ ## 🔨 Development
780
477
 
781
478
  After checking out the repo, run `bundle install`, then:
782
479
 
783
480
  ```bash
784
- bundle exec rake # run the specs and RuboCop (the default task)
481
+ bundle exec rake # specs + RuboCop (the default task)
785
482
  bundle exec rspec # specs only
786
483
  bundle exec rubocop # lint only
787
484
  bin/console # interactive prompt
788
485
  ```
789
486
 
790
- ## Contributing
487
+ ## 🤝 Contributing
488
+
489
+ Bug reports and pull requests are welcome at
490
+ <https://github.com/saleszera/ruby-whatsapp>. Please write the failing spec first —
491
+ this gem is developed test-first — and make sure `bundle exec rake` is green before
492
+ opening a PR.
493
+
494
+ ## 📄 License
791
495
 
792
- Bug reports and pull requests are welcome on GitHub at https://github.com/saleszera/ruby-whatsapp.
496
+ Available as open source under the terms of the
497
+ [MIT License](https://opensource.org/licenses/MIT).
793
498
 
794
- ## License
499
+ ---
795
500
 
796
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
501
+ ![that's all folks](https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExaWlsc3ZkcGhiNWlmMXgwZmNhdnAwaWFleDM5YjZlZmRqa2MxcnM0NCZlcD12MV9naWZzX3NlYXJjaCZjdD1n/xUPOqo6E1XvWXwlCyQ/giphy.gif)