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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +152 -0
- data/README.md +322 -617
- data/docs/README.md +76 -0
- data/docs/business_phone_number/README.md +243 -0
- data/docs/business_phone_number/account.md +170 -0
- data/docs/business_phone_number/contact_book.md +90 -0
- data/docs/business_phone_number/profile.md +220 -0
- data/docs/business_phone_number/username.md +157 -0
- data/docs/configuration.md +177 -0
- data/docs/errors.md +219 -0
- data/docs/media/README.md +161 -0
- data/docs/message_templates/README.md +206 -0
- data/docs/message_templates/authentication.md +107 -0
- data/docs/message_templates/carousel.md +103 -0
- data/docs/message_templates/components.md +215 -0
- data/docs/message_templates/library.md +98 -0
- data/docs/message_templates/limited_time_offer.md +94 -0
- data/docs/message_templates/responses.md +173 -0
- data/docs/message_templates/standard.md +138 -0
- data/docs/messages/README.md +212 -0
- data/docs/messages/address.md +86 -0
- data/docs/messages/audio.md +43 -0
- data/docs/messages/contacts.md +129 -0
- data/docs/messages/document.md +49 -0
- data/docs/messages/image.md +59 -0
- data/docs/messages/interactive.md +342 -0
- data/docs/messages/location.md +51 -0
- data/docs/messages/location_request.md +59 -0
- data/docs/messages/mark_message_as_read.md +84 -0
- data/docs/messages/reaction.md +59 -0
- data/docs/messages/sticker.md +40 -0
- data/docs/messages/template.md +193 -0
- data/docs/messages/text.md +55 -0
- data/docs/messages/video.md +41 -0
- data/docs/schedule/README.md +183 -0
- data/docs/subscribed_app/README.md +139 -0
- data/docs/webhooks/README.md +217 -0
- data/docs/webhooks/account_alerts.md +44 -0
- data/docs/webhooks/account_review_update.md +29 -0
- data/docs/webhooks/account_update.md +54 -0
- data/docs/webhooks/automatic_events.md +47 -0
- data/docs/webhooks/business_capability_update.md +40 -0
- data/docs/webhooks/business_username_updates.md +55 -0
- data/docs/webhooks/history.md +107 -0
- data/docs/webhooks/message_template_components_update.md +46 -0
- data/docs/webhooks/message_template_quality_update.md +49 -0
- data/docs/webhooks/message_template_status_update.md +55 -0
- data/docs/webhooks/messages.md +520 -0
- data/docs/webhooks/partner_solutions.md +35 -0
- data/docs/webhooks/payment_configuration_update.md +40 -0
- data/docs/webhooks/phone_number_name_update.md +44 -0
- data/docs/webhooks/phone_number_quality_update.md +40 -0
- data/docs/webhooks/security.md +43 -0
- data/docs/webhooks/smb_app_state_sync.md +91 -0
- data/docs/webhooks/smb_message_echoes.md +91 -0
- data/docs/webhooks/template_category_update.md +52 -0
- data/docs/webhooks/user_id_update.md +84 -0
- data/docs/webhooks/user_preferences.md +87 -0
- data/lib/ruby/whatsapp/business_phone_number/account/details.rb +149 -0
- data/lib/ruby/whatsapp/business_phone_number/account/get.rb +53 -0
- data/lib/ruby/whatsapp/business_phone_number/account/transport.rb +30 -0
- data/lib/ruby/whatsapp/business_phone_number/account/update.rb +76 -0
- data/lib/ruby/whatsapp/business_phone_number/account.rb +23 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/delete.rb +87 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book/deletion.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/contact_book.rb +25 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/details.rb +105 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/get.rb +56 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/update.rb +148 -0
- data/lib/ruby/whatsapp/business_phone_number/profile/verticals.rb +54 -0
- data/lib/ruby/whatsapp/business_phone_number/profile.rb +22 -0
- data/lib/ruby/whatsapp/business_phone_number/response.rb +3 -3
- data/lib/ruby/whatsapp/business_phone_number/transport.rb +12 -12
- data/lib/ruby/whatsapp/business_phone_number/username/claim.rb +180 -0
- data/lib/ruby/whatsapp/business_phone_number/username/delete.rb +36 -0
- data/lib/ruby/whatsapp/business_phone_number/username/details.rb +66 -0
- data/lib/ruby/whatsapp/business_phone_number/username/get.rb +33 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggest.rb +37 -0
- data/lib/ruby/whatsapp/business_phone_number/username/suggestions.rb +50 -0
- data/lib/ruby/whatsapp/business_phone_number/username.rb +27 -0
- data/lib/ruby/whatsapp/business_phone_number.rb +20 -4
- data/lib/ruby/whatsapp/message_templates/button/request_contact_info.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +5 -2
- data/lib/ruby/whatsapp/message_templates/component_set.rb +28 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +2 -1
- data/lib/ruby/whatsapp/message_templates.rb +2 -6
- data/lib/ruby/whatsapp/messages/base.rb +47 -5
- data/lib/ruby/whatsapp/messages/interactive/request_contact_info.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +22 -4
- data/lib/ruby/whatsapp/messages/response/contacts.rb +22 -5
- data/lib/ruby/whatsapp/path_building.rb +33 -0
- data/lib/ruby/whatsapp/schedule/create.rb +101 -0
- data/lib/ruby/whatsapp/schedule/list.rb +50 -0
- data/lib/ruby/whatsapp/schedule/response/collection.rb +61 -0
- data/lib/ruby/whatsapp/schedule/response/created.rb +30 -0
- data/lib/ruby/whatsapp/schedule/response/node.rb +85 -0
- data/lib/ruby/whatsapp/schedule/response.rb +18 -0
- data/lib/ruby/whatsapp/schedule/statuses.rb +19 -0
- data/lib/ruby/whatsapp/schedule/transport.rb +24 -0
- data/lib/ruby/whatsapp/schedule.rb +15 -0
- data/lib/ruby/whatsapp/subscribed_app/transport.rb +6 -5
- data/lib/ruby/whatsapp/version.rb +1 -1
- data/lib/ruby/whatsapp/webhook/business_username_updates.rb +51 -0
- data/lib/ruby/whatsapp/webhook/change.rb +3 -1
- data/lib/ruby/whatsapp/webhook/contact.rb +26 -3
- data/lib/ruby/whatsapp/webhook/history/chunk.rb +50 -0
- data/lib/ruby/whatsapp/webhook/history/thread.rb +64 -0
- data/lib/ruby/whatsapp/webhook/history.rb +73 -34
- data/lib/ruby/whatsapp/webhook/message/base.rb +27 -2
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +15 -2
- data/lib/ruby/whatsapp/webhook/message/context.rb +23 -2
- data/lib/ruby/whatsapp/webhook/message/edit.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/revoke.rb +36 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +19 -2
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +2 -2
- data/lib/ruby/whatsapp/webhook/message.rb +3 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +4 -3
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/contact.rb +76 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +18 -4
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -9
- data/lib/ruby/whatsapp/webhook/smb_message_echoes/echo.rb +60 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +26 -11
- data/lib/ruby/whatsapp/webhook/status.rb +23 -2
- data/lib/ruby/whatsapp/webhook/user_id_update/entry.rb +56 -0
- data/lib/ruby/whatsapp/webhook/user_id_update/id_change.rb +35 -0
- data/lib/ruby/whatsapp/webhook/user_id_update.rb +61 -0
- data/lib/ruby/whatsapp/webhook/user_preferences/entry.rb +76 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +44 -23
- metadata +100 -1
data/README.md
CHANGED
|
@@ -1,450 +1,245 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">ruby-whatsapp</h1>
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>A small, dependency-light Ruby client for the Meta WhatsApp Cloud API.</strong>
|
|
5
|
+
</p>
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/ruby-whatsapp"><img alt="Gem Version" src="https://img.shields.io/gem/v/ruby-whatsapp.svg"></a>
|
|
9
|
+
<a href="https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml"><img alt="Build Status" src="https://github.com/saleszera/ruby-whatsapp/actions/workflows/main.yml/badge.svg"></a>
|
|
10
|
+
<a href="https://rubygems.org/gems/ruby-whatsapp"><img alt="Downloads" src="https://img.shields.io/gem/dt/ruby-whatsapp.svg"></a>
|
|
11
|
+
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg"></a>
|
|
12
|
+
<a href="https://www.ruby-lang.org"><img alt="Ruby" src="https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg"></a>
|
|
13
|
+
</p>
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- [Installation](#installation)
|
|
12
|
-
- [Configuration](#configuration)
|
|
13
|
-
- [Quick Start](#quick-start)
|
|
14
|
-
- [Features](#features)
|
|
15
|
-
- [Sending Messages](#sending-messages)
|
|
16
|
-
- [Text](#text)
|
|
17
|
-
- [Image](#image)
|
|
18
|
-
- [Video](#video)
|
|
19
|
-
- [Audio](#audio)
|
|
20
|
-
- [Document](#document)
|
|
21
|
-
- [Sticker](#sticker)
|
|
22
|
-
- [Reaction](#reaction)
|
|
23
|
-
- [Location](#location)
|
|
24
|
-
- [Contacts](#contacts)
|
|
25
|
-
- [Address](#address)
|
|
26
|
-
- [Location Request](#location-request)
|
|
27
|
-
- [Template](#template)
|
|
28
|
-
- [Interactive](#interactive)
|
|
29
|
-
- [Mark Message As Read](#mark-message-as-read)
|
|
30
|
-
- [Handling Responses](#handling-responses)
|
|
31
|
-
- [Media](#media)
|
|
32
|
-
- [Managing Templates](#managing-templates)
|
|
33
|
-
- [Managing Subscribed Apps](#managing-subscribed-apps)
|
|
34
|
-
- [Registering Business Phone Numbers](#registering-business-phone-numbers)
|
|
35
|
-
- [Webhooks](#webhooks)
|
|
36
|
-
- [Development](#development)
|
|
37
|
-
- [Contributing](#contributing)
|
|
38
|
-
- [License](#license)
|
|
39
|
-
|
|
40
|
-
## Installation
|
|
41
|
-
|
|
42
|
-
Add to your Gemfile:
|
|
43
|
-
|
|
44
|
-
```ruby
|
|
45
|
-
gem "ruby-whatsapp"
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Then run `bundle install`. Or install directly:
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
gem install ruby-whatsapp
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
## Configuration
|
|
55
|
-
|
|
56
|
-
```ruby
|
|
57
|
-
Whatsapp.configure do |config|
|
|
58
|
-
config.api_key = ENV.fetch("WHATSAPP_TOKEN") # a Meta system-user / app access token
|
|
59
|
-
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
|
|
60
|
-
# optional overrides:
|
|
61
|
-
# config.version = "v24.0"
|
|
62
|
-
# config.host = "https://graph.facebook.com"
|
|
63
|
-
# config.waba_id = ENV.fetch("WHATSAPP_WABA_ID") # for template management, see below
|
|
64
|
-
# config.verify_token = ENV.fetch("WHATSAPP_VERIFY_TOKEN") # for webhooks, see below
|
|
65
|
-
# config.app_secret = ENV.fetch("WHATSAPP_APP_SECRET") # for webhooks, see below
|
|
66
|
-
end
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
`api_key`, `app_secret`, and `verify_token` are redacted from `Configuration#inspect` and `Client#inspect`, so they will not leak into logs or error reports.
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
104
|
-
```
|
|
25
|
+
photo = "https://example.com/new-arrivals.jpg"
|
|
105
26
|
|
|
106
|
-
|
|
27
|
+
# Send it 📸
|
|
28
|
+
Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "Just landed")
|
|
107
29
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
Whatsapp::Messages.new(
|
|
112
|
-
kind: :text, # or a variable
|
|
113
|
-
payload: { to: "+15551234567", body: "Hello!" },
|
|
114
|
-
client: Whatsapp::Client.new # optional — defaults to a new Client built from Whatsapp.configuration
|
|
115
|
-
).send!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
### Audio
|
|
97
|
+
Or standalone:
|
|
165
98
|
|
|
166
|
-
```
|
|
167
|
-
|
|
99
|
+
```bash
|
|
100
|
+
gem install ruby-whatsapp
|
|
168
101
|
```
|
|
169
102
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
### Document
|
|
103
|
+
## 🔧 Configuration
|
|
173
104
|
|
|
174
105
|
```ruby
|
|
175
|
-
Whatsapp
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
142
|
+
→ **[Full configuration reference](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/configuration.md)**
|
|
207
143
|
|
|
208
|
-
|
|
144
|
+
## 🚀 Quick Start
|
|
209
145
|
|
|
210
146
|
```ruby
|
|
211
|
-
|
|
212
|
-
to: "+15551234567",
|
|
213
|
-
contacts: [
|
|
214
|
-
{
|
|
215
|
-
name: { formatted_name: "Jane Doe", first_name: "Jane", last_name: "Doe" },
|
|
216
|
-
phones: [{ phone: "+15550001111", type: "WORK" }],
|
|
217
|
-
emails: [{ email: "jane@example.com", type: "WORK" }],
|
|
218
|
-
org: { company: "Acme Inc." },
|
|
219
|
-
birthday: "1990-05-12",
|
|
220
|
-
},
|
|
221
|
-
]
|
|
222
|
-
)
|
|
223
|
-
```
|
|
147
|
+
require "ruby/whatsapp"
|
|
224
148
|
|
|
225
|
-
|
|
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
|
-
|
|
154
|
+
response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
|
|
228
155
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
to: "+15551234567",
|
|
232
|
-
body: "Please share your delivery address",
|
|
233
|
-
country: "IN",
|
|
234
|
-
footer: "Thanks for shopping with us",
|
|
235
|
-
values: { name: "Jane Doe", city: "Bangalore" }
|
|
236
|
-
)
|
|
156
|
+
response.messages.first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
|
|
157
|
+
response.contacts.first.wa_id # => "15551234567"
|
|
237
158
|
```
|
|
238
159
|
|
|
239
|
-
|
|
160
|
+
## 📚 Documentation
|
|
240
161
|
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
## 💬 Sending Messages
|
|
248
179
|
|
|
249
|
-
|
|
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.
|
|
253
|
-
|
|
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" },
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
219
|
+
→ **[Sending messages](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/messages/README.md)**
|
|
394
220
|
|
|
395
|
-
|
|
396
|
-
Meta. `Whatsapp::MessageTemplates` creates and manages those templates, so they can live in
|
|
397
|
-
your codebase and ship from CI instead of being clicked together in WhatsApp Manager.
|
|
221
|
+
## 📋 Managing Templates
|
|
398
222
|
|
|
399
|
-
|
|
400
|
-
|
|
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.
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
template.status # => "APPROVED"
|
|
473
|
-
template.editable? # => true (APPROVED, REJECTED and PAUSED templates can be edited)
|
|
262
|
+
## 🔔 Webhooks
|
|
474
263
|
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
283
|
+
notification.entry.each do |entry|
|
|
284
|
+
entry.changes.each { |change| WebhookJob.perform_later(change) }
|
|
285
|
+
end
|
|
523
286
|
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
541
|
-
`APPROVED` immediately:
|
|
291
|
+
Every notification deserializes into typed objects — no raw hash spelunking:
|
|
542
292
|
|
|
543
293
|
```ruby
|
|
544
|
-
|
|
545
|
-
name: "my_delivery_update", language: "en_US", category: "UTILITY",
|
|
546
|
-
library_template_name: "delivery_update_1",
|
|
547
|
-
library_template_button_inputs: [
|
|
548
|
-
{ type: "URL", url: { base_url: "https://example.com/{{1}}",
|
|
549
|
-
url_suffix_example: "https://example.com/order_update" } },
|
|
550
|
-
]
|
|
551
|
-
)
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
### Notes
|
|
294
|
+
message = change.value.messages.first
|
|
555
295
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
571
|
-
the `whatsapp_business_management` permission, same as [template management](#managing-templates).
|
|
308
|
+
## 📷 Media
|
|
572
309
|
|
|
573
310
|
```ruby
|
|
574
|
-
Whatsapp.
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
|
|
336
|
+
Tech Providers routing several accounts to different callback URLs pass an
|
|
337
|
+
`override_callback_uri:`.
|
|
603
338
|
|
|
604
|
-
|
|
605
|
-
apps = Whatsapp::SubscribedApp::List.call
|
|
606
|
-
apps.map(&:name) # Collection is Enumerable
|
|
607
|
-
apps.first.link # => "https://www.facebook.com/games/?app_id=..."
|
|
339
|
+
→ **[Subscribed apps](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/subscribed_app/README.md)**
|
|
608
340
|
|
|
609
|
-
|
|
610
|
-
```
|
|
341
|
+
## 📅 Scheduling Campaigns
|
|
611
342
|
|
|
612
|
-
|
|
343
|
+
Send an approved template to an audience at a set time, and list what is scheduled:
|
|
613
344
|
|
|
614
345
|
```ruby
|
|
615
|
-
Whatsapp::
|
|
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
|
-
|
|
357
|
+
Meta's default fields leave out `id`, so ask for it with `fields:` when you need it.
|
|
619
358
|
|
|
620
|
-
|
|
621
|
-
`Whatsapp::Client` built from `Whatsapp.configuration`), and raises
|
|
622
|
-
`Whatsapp::SubscribedApp::Error` if no `waba_id` is configured or the API rejects the
|
|
623
|
-
request.
|
|
359
|
+
→ **[Campaign schedules](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/schedule/README.md)**
|
|
624
360
|
|
|
625
|
-
##
|
|
361
|
+
## 📞 Business Phone Numbers
|
|
626
362
|
|
|
627
|
-
A
|
|
628
|
-
|
|
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")
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
+
details.name # => "Acme Corp"
|
|
391
|
+
details.approved? # => true
|
|
674
392
|
|
|
675
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
`ActiveModel::ValidationError` before any request is made.
|
|
683
|
-
|
|
684
|
-
Pass `data_localization_region` to enable local storage in one of Meta's supported
|
|
685
|
-
regions (`AU ID IN JP SG KR DE CH GB BR BH ZA AE CA`):
|
|
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::
|
|
689
|
-
|
|
400
|
+
Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
|
|
401
|
+
# => "Open daily 9-5"
|
|
690
402
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
698
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
715
|
-
|
|
419
|
+
Whatsapp::BusinessPhoneNumber::Username::Claim.call(username: "jaspersmarket").reserved?
|
|
420
|
+
# => true, until Meta makes the feature generally available
|
|
716
421
|
|
|
717
|
-
|
|
718
|
-
bundle exec rake whatsapp:install:webhook
|
|
422
|
+
Whatsapp::BusinessPhoneNumber::Username::Get.call.approved? # => false, for now
|
|
719
423
|
```
|
|
720
424
|
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
443
|
+
Everything descends from `Whatsapp::Error`, and each module raises its own subclass so
|
|
444
|
+
you can rescue narrowly:
|
|
754
445
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
`
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
465
|
+
→ **[Errors](https://github.com/saleszera/ruby-whatsapp/blob/main/docs/errors.md)**
|
|
778
466
|
|
|
779
|
-
##
|
|
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 #
|
|
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
|
-
|
|
496
|
+
Available as open source under the terms of the
|
|
497
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
|
793
498
|
|
|
794
|
-
|
|
499
|
+
---
|
|
795
500
|
|
|
796
|
-
|
|
501
|
+

|