ruby-whatsapp
A small, dependency-light Ruby client for the Meta WhatsApp Cloud API.
Every message type the Cloud API supports โ text, media, location, contacts,
templates, and the full family of interactive messages โ is modeled as its own
ActiveModel-validated Ruby class. A malformed payload raises in your own process,
naming the field that's wrong, instead of travelling to Meta and coming back as
error 131009 โ the catch-all code the Cloud API returns for a too-long caption, an
unsupported message type, a bad message ID, and a dozen other unrelated mistakes.
photo = "https://example.com/new-arrivals.jpg"
# Send it ๐ธ
Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "Just landed")
# Get a field wrong, and you find out here โ not from Meta, three seconds later
Whatsapp::Messages.send_image!(to: "+15551234567", link: photo, caption: "x" * 2000)
# => ActiveModel::ValidationError: Caption is too long (maximum is 1024 characters)
Beyond sending, the gem covers the whole surface: media upload and download, template creation and management, inbound webhook parsing, webhook subscription, phone number onboarding, and reading or updating the business account itself.
Table of Contents
- โจ Why ruby-whatsapp
- ๐ฆ Installation
- ๐ง Configuration
- ๐ Quick Start
- ๐ Documentation
- ๐ฌ Sending Messages
- ๐ Managing Templates
- ๐ Webhooks
- ๐ท Media
- ๐ Subscribed Apps
- ๐ Business Phone Numbers
- ๐จ Errors
- ๐งฉ Compatibility
- ๐จ Development
- ๐ค Contributing
- ๐ License
โจ Why ruby-whatsapp
- Typed, validated message classes for every Cloud API message kind โ invalid payloads raise before any HTTP request is made, with the failing attribute named.
- Template rules checked client-side. Meta's documented constraints โ name format, character limits, placeholder/example matching, quick-reply contiguity, carousel structure โ are enforced locally, so a mistake costs a validation error instead of a 24-hour review cycle.
- Hardened media downloads.
Media#downloadrefuses to attach your bearer token to a non-HTTPS URL or a host outside an allowlist, so a token can never leak to an attacker-influenced URL. - Secrets stay out of logs.
api_key,app_secret, andverify_tokenare redacted from every#inspect, including credentials in transit like a registration PIN. - Inbound webhooks, fully typed. An object tree for all 19 documented Meta notification fields, plus HMAC signature verification and a Rails controller generator.
- Persistent HTTP connections via
HTTP.persistent, reused across requests, with pluggable logging. - Four runtime dependencies, no Rails requirement. Rails integration activates itself when Rails is present.
๐ฆ Installation
# Gemfile
gem "ruby-whatsapp"
bundle install
Or standalone:
gem install ruby-whatsapp
๐ง Configuration
Whatsapp.configure do |config|
config.api_key = ENV.fetch("WHATSAPP_API_KEY") # a Meta system-user / app access token
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID") # the sending phone number ID
end
| Option | Default | Needed for |
|---|---|---|
api_key |
โ | Everything |
phone_id |
โ | Messages, media, phone-number onboarding |
waba_id |
โ | Template management, subscribed apps, the business account |
verify_token |
โ | The webhook GET handshake |
app_secret |
โ | Webhook signature verification |
host |
https://graph.facebook.com |
Overriding the API host |
version |
v24.0 |
Pinning a Graph API version |
media_host_allowlist |
3 Meta hosts | Media download safety |
Which ID addresses what โ the most common source of confusion:
phone_id โโโบ Messages ยท Media ยท BusinessPhoneNumber
permission: whatsapp_business_messaging
waba_id โโโบ MessageTemplates ยท SubscribedApp ยท BusinessPhoneNumber::Account
permission: whatsapp_business_management
BusinessPhoneNumber appears on both sides: its onboarding actions address a phone
number, its Account actions the account that number belongs to.
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.
โ Full configuration reference
๐ Quick Start
require "ruby/whatsapp"
Whatsapp.configure do |config|
config.api_key = ENV.fetch("WHATSAPP_API_KEY")
config.phone_id = ENV.fetch("WHATSAPP_PHONE_ID")
end
response = Whatsapp::Messages.send_text!(to: "+15551234567", body: "Hello from ruby-whatsapp!")
response..first.id # => "wamid.HBgLMTU1NTU1NTU1NTUV..."
response.contacts.first.wa_id # => "15551234567"
๐ Documentation
| Area | What it covers |
|---|---|
| Configuration | Credentials, the client, connection reuse, instrumentation |
| Messages | Every message kind, one page each, with exact payloads |
| Message Templates | Creating and managing templates: standard, auth, carousel, offers, library |
| Webhooks | Install, verification, signatures, and all 19 notification fields |
| Media | Upload, download, delete, and the token-safety allowlist |
| Subscribed Apps | Turning webhook delivery on and off for an account |
| Business Phone Numbers | Onboarding: request code โ verify โ register |
| Business Account | Reading and updating the account: name, timezone, review status |
| Business Profile | The profile a user sees: about, description, address, vertical, picture |
| Errors | The exception hierarchy and retry strategy |
Or start at the documentation index.
๐ฌ Sending Messages
Every registered kind gets its own Whatsapp::Messages.send_<kind>! class method:
Whatsapp::Messages.send_interactive!(
to: "+15551234567",
type: :reply_buttons,
body: "Would you like to confirm your order?",
action: { buttons: [{ id: "confirm", title: "Confirm" },
{ id: "cancel", title: "Cancel" }] }
)
| Method | Sends |
|---|---|
send_text! |
Plain text with an optional link preview |
send_image! |
An image with an optional caption |
send_video! |
A video with an optional caption |
send_audio! |
A voice note or audio clip |
send_document! |
A file with an optional caption and filename |
send_sticker! |
A sticker |
send_reaction! |
An emoji reaction to a previous message |
send_location! |
A latitude/longitude pin |
send_contacts! |
A rich, vCard-like contact card |
send_address! |
A delivery-address form (India & Singapore only) |
send_location_request! |
A prompt asking the user to share their location |
send_template! |
A pre-approved marketing/utility/authentication template |
send_interactive! |
Reply buttons, lists, CTA URLs, or carousels |
mark_message_as_read! |
Closes the read-receipt loop on an inbound message |
Each accepts an optional client: and returns a Whatsapp::Messages::Response with
typed #contacts and #messages. Invalid input raises
ActiveModel::ValidationError before any request is made.
โ Sending messages
๐ Managing Templates
Sending a template requires one that already exists and has been approved by Meta.
Whatsapp::MessageTemplates creates and manages those, so they live in your codebase
and ship from CI instead of being clicked together in WhatsApp Manager.
templates = Whatsapp::MessageTemplates.new # needs waba_id
created = templates.create(
name: "order_confirmation", language: "en_US", category: "UTILITY",
components: [
{ type: :body,
text: "Thank you, {{1}}! Your order number is {{2}}.",
example: ["Pablo", "860198-230332"] },
{ type: :buttons, buttons: [
{ type: :url, text: "Track order", url: "https://example.com/orders/{{1}}", example: "1234" },
] },
]
)
created.status # => "PENDING" โ Meta reviews asynchronously, up to 24 hours
Meta's rules are checked before the request, so a mistake raises immediately instead of costing a review cycle:
templates.create(name: "Order Confirmation", ...)
# => ActiveModel::ValidationError: Name must contain only lowercase alphanumeric
# characters and underscores
Covers standard, authentication/OTP, carousel, limited-time offer, and library templates, plus list, find, update, and delete.
๐ Webhooks
Meta pushes inbound messages, delivery statuses, and ~18 other notification types to a callback URL you register. Inside a Rails app:
bundle exec rake whatsapp:install:webhook
That copies a personalizable controller to
app/controllers/whatsapp/webhooks_controller.rb and prints the routes to add:
def receive
raw_body = request.body.read
return head(:unauthorized) unless Whatsapp::Webhook::Signature.valid?(
payload: raw_body, header: request.headers["X-Hub-Signature-256"]
)
notification = Whatsapp::Webhook::Notification.deserialize(JSON.parse(raw_body))
notification.entry.each do |entry|
entry.changes.each { |change| WebhookJob.perform_later(change) }
end
head :ok
end
Every notification deserializes into typed objects โ no raw hash spelunking:
= change.value..first
.from # => "16505551234"
.id # => "wamid.HBg..."
.body # => "Does it come in another color?"
All 19 documented fields get a class. Only messages has a Meta-published schema; the
other 18 are best-effort and flagged as such, per field.
โ Webhooks ยท inbound messages & statuses
๐ท Media
media = Whatsapp::Media.new
media_id = media.upload(file_path: "photo.jpg", type: "image/jpeg")
info = media.get_url(media_id: media_id)
media.download(url: info["url"], save_to: "photo.jpg")
media.delete(media_id: media_id) # => true
download refuses to attach the API token to a non-HTTPS URL or a host that is not on
Configuration#media_host_allowlist, so a token is never sent to an
attacker-influenced URL. Bodies stream to disk rather than buffering in memory.
โ Media
๐ Subscribed Apps
Before your app receives any webhook notifications for a business account, it has to be subscribed to it:
Whatsapp::SubscribedApp::Subscribe.call # start webhook delivery
Whatsapp::SubscribedApp::List.call.map(&:name) # => ["My App"]
Whatsapp::SubscribedApp::Unsubscribe.call # stop it
Tech Providers routing several accounts to different callback URLs pass an
override_callback_uri:.
โ Subscribed apps
๐ Business Phone Numbers
A phone number is unusable with Cloud API until it is registered โ the prerequisite that makes sending, media, and templates work for it at all.
RequestCode -> VerifyCode -> Register (onboarding)
(send OTP) (confirm it) (activate on Cloud API)
Deregister (the reverse switch)
Whatsapp::BusinessPhoneNumber::RequestCode.call(code_method: "SMS", language: "en_US")
Whatsapp::BusinessPhoneNumber::VerifyCode.call(code: "123456")
Whatsapp::BusinessPhoneNumber::Register.call(pin: "212834")
Whatsapp::BusinessPhoneNumber::Deregister.call
The 6-digit two-step verification PIN and the local-storage region are validated client-side โ which matters here, because a rejected attempt still counts against a rate limit of 10 requests per number per 72-hour window.
The account that number belongs to is readable and writable too โ the one part of this
module that addresses waba_id rather than phone_id:
details = Whatsapp::BusinessPhoneNumber::Account::Get.call(fields: %w[name account_review_status])
details.name # => "Acme Corp"
details.approved? # => true
Whatsapp::BusinessPhoneNumber::Account::Update.call(name: "Acme Corporation").success # => true
And so is the business profile โ the card a user sees before they reply. It addresses
the same phone_id as the onboarding actions:
Whatsapp::BusinessPhoneNumber::Profile::Get.call.about
# => "Open daily 9-5"
Whatsapp::BusinessPhoneNumber::Profile::Update.call(
about: "Open daily 9-6", vertical: "RETAIL", websites: ["https://acme.test"]
).success
# => true
Character limits, the 21-value vertical enum, and the two-website cap are all checked before the request goes out.
โ Business phone numbers ยท the business account ยท the business profile
๐จ Errors
Everything descends from Whatsapp::Error, and each module raises its own subclass so
you can rescue narrowly:
| Class | Raised by |
|---|---|
Whatsapp::RequestError |
A failed message send |
Whatsapp::Messages::PayloadError |
An unknown message kind |
Whatsapp::Media::MediaError |
Anything in Media |
Whatsapp::MessageTemplates::TemplateError |
Anything in MessageTemplates |
Whatsapp::SubscribedApp::Error |
Anything in SubscribedApp |
Whatsapp::BusinessPhoneNumber::Error |
Anything in BusinessPhoneNumber, including Account |
Local validation failures raise ActiveModel::ValidationError instead โ they happen at
construction time, before any network call, and carry per-attribute detail:
rescue ActiveModel::ValidationError => e
e.model.errors.full_messages
# => ["Caption is too long (maximum is 1024 characters)"]
โ Errors
๐งฉ Compatibility
| | |
| --- | --- |
| Ruby | >= 3.2 (CI runs 3.2 and 3.4) |
| Graph API | v24.0 by default, overridable |
| Rails | Optional. Webhook controller generator activates when Rails is loaded |
| Dependencies | activemodel, http, logger, zeitwerk |
๐จ Development
After checking out the repo, run bundle install, then:
bundle exec rake # specs + RuboCop (the default task)
bundle exec rspec # specs only
bundle exec rubocop # lint only
bin/console # interactive prompt
๐ค Contributing
Bug reports and pull requests are welcome at
https://github.com/saleszera/ruby-whatsapp. Please write the failing spec first โ
this gem is developed test-first โ and make sure bundle exec rake is green before
opening a PR.
๐ License
Available as open source under the terms of the MIT License.
