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

  • 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