· 5 min · TextMeFlow Team

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

  1. Create a free TextMeFlow account — 50 messages a month, forever, no credit card and no Meta Business verification.
  2. Scan the QR code from the portal with the phone holding the WhatsApp number you want to send from.
  3. 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