0.0
The project is in a healthy, maintained state
A small, dependency-light Ruby client for the Meta WhatsApp Cloud API: send text, media, template, interactive, and other message types, and upload, download, and manage media assets.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 5.1
>= 1.0
~> 2.6
 Project Readme

ruby-whatsapp

A small, dependency-light Ruby client for the Meta WhatsApp Cloud API.

Gem Version Build Status Downloads License: MIT Ruby


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#download refuses 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, and verify_token are 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.messages.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.

โ†’ Managing templates

๐Ÿ”” 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:

message = change.value.messages.first

message.from    # => "16505551234"
message.id      # => "wamid.HBg..."
message.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.


that's all folks