Send WhatsApp Messages from Ruby on Rails with the TextMeFlow API
The TextMeFlow API is plain JSON over HTTPS, so a Rails app doesn't need a gem to talk to it — Net::HTTP (or Faraday if you already use it for other services) and Ruby's built-in OpenSSL cover sending messages, retrying rate limits, and verifying inbound webhooks. This guide walks through all three with code that drops straight into a service object and a controller.
Before you start
- Create a free TextMeFlow account — 50 messages a month, forever, no credit card and no Meta Business verification.
- Scan the QR code from the portal with the phone holding the WhatsApp number you want to send from.
- Copy your API key from the dashboard and your webhook secret from
/admin/webhook.
The full endpoint reference lives at /docs/api; everything below targets POST /v1/messages on https://api.textmeflow.eu.
A send service
Wrap the call in a small service object rather than scattering Net::HTTP calls through controllers or jobs:
# app/services/text_me_flow/client.rb
require "net/http"
require "json"
require "uri"
module TextMeFlow
class Client
BASE_URL = "https://api.textmeflow.eu"
def initialize(api_key: Rails.application.credentials.textmeflow_api_key)
@api_key = api_key
end
def send_message(to:, text: nil, media_url: nil)
uri = URI("#{BASE_URL}/v1/messages")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@api_key}"
request.body = { to: to, text: text, media_url: media_url }.compact.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
handle_response(response)
end
private
def handle_response(response)
case response.code.to_i
when 202
JSON.parse(response.body)
when 429
raise RateLimited.new(JSON.parse(response.body))
else
raise Error, "textmeflow: unexpected status #{response.code} — #{response.body}"
end
end
end
class Error < StandardError; end
class RateLimited < Error
attr_reader :reason, :retry_after_seconds
def initialize(body)
@reason = body["reason"]
@retry_after_seconds = body["retry_after_seconds"]
super("rate limited: #{@reason}")
end
end
end
Keep to in E.164 format (+32472932208) — a number without the leading + and country code is rejected with a 422 and is the single most common integration bug, in any language. media_url is optional and accepts any publicly reachable file up to 100 MB; omit it for a plain text message. A 202 means the message is queued, not delivered — the message_id in the response body is what you use to check real status afterwards.
Retrying on 429 from a background job
Since Rails apps already run Sidekiq or GoodJob for anything that touches a third-party API, let the job's own retry mechanism do the waiting instead of sleeping inline:
# app/jobs/send_whatsapp_job.rb
class SendWhatsappJob < ApplicationJob
queue_as :default
retry_on TextMeFlow::RateLimited,
wait: ->(executions) { TextMeFlow::RateLimited.new({}).retry_after_seconds || executions * 5 },
attempts: 5
def perform(to:, text:)
TextMeFlow::Client.new.send_message(to: to, text: text)
end
end
A 429 body always includes reason (burst_exceeded or hourly_exceeded) and retry_after_seconds, documented on the rate limits page — there's no need to guess a backoff interval or poll in a tight loop. For the full reasoning on polling GET /v1/messages/{id} versus trusting webhooks for delivery status, see handling rate limits and message status.
Verifying inbound webhooks in a Rails controller
Customer replies are POSTed to whatever endpoint you configure in /admin/webhook, signed with HMAC-SHA256 over the raw request body in an X-TextMeFlow-Signature: sha256=<hex> header. Rails' ActionDispatch buffers the raw body, so read it with request.raw_post before any JSON parsing happens — parsing first and re-serializing for comparison is the most common way this check silently breaks:
# app/controllers/webhooks/text_me_flow_controller.rb
class Webhooks::TextMeFlowController < ActionController::API
skip_before_action :verify_authenticity_token, raise: false
def create
raw = request.raw_post
secret = Rails.application.credentials.textmeflow_webhook_secret
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", secret, raw)
received = request.headers["X-TextMeFlow-Signature"].to_s
unless ActiveSupport::SecurityUtils.secure_compare(expected, received)
return head :unauthorized
end
event_id = request.headers["X-TextMeFlow-Event-Id"]
return head :ok if ProcessedWebhookEvent.exists?(event_id: event_id)
ProcessedWebhookEvent.create!(event_id: event_id)
HandleIncomingWhatsappJob.perform_later(JSON.parse(raw))
head :ok
end
end
ActiveSupport::SecurityUtils.secure_compare is Rails' constant-time comparison — use it instead of ==, which short-circuits on the first mismatched byte and leaks timing information. X-TextMeFlow-Event-Id is stable across retries, so a unique index on that column (via ProcessedWebhookEvent) is enough to avoid double-processing: TextMeFlow retries undelivered webhooks up to 5 times with increasing backoff, and anything but a 2xx within 10 seconds counts as a failed attempt. Respond 200 immediately and hand the real work to perform_later — a slow controller action looks identical to a timeout from TextMeFlow's side and gets retried needlessly. The full header list and payload shape are in the webhook docs; the signature mechanics are covered in more depth, with PHP/Python/Node side by side, in verifying WhatsApp webhook signatures.
Wrapping up
A Rails integration needs three pieces: a Net::HTTP POST with a bearer token, a job retry policy that respects retry_after_seconds instead of a fixed sleep, and an HMAC check on the way in using ActiveSupport::SecurityUtils.secure_compare — no gem required. Start free and you can have a send service and a webhook controller both running before your next deploy.
Zelf WhatsApp-berichten versturen via API?
Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.
Gratis voor altijd