ruby-whatsapp 0.3.0

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 (166) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +54 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +15 -0
  6. data/CODE_OF_CONDUCT.md +132 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +643 -0
  9. data/Rakefile +12 -0
  10. data/lib/ruby/whatsapp/client.rb +82 -0
  11. data/lib/ruby/whatsapp/configuration.rb +79 -0
  12. data/lib/ruby/whatsapp/instrumentation.rb +34 -0
  13. data/lib/ruby/whatsapp/media.rb +134 -0
  14. data/lib/ruby/whatsapp/message_templates/button/base.rb +26 -0
  15. data/lib/ruby/whatsapp/message_templates/button/copy_code.rb +59 -0
  16. data/lib/ruby/whatsapp/message_templates/button/otp/supported_app.rb +44 -0
  17. data/lib/ruby/whatsapp/message_templates/button/otp.rb +138 -0
  18. data/lib/ruby/whatsapp/message_templates/button/phone_number.rb +45 -0
  19. data/lib/ruby/whatsapp/message_templates/button/quick_reply.rb +38 -0
  20. data/lib/ruby/whatsapp/message_templates/button/url.rb +110 -0
  21. data/lib/ruby/whatsapp/message_templates/button.rb +56 -0
  22. data/lib/ruby/whatsapp/message_templates/categories.rb +47 -0
  23. data/lib/ruby/whatsapp/message_templates/component/base.rb +112 -0
  24. data/lib/ruby/whatsapp/message_templates/component/body.rb +139 -0
  25. data/lib/ruby/whatsapp/message_templates/component/buttons.rb +95 -0
  26. data/lib/ruby/whatsapp/message_templates/component/carousel/card.rb +100 -0
  27. data/lib/ruby/whatsapp/message_templates/component/carousel.rb +71 -0
  28. data/lib/ruby/whatsapp/message_templates/component/footer.rb +99 -0
  29. data/lib/ruby/whatsapp/message_templates/component/header.rb +188 -0
  30. data/lib/ruby/whatsapp/message_templates/component/limited_time_offer.rb +57 -0
  31. data/lib/ruby/whatsapp/message_templates/component.rb +62 -0
  32. data/lib/ruby/whatsapp/message_templates/component_set.rb +221 -0
  33. data/lib/ruby/whatsapp/message_templates/example.rb +148 -0
  34. data/lib/ruby/whatsapp/message_templates/library_template/body_inputs.rb +81 -0
  35. data/lib/ruby/whatsapp/message_templates/library_template/button_inputs.rb +146 -0
  36. data/lib/ruby/whatsapp/message_templates/library_template.rb +114 -0
  37. data/lib/ruby/whatsapp/message_templates/parameter_formats.rb +49 -0
  38. data/lib/ruby/whatsapp/message_templates/placeholders.rb +86 -0
  39. data/lib/ruby/whatsapp/message_templates/response/collection.rb +74 -0
  40. data/lib/ruby/whatsapp/message_templates/response/created.rb +65 -0
  41. data/lib/ruby/whatsapp/message_templates/response/node.rb +152 -0
  42. data/lib/ruby/whatsapp/message_templates/response/paging.rb +39 -0
  43. data/lib/ruby/whatsapp/message_templates/response/quality_score.rb +49 -0
  44. data/lib/ruby/whatsapp/message_templates/response/summary.rb +68 -0
  45. data/lib/ruby/whatsapp/message_templates/response.rb +22 -0
  46. data/lib/ruby/whatsapp/message_templates/statuses.rb +66 -0
  47. data/lib/ruby/whatsapp/message_templates/template.rb +180 -0
  48. data/lib/ruby/whatsapp/message_templates/value_object.rb +62 -0
  49. data/lib/ruby/whatsapp/message_templates.rb +283 -0
  50. data/lib/ruby/whatsapp/messages/address.rb +103 -0
  51. data/lib/ruby/whatsapp/messages/audio.rb +64 -0
  52. data/lib/ruby/whatsapp/messages/base.rb +46 -0
  53. data/lib/ruby/whatsapp/messages/contacts/address.rb +84 -0
  54. data/lib/ruby/whatsapp/messages/contacts/contact.rb +78 -0
  55. data/lib/ruby/whatsapp/messages/contacts/email.rb +49 -0
  56. data/lib/ruby/whatsapp/messages/contacts/name.rb +68 -0
  57. data/lib/ruby/whatsapp/messages/contacts/org.rb +41 -0
  58. data/lib/ruby/whatsapp/messages/contacts/phone.rb +59 -0
  59. data/lib/ruby/whatsapp/messages/contacts/url.rb +51 -0
  60. data/lib/ruby/whatsapp/messages/contacts.rb +37 -0
  61. data/lib/ruby/whatsapp/messages/document.rb +79 -0
  62. data/lib/ruby/whatsapp/messages/image.rb +72 -0
  63. data/lib/ruby/whatsapp/messages/interactive/base.rb +15 -0
  64. data/lib/ruby/whatsapp/messages/interactive/body.rb +39 -0
  65. data/lib/ruby/whatsapp/messages/interactive/footer.rb +40 -0
  66. data/lib/ruby/whatsapp/messages/interactive/header.rb +102 -0
  67. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section/row.rb +66 -0
  68. data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +64 -0
  69. data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +63 -0
  70. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button/quick_reply.rb +50 -0
  71. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button.rb +47 -0
  72. data/lib/ruby/whatsapp/messages/interactive/media_carousel/card.rb +78 -0
  73. data/lib/ruby/whatsapp/messages/interactive/media_carousel.rb +50 -0
  74. data/lib/ruby/whatsapp/messages/interactive/product_carousel/card.rb +55 -0
  75. data/lib/ruby/whatsapp/messages/interactive/product_carousel.rb +50 -0
  76. data/lib/ruby/whatsapp/messages/interactive/reply_buttons/button.rb +59 -0
  77. data/lib/ruby/whatsapp/messages/interactive/reply_buttons.rb +47 -0
  78. data/lib/ruby/whatsapp/messages/interactive/url_button.rb +68 -0
  79. data/lib/ruby/whatsapp/messages/interactive.rb +112 -0
  80. data/lib/ruby/whatsapp/messages/location.rb +67 -0
  81. data/lib/ruby/whatsapp/messages/location_request.rb +41 -0
  82. data/lib/ruby/whatsapp/messages/mark_message_as_read.rb +42 -0
  83. data/lib/ruby/whatsapp/messages/reaction.rb +61 -0
  84. data/lib/ruby/whatsapp/messages/response/contacts.rb +36 -0
  85. data/lib/ruby/whatsapp/messages/response/messages.rb +27 -0
  86. data/lib/ruby/whatsapp/messages/response.rb +49 -0
  87. data/lib/ruby/whatsapp/messages/sticker.rb +62 -0
  88. data/lib/ruby/whatsapp/messages/template/component.rb +67 -0
  89. data/lib/ruby/whatsapp/messages/template/language.rb +55 -0
  90. data/lib/ruby/whatsapp/messages/template/parameter.rb +113 -0
  91. data/lib/ruby/whatsapp/messages/template.rb +62 -0
  92. data/lib/ruby/whatsapp/messages/text.rb +43 -0
  93. data/lib/ruby/whatsapp/messages/video.rb +68 -0
  94. data/lib/ruby/whatsapp/messages.rb +116 -0
  95. data/lib/ruby/whatsapp/railtie.rb +14 -0
  96. data/lib/ruby/whatsapp/request_error.rb +5 -0
  97. data/lib/ruby/whatsapp/response_handling.rb +44 -0
  98. data/lib/ruby/whatsapp/utils/language_codes.rb +96 -0
  99. data/lib/ruby/whatsapp/version.rb +5 -0
  100. data/lib/ruby/whatsapp/webhook/account_alerts.rb +58 -0
  101. data/lib/ruby/whatsapp/webhook/account_review_update.rb +27 -0
  102. data/lib/ruby/whatsapp/webhook/account_update/ban_info.rb +33 -0
  103. data/lib/ruby/whatsapp/webhook/account_update.rb +46 -0
  104. data/lib/ruby/whatsapp/webhook/automatic_events.rb +43 -0
  105. data/lib/ruby/whatsapp/webhook/business_capability_update.rb +36 -0
  106. data/lib/ruby/whatsapp/webhook/change.rb +61 -0
  107. data/lib/ruby/whatsapp/webhook/contact.rb +36 -0
  108. data/lib/ruby/whatsapp/webhook/entry.rb +35 -0
  109. data/lib/ruby/whatsapp/webhook/error.rb +47 -0
  110. data/lib/ruby/whatsapp/webhook/history.rb +54 -0
  111. data/lib/ruby/whatsapp/webhook/installer.rb +61 -0
  112. data/lib/ruby/whatsapp/webhook/message/audio.rb +28 -0
  113. data/lib/ruby/whatsapp/webhook/message/base.rb +61 -0
  114. data/lib/ruby/whatsapp/webhook/message/button.rb +33 -0
  115. data/lib/ruby/whatsapp/webhook/message/contacts/address.rb +68 -0
  116. data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +68 -0
  117. data/lib/ruby/whatsapp/webhook/message/contacts/email.rb +35 -0
  118. data/lib/ruby/whatsapp/webhook/message/contacts/name.rb +62 -0
  119. data/lib/ruby/whatsapp/webhook/message/contacts/org.rb +40 -0
  120. data/lib/ruby/whatsapp/webhook/message/contacts/phone.rb +40 -0
  121. data/lib/ruby/whatsapp/webhook/message/contacts/url.rb +35 -0
  122. data/lib/ruby/whatsapp/webhook/message/contacts.rb +31 -0
  123. data/lib/ruby/whatsapp/webhook/message/context.rb +49 -0
  124. data/lib/ruby/whatsapp/webhook/message/document.rb +32 -0
  125. data/lib/ruby/whatsapp/webhook/message/image.rb +18 -0
  126. data/lib/ruby/whatsapp/webhook/message/interactive.rb +53 -0
  127. data/lib/ruby/whatsapp/webhook/message/location.rb +49 -0
  128. data/lib/ruby/whatsapp/webhook/message/media.rb +54 -0
  129. data/lib/ruby/whatsapp/webhook/message/order/product_item.rb +50 -0
  130. data/lib/ruby/whatsapp/webhook/message/order.rb +43 -0
  131. data/lib/ruby/whatsapp/webhook/message/reaction.rb +37 -0
  132. data/lib/ruby/whatsapp/webhook/message/referral.rb +80 -0
  133. data/lib/ruby/whatsapp/webhook/message/sticker.rb +32 -0
  134. data/lib/ruby/whatsapp/webhook/message/system.rb +49 -0
  135. data/lib/ruby/whatsapp/webhook/message/text.rb +28 -0
  136. data/lib/ruby/whatsapp/webhook/message/unknown.rb +39 -0
  137. data/lib/ruby/whatsapp/webhook/message/video.rb +18 -0
  138. data/lib/ruby/whatsapp/webhook/message.rb +39 -0
  139. data/lib/ruby/whatsapp/webhook/message_template_components_update.rb +53 -0
  140. data/lib/ruby/whatsapp/webhook/message_template_quality_update.rb +59 -0
  141. data/lib/ruby/whatsapp/webhook/message_template_status_update.rb +54 -0
  142. data/lib/ruby/whatsapp/webhook/messages.rb +55 -0
  143. data/lib/ruby/whatsapp/webhook/metadata.rb +35 -0
  144. data/lib/ruby/whatsapp/webhook/notification.rb +36 -0
  145. data/lib/ruby/whatsapp/webhook/partner_solutions.rb +36 -0
  146. data/lib/ruby/whatsapp/webhook/payment_configuration_update.rb +51 -0
  147. data/lib/ruby/whatsapp/webhook/phone_number_name_update.rb +51 -0
  148. data/lib/ruby/whatsapp/webhook/phone_number_quality_update.rb +45 -0
  149. data/lib/ruby/whatsapp/webhook/security.rb +41 -0
  150. data/lib/ruby/whatsapp/webhook/signature.rb +28 -0
  151. data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +38 -0
  152. data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -0
  153. data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +48 -0
  154. data/lib/ruby/whatsapp/webhook/status/conversation.rb +42 -0
  155. data/lib/ruby/whatsapp/webhook/status/pricing.rb +38 -0
  156. data/lib/ruby/whatsapp/webhook/status.rb +65 -0
  157. data/lib/ruby/whatsapp/webhook/template_category_update.rb +64 -0
  158. data/lib/ruby/whatsapp/webhook/templates/webhooks_controller.rb.tt +31 -0
  159. data/lib/ruby/whatsapp/webhook/unknown_field.rb +37 -0
  160. data/lib/ruby/whatsapp/webhook/user_preferences.rb +47 -0
  161. data/lib/ruby/whatsapp/webhook/verification.rb +27 -0
  162. data/lib/ruby/whatsapp/webhook.rb +8 -0
  163. data/lib/ruby/whatsapp.rb +49 -0
  164. data/lib/tasks/whatsapp.rake +10 -0
  165. data/sig/ruby/whatsapp.rbs +6 -0
  166. metadata +267 -0
data/README.md ADDED
@@ -0,0 +1,643 @@
1
+ # ruby-whatsapp
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)
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.
8
+
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
+ - [Webhooks](#webhooks)
34
+ - [Development](#development)
35
+ - [Contributing](#contributing)
36
+ - [License](#license)
37
+
38
+ ## Installation
39
+
40
+ Add to your Gemfile:
41
+
42
+ ```ruby
43
+ gem "ruby-whatsapp"
44
+ ```
45
+
46
+ Then run `bundle install`. Or install directly:
47
+
48
+ ```bash
49
+ gem install ruby-whatsapp
50
+ ```
51
+
52
+ ## Configuration
53
+
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
65
+ ```
66
+
67
+ `api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect`, so they will not leak into logs.
68
+
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:
97
+
98
+ ```ruby
99
+ Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello!")
100
+ ```
101
+
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!
112
+ ```
113
+
114
+ This form raises `Whatsapp::Messages::PayloadError` for an unknown `kind` (in addition to the same validation/request errors above).
115
+
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)
136
+ ```
137
+
138
+ `body` is required (max 4096 characters). `preview_url` defaults to `true`.
139
+
140
+ ### Image
141
+
142
+ ```ruby
143
+ Whatsapp::Messages.send_image!(
144
+ to: "+15551234567",
145
+ link: "https://example.com/photo.jpg",
146
+ caption: "Our new product"
147
+ )
148
+ ```
149
+
150
+ Either `id` (an uploaded [media](#media) ID) or `link` is required. `caption` is optional (max 1024 characters).
151
+
152
+ ### Video
153
+
154
+ ```ruby
155
+ Whatsapp::Messages.send_video!(to: "+15551234567", id: "1234567890", caption: "Demo video")
156
+ ```
157
+
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")
164
+ ```
165
+
166
+ Either `id` or `link` is required. Audio messages do not support captions.
167
+
168
+ ### Document
169
+
170
+ ```ruby
171
+ Whatsapp::Messages.send_document!(
172
+ to: "+15551234567",
173
+ link: "https://example.com/invoice.pdf",
174
+ filename: "invoice.pdf"
175
+ )
176
+ ```
177
+
178
+ Either `id` or `link` is required. `caption` (max 1024 characters) and `filename` are optional.
179
+
180
+ ### Sticker
181
+
182
+ ```ruby
183
+ Whatsapp::Messages.send_sticker!(to: "+15551234567", id: "STICKER_MEDIA_ID")
184
+ ```
185
+
186
+ Either `id` or `link` is required — prefer `id` (from [`Media#upload`](#media)) over `link` for performance.
187
+
188
+ ### Reaction
189
+
190
+ ```ruby
191
+ Whatsapp::Messages.send_reaction!(to: "+15551234567", message_id: "wamid.HBg...", emoji: "👍")
192
+ ```
193
+
194
+ `message_id` and `emoji` are required. Pass `emoji: ""` to remove a reaction you sent previously.
195
+
196
+ ### Location
197
+
198
+ ```ruby
199
+ Whatsapp::Messages.send_location!(to: "+15551234567", latitude: 37.4847, longitude: -122.1477, name: "Meta HQ")
200
+ ```
201
+
202
+ `latitude` and `longitude` are required. `name` and `address` are optional.
203
+
204
+ ### Contacts
205
+
206
+ ```ruby
207
+ Whatsapp::Messages.send_contacts!(
208
+ 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
+ ]
218
+ )
219
+ ```
220
+
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.
222
+
223
+ ### Address
224
+
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
+ ```
234
+
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.
236
+
237
+ ### Location Request
238
+
239
+ ```ruby
240
+ Whatsapp::Messages.send_location_request!(to: "+15551234567", body: "Can you share your delivery location?")
241
+ ```
242
+
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" },
252
+ components: [
253
+ { type: "body", parameters: [{ type: "text", text: "Jane" }, { type: "text", text: "#1234" }] },
254
+ ]
255
+ )
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
+
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
+ )
273
+ ```
274
+
275
+ **List** — a button that expands into up to 10 sections of up to 10 rows each:
276
+
277
+ ```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
+ )
289
+ ```
290
+
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
+ ```
301
+
302
+ **Media carousel** — 2 to 10 swipeable cards, each with its own header/body/CTA/quick-replies:
303
+
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
+ ```
327
+
328
+ **Product carousel** — 2 to 10 cards referencing products in your Meta catalog:
329
+
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
+ )
342
+ ```
343
+
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
347
+
348
+ ```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.
353
+
354
+ ## Handling Responses
355
+
356
+ A successful `send!` returns a `Whatsapp::Messages::Response`, built from a payload shaped like:
357
+
358
+ ```json
359
+ {
360
+ "messaging_product": "whatsapp",
361
+ "contacts": [{ "input": "+15551234567", "wa_id": "15551234567" }],
362
+ "messages": [{ "id": "wamid.HBgLMTU1NTU1NTU1NTUV..." }]
363
+ }
364
+ ```
365
+
366
+ ```ruby
367
+ response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hi")
368
+
369
+ response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
370
+ response.contacts.first.wa_id # => "15551234567"
371
+ ```
372
+
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.
374
+
375
+ ## Media
376
+
377
+ ```ruby
378
+ media = Whatsapp::Media.new
379
+
380
+ 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
384
+ ```
385
+
386
+ `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.
388
+
389
+ ## Managing Templates
390
+
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.
394
+
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.
397
+
398
+ ```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
405
+ ```
406
+
407
+ ### Creating
408
+
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
+ )
426
+
427
+ created.id # => "1259544702043867"
428
+ created.status # => "PENDING" — Meta reviews asynchronously, up to 24 hours
429
+ created.pending? # => true
430
+ ```
431
+
432
+ Named parameters read better than positional ones for anything non-trivial:
433
+
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
+ ```
445
+
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
457
+ ```
458
+
459
+ ### Listing, reading, editing, deleting
460
+
461
+ ```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
477
+ ```
478
+
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.
482
+
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:
487
+
488
+ ```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
+ ```
498
+
499
+ **Marketing carousels** take 2–10 cards that must all share the same structure:
500
+
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
+ )
515
+ ```
516
+
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):
519
+
520
+ ```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:
538
+
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
+ )
548
+ ```
549
+
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.
558
+
559
+ ## Webhooks
560
+
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:
563
+
564
+ ```bash
565
+ bundle exec rake whatsapp:install:webhook
566
+ ```
567
+
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
+ ```
582
+
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:
585
+
586
+ ```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
605
+ ```
606
+
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.
614
+
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:
617
+
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.
625
+
626
+ ## Development
627
+
628
+ After checking out the repo, run `bundle install`, then:
629
+
630
+ ```bash
631
+ bundle exec rake # run the specs and RuboCop (the default task)
632
+ bundle exec rspec # specs only
633
+ bundle exec rubocop # lint only
634
+ bin/console # interactive prompt
635
+ ```
636
+
637
+ ## Contributing
638
+
639
+ Bug reports and pull requests are welcome on GitHub at https://github.com/saleszera/ruby-whatsapp.
640
+
641
+ ## License
642
+
643
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]