WhatsApp Operators DailyThe Blueticks DispatchThursday, September 24, 2026
Productivity

How to Send WhatsApp Messages from Ruby on Your Own Number (2026)

One authenticated POST from Ruby, zero gems. Then the parts that bite Rails: the escape method that ruins the path, the null waMessageKey, and a Sidekiq retry that sends twice.

DRBy Daniel Roth · September 24, 2026 · 10 min read
How to Send WhatsApp Messages from Ruby on Your Own Number (2026)

Your Rails app already knows the order shipped, the invoice is overdue, the appointment is tomorrow. Getting that fact into WhatsApp is where it stalls, usually behind a Business Solution Provider contract nobody wants to sign for a few hundred notifications a day.

Here is the plain version. A whatsapp api ruby integration is one authenticated POST to a REST endpoint, sending from the WhatsApp number you already own, linked as a device over WhatsApp Web or a managed 24/7 gateway. It is not Meta's Cloud API, so there are no templates, no Business verification, and no per-message billing anywhere below. Consent stays yours, and nobody can promise a number will never be actioned.

What is a WhatsApp API integration in Ruby, really?

It is a single HTTP call from your own code to a number you already control. You POST to a REST endpoint, authenticate with a bearer key, and the message leaves from your linked WhatsApp account over WhatsApp Web or a hosted gateway. This is the own-number model, not Meta's Cloud API, so no templates and no per-message fees apply.

The distinction changes your whole integration. On the Cloud API path you register a number with Meta, get it approved, write message templates, submit them for review, and pay per conversation. On the own-number path you send from a phone your customers already have saved, in plain text, with a bt_live_ key and Ruby's standard Net::HTTP. The auth model and why your own number works this way is the subject of the own-number REST API guide, so this piece stays on the Ruby mechanics.

Hold onto one thing from the start. Sending from your own number is not a licence to blast. A loop firing unsolicited messages is the fastest way to get a number flagged under WhatsApp's Business Policy. Consent is yours to collect.

What do you need before your first send from Ruby (or Rails)?

Three things: a recent Ruby toolchain (3.0 or newer is a safe floor, though the standard library used here has been stable for years), a WhatsApp number already linked to your Blueticks account, and a bt_live_ API key. The recipient goes in the URL path as an E.164 number like +15551234567, not in the JSON body. That single detail causes most first-attempt failures.

The pre-flight, in order:

  1. Mint a key. Open dev.blueticks.co, sign in, create a key, copy it once. Keys are bearer tokens, so they live in Rails encrypted credentials or an environment variable, never in a committed file.
  2. Link a number. Blueticks drives your number, not a Meta-provisioned one. If you already connected a phone in the app, you are done.
  3. Check nothing else. The first send uses only net/http, uri, json, erb and time from the standard library. Zero gems, zero bundle install.

Now the trap. The endpoint is POST /v1/scheduled-messages/{chatId} on https://api.blueticks.co, and {chatId} is a path segment. A recipient is an E.164 number with a leading +, and a bare + in a path is ambiguous, so it has to be percent-encoded as %2B. You can also target a chat id directly, such as 120363...@g.us for a group. The same first send in shell, Python and Node lives in the language-agnostic walkthrough if you want to sanity-check the request outside Ruby first.

How do you send your first WhatsApp message from Ruby with only Net::HTTP (no gems)?

Escape the recipient with ERB::Util.url_encode, set an Authorization: Bearer header, and POST a flat JSON body of {"type":"text","text":"..."} over a single Net::HTTP connection with explicit timeouts. Omit sendAt and it sends immediately. This is the send whatsapp message ruby baseline, and it needs nothing beyond the standard library.

require "net/http"
require "uri"
require "erb"
require "json"

api_key   = ENV.fetch("BLUETICKS_API_KEY") # bt_live_YOUR_KEY
recipient = "+15551234567"

# The recipient is a PATH segment. ERB::Util.url_encode applies RFC 3986
# escaping: '+' -> %2B, a space -> %20. Do NOT use CGI.escape or
# URI.encode_www_form_component here (they encode a space as '+').
segment = ERB::Util.url_encode(recipient) # -> %2B15551234567
uri = URI("https://api.blueticks.co/v1/scheduled-messages/#{segment}")

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl      = true
http.open_timeout = 5  # seconds to open the socket
http.read_timeout = 10 # seconds to wait for the response

request = Net::HTTP::Post.new(uri)
request["Authorization"]   = "Bearer #{api_key}"
request["Content-Type"]    = "application/json"
request["Idempotency-Key"] = "invoice-4471-reminder"
# Build the body with to_json, never string interpolation: a `"` in a
# customer name would break a hand-concatenated payload.
request.body = { type: "text", text: "Your invoice #4471 is due tomorrow." }.to_json

response = http.request(request)
puts "#{response.code} #{response.body}"

Three things about that body. type is required and validation-only, so a bare {"text":"hi"} is rejected with a 400. Text tops out at 4,096 characters. A 2xx that returns a resource is wrapped in {"success":true,"data":{...}}, so the id lives at data["id"], never at the top level.

Now the Ruby-specific footgun, and it is quiet. The reflex for escaping a URL value is CGI.escape or URI.encode_www_form_component. Both apply the application/x-www-form-urlencoded rule and turn a space into a +. That is correct for a query string and wrong for a path segment. For a bare phone number you would never notice, because a plain number has no space to mangle and both methods do encode the + to %2B. The day a chat id carries a character those rules treat differently, CGI.escape ships a + where the server expected %20 and the send resolves to the wrong recipient. ERB::Util.url_encode applies RFC 3986 escaping, which is the right rule for a path segment. Use it, not the form encoders.

Net::HTTP or Faraday/HTTParty, which, and how do you pool the connection?

Start with Net::HTTP for a single send and reach for Faraday or HTTParty when you want middleware, retries and a connection pool in one place. The one rule that matters either way: reuse one connection with explicit timeouts. A fresh Net::HTTP.start per send throws away the socket and pays a new TLS handshake every time.

The correctness catch is the timeout. Ruby's Net::HTTP sets open_timeout and read_timeout to 60 seconds by default. That is not infinite, but 60 seconds is far too long: one stalled send ties up a worker for a full minute before it gives up. Set both explicitly to a few seconds, the direct analogue of the Go trap where http.Get has no timeout at all.

For anything past a one-off, reuse a persistent connection. The zero-gem way is a keep-alive block:

# Net::HTTP with keep-alive: one socket drains many sends in the block.
Net::HTTP.start(uri.host, uri.port, use_ssl: true,
                open_timeout: 5, read_timeout: 10) do |http|
  orders.each do |order|
    req = Net::HTTP::Post.new("/v1/scheduled-messages/#{ERB::Util.url_encode(order.phone)}")
    req["Authorization"]   = "Bearer #{api_key}"
    req["Content-Type"]    = "application/json"
    req["Idempotency-Key"] = "order-#{order.id}-shipped"
    req.body = { type: "text", text: "Your order #{order.id} has shipped." }.to_json
    http.request(req) # rides the same warm connection
  end
end

In a long-running process, Net::HTTP::Persistent or a shared Faraday connection keeps a real pool across jobs instead of one block. A single Faraday connection built once, with timeouts baked in, is the shape most Rails apps land on:

require "faraday"

# Build ONCE (an initializer / constant), reuse for the process lifetime.
CONN = Faraday.new(
  url: "https://api.blueticks.co",
  headers: { "Content-Type" => "application/json" },
  request: { open_timeout: 5, timeout: 10 },
)
# For true keep-alive pooling, add the faraday-net_http_persistent adapter
# and `f.adapter :net_http_persistent` inside a Faraday.new block.

Whichever client you pick, the request shape and the response envelope are identical. Faraday earns its keep when you want retry middleware and instrumentation without hand-rolling them; Net::HTTP is enough when you do not.

How do you build and parse the JSON body safely (and survive a null waMessageKey)?

Build the body with { type: "text", text: user_text }.to_json so a quote or newline in the text can never break the payload. On the way back, JSON.parse gives you a Hash, but waMessageKey is null on a scheduled send, so it parses to nil, not a Hash. Guard it with &. before reading a field, or a nil chat name will raise in production.

Hand-concatenating JSON works until a customer's name has a " in it. Let JSON build and read it:

require "json"

parsed = JSON.parse(response.body)
data   = parsed["data"]
return if data.nil? # a 2xx with no payload: don't index into nil

message_id = data["id"] # 24-char hex queue id, your handle

# waMessageKey is an OBJECT, null until the engine dispatches, never a string.
wa_key = data["waMessageKey"]       # => nil on a scheduled/not-yet-sent message
serialized = wa_key&.fetch("_serialized", nil) # &. guards the nil Hash

The trimmed response on a scheduled send looks like this:

{ "success": true, "data": { "id": "6a1f...c2", "status": "pending", "waMessageKey": null } }

Typing waMessageKey as a string is the mistake that passes every test and breaks in production. On a scheduled send the engine has not dispatched yet, so waMessageKey comes back null and only fills in later as an object with fromMe, remote, id, _serialized and participant. It is an object, not a bare key string. Read data["id"], the 24-character hex queue id, as your handle in the meantime, and only touch wa_key["_serialized"] after the &. guard proves it is present. The lifecycle the message moves through is pending, then confirmed, received, read, played, or failed.

How do you schedule a message for later and get sendAt right in Ruby?

Add a camelCase sendAt field holding an RFC 3339 timestamp with an explicit offset, built with Time.now.utc.iso8601. Always send UTC. The accepted window is roughly 10 seconds to 365 days ahead; anything outside is rejected at validation with a 400.

require "time"

# .utc gives a 'Z' offset, so the moment is unambiguous.
send_at = (Time.now.utc + 2 * 3600).iso8601 # => "2026-09-24T14:30:00Z"

body = { type: "text", text: "Reminder", sendAt: send_at }.to_json

The Ruby-specific way this goes wrong is a bare Time.now without .utc. That serialises with your server's local offset, so the same code schedules a different instant on your laptop than in a UTC container. Call .utc first, then Time#iso8601 spells the RFC 3339 string for you. A second trap: compute sendAt, let the job sit in a queue for nine seconds, and dispatch arrives already inside the floor with 400 sendAt must be at least 10 seconds in the future. Compute it at send time, or leave a minute of headroom. Recurring cadences and deeper timezone hardening are a separate mechanism, covered in the scheduling-in-production guide. Do not rebuild that here.

Ready to wire this into your Rails app? Get a bt_live_ key and POST from the number you already own. No Meta Business verification, no message templates, no per-message fees. Point it at a live number and watch the first send land in seconds.

How do you make sends reliable from Rails: Sidekiq, idempotency keys, and retries that never double-send?

Push the send into a background job, give the HTTP call explicit timeouts, retry only 429 and 5xx, and carry a stable Idempotency-Key derived from the business object so a retry never double-sends. That last part is the whole game for a Rails whatsapp api rails integration that survives real traffic, because Sidekiq and ActiveJob are at-least-once: a retried job can run twice.

A tidy desk flat-lay with a closed laptop, a face-down phone, coffee, and a neat evenly-spaced row of pencils, an orderly steady one-at-a-time workflow

# app/jobs/whats_app_send_job.rb
class WhatsAppSendJob
  include Sidekiq::Job
  sidekiq_options queue: :whatsapp, retry: 5

  # ONE Faraday connection for the whole process, timeouts baked in.
  CONN = Faraday.new(
    url: "https://api.blueticks.co",
    headers: { "Content-Type" => "application/json" },
    request: { open_timeout: 5, timeout: 10 },
  )

  def perform(order_id, phone)
    # Key derived ONCE from the business object, so every Sidekiq retry of
    # THIS job carries the identical value. Never SecureRandom.uuid.
    idempotency_key = "order-#{order_id}-shipped"

    segment  = ERB::Util.url_encode(phone)
    response = CONN.post("/v1/scheduled-messages/#{segment}") do |req|
      req.headers["Authorization"]   = "Bearer #{api_key}"
      req.headers["Idempotency-Key"] = idempotency_key
      req.body = { type: "text", text: "Your order #{order_id} has shipped." }.to_json
    end

    code = response.status
    # Re-raise on 429/5xx so Sidekiq retries with the SAME key. A 4xx is a
    # bad body: log it and return so the job does not retry forever.
    raise "retryable #{code}" if code == 429 || code >= 500

    Rails.logger.warn("send #{idempotency_key} rejected: #{code}") if code >= 400
  end

  private

  def api_key
    # Rails encrypted credentials, never a committed file.
    Rails.application.credentials.dig(:blueticks, :api_key)
  end
end

Enqueue it with WhatsAppSendJob.perform_async(order.id, order.phone). Two rules keep the retry safe. First, retry only 429, 5xx and transport errors, never a 400; a 400 is a bad body and will fail identically forever, so re-raising it just burns your five Sidekiq retries. Second, the Idempotency-Key is computed once from the order, ticket or reminder (order-4471-shipped), so every attempt carries the identical value. Generate a SecureRandom.uuid per attempt and each retry becomes a new message. The key is capped at 64 characters and scoped per workspace: resend the same key with an identical body and the original response replays, returning the same message id, so detect a replay by the id, not the status. A different body under the same key returns 409 Conflict, which means your key derivation has a bug.

One production limit to design around: the free plan allows 5 requests per 6-hour window, shared across the REST and MCP surfaces, so a loop testing ten sends hits 429 on the sixth within a second. Any active subscription removes the ceiling. As one Rails operator running nightly shipping notifications put it, "the stable key wasn't about tidiness, it was the difference between one 'your order shipped' and three at 2am after a redeploy retried the queue."

When should you use this vs the WhatsApp Business (Cloud) API instead?

Use the own-number path for transactional volume, real two-way conversations, and a number your customers already have saved. Use Meta's Cloud API and a Business Solution Provider when you need approved marketing templates at broadcast scale, an Official Business Account badge, or a multi-agent shared inbox. The trade is honest and runs both ways.

The Ruby-specific reason a hosted REST engine fits is maintenance. The maintained WhatsApp Web clients are Node projects, not Ruby ones. Running your own from Rails means supervising a second runtime, a Node process and a browser profile, next to your Rails app forever: a session store, a reconnect supervisor, and a pager rotation for something that is not your product. A hosted /v1 endpoint removes that whole tier, which is the argument the no-Meta-verification guide makes in full. The same first send exists in Go, C#, Java and PHP, and the bot-shaped variants live in the Python and Node.js guides.

One honest warning, because a Sidekiq queue makes it easy to get wrong. Fan 500 jobs onto the queue with the concurrency set wide and you fire sends as fast as the workers drain. That burst is the pattern most likely to get a number flagged, whatever sent it, and WhatsApp's rules on automated and bulk messaging apply either way. Keep the concurrency low, spread a batch over hours, and remember that consent is the sender's responsibility. Nobody can guarantee a number will never be actioned.

A smartphone lying face-down on a calm wooden desk beside a hand and a closed notebook, a WhatsApp send confirmed from a Ruby workflow

FAQ

Can I send a WhatsApp message from Ruby without any gems?

Yes. The first-send example imports only net/http, uri, erb, json and time from the standard library. Net::HTTP does the request and JSON builds the body. Gems like Faraday, HTTParty or Sidekiq add ergonomics for pooling, middleware and background jobs, not capability.

Why does Net::HTTP hang a busy Rails worker?

Its open_timeout and read_timeout default to 60 seconds each, so one stalled send holds a worker for a full minute before it gives up. Set both explicitly to a few seconds on the Net::HTTP object (or open_timeout/timeout on a Faraday connection), and reuse one connection so every send does not pay a fresh TLS handshake.

Should I use ERB::Util.url_encode or CGI.escape for the recipient?

ERB::Util.url_encode. The recipient sits in the URL path, and ERB::Util.url_encode applies RFC 3986 escaping, encoding the leading + to %2B and a space to %20. CGI.escape and URI.encode_www_form_component use the form/query rule and encode a space as +, which is wrong for a path segment and can silently ship the message to the wrong recipient.

How do I stop a Sidekiq retry from sending the same WhatsApp message twice?

Send an Idempotency-Key header derived once from the business object (order-4471-shipped) so every retry of the job reuses it. A replay returns the original response and the same 24-hex message id instead of sending again. A SecureRandom.uuid per attempt defeats the whole mechanism; the key must be stable across retries and is capped at 64 characters.

Will sending from Ruby get my WhatsApp number banned?

Nobody can guarantee it will not. You are messaging from your own number under WhatsApp's Business Policy and its rules on automated and bulk messaging, and a loop firing unsolicited sends is the pattern most likely to get a number flagged. Message people who asked to hear from you, spread batches over hours with low Sidekiq concurrency, and stop when someone asks. Consent is the sender's responsibility.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.