textbee Logotextbee.dev
Plans from $9.99/mo.View Plans

Send and receive SMS in Ruby with a REST API

Published and updated

You need an API key and one Android phone with a SIM registered as a device. From Ruby you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.

Prerequisites

  • A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
  • An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
  • Ruby 2.6 or newer. The samples use net/http, json, openssl and webrick from the standard library, so no gems are required.

Send an SMS

One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.

send.rb
require "json"
require "net/http"

BASE_URL = ENV.fetch("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = ENV.fetch("TEXTBEE_API_KEY")

def send_sms(recipients, message)
  uri = URI("#{BASE_URL}/gateway/send-sms")
  request = Net::HTTP::Post.new(uri, "x-api-key" => API_KEY, "Content-Type" => "application/json")
  request.body = JSON.generate(recipients: recipients, message: message)
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  raise "HTTP #{response.code}" unless response.code == "200"
  JSON.parse(response.body)["data"]
end

result = send_sms(["+12015550123"], "Hello from textbee")
puts "#{result["smsBatchId"]} #{result["recipientCount"]}"

Receive SMS with a webhook

textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.

webhook.rb
require "json"
require "openssl"
require "webrick"

SECRET = ENV.fetch("TEXTBEE_WEBHOOK_SECRET")
seen = {} # use your database in production

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize
  a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end

server = WEBrick::HTTPServer.new(Port: ENV.fetch("PORT", "3000").to_i, AccessLog: [], Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN))
server.mount_proc "/" do |request, response|
  raw_body = request.body.to_s
  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, raw_body)
  unless secure_compare(expected, request["X-Signature"].to_s)
    response.status = 401
    next
  end

  event = JSON.parse(raw_body)
  unless seen[event["idempotencyKey"]]
    seen[event["idempotencyKey"]] = true
    puts "#{event["sender"]}: #{event["message"]}" if event["webhookEvent"] == "MESSAGE_RECEIVED"
    $stdout.flush
  end
  response.status = 200
end

trap("TERM") { server.shutdown }
server.start

Poll for new messages with a cursor

The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.

poll.rb
require "json"
require "net/http"

BASE_URL = ENV.fetch("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = ENV.fetch("TEXTBEE_API_KEY")

def fetch_page(params)
  uri = URI("#{BASE_URL}/gateway/messages")
  uri.query = URI.encode_www_form(params)
  request = Net::HTTP::Get.new(uri, "x-api-key" => API_KEY)
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  raise "HTTP #{response.code}" unless response.code == "200"
  JSON.parse(response.body)
end

params = { direction: "received", order: "asc", limit: 50, from: "2026-09-01T00:00:00Z" }
loop do
  page = fetch_page(params)
  page["data"].each { |message| puts "#{message["_id"]} #{message["sender"]} #{message["message"]}" }
  break unless page["meta"]["hasMore"]
  params[:cursor] = page["meta"]["nextCursor"] # store this to resume the next poll
end

Handle errors

A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.

errors.rb
require "json"
require "net/http"

BASE_URL = ENV.fetch("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = ENV.fetch("TEXTBEE_API_KEY")

def send_sms(recipients, message, retried: false)
  uri = URI("#{BASE_URL}/gateway/send-sms")
  request = Net::HTTP::Post.new(uri, "x-api-key" => API_KEY, "Content-Type" => "application/json")
  request.body = JSON.generate(recipients: recipients, message: message)
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
  return JSON.parse(response.body)["data"] if response.code == "200"

  detail = (JSON.parse(response.body) rescue {})["message"] || response.message
  case response.code
  when "429"
    raise "Rate limited twice: #{detail}" if retried
    sleep 2 # a plan limit or a burst; one retry after a pause
    send_sms(recipients, message, retried: true)
  when "401" then raise "API key rejected: #{detail}"
  when "400" then raise "Request rejected: #{detail}"
  else raise "HTTP #{response.code}: #{detail}"
  end
end

p send_sms(["+12015550123"], "Hello from textbee")

Using it in Rails

Wrap the send call in a service object or an ActiveJob so the request cycle does not wait on it, with the key in Rails credentials. For the webhook, skip_forgery_protection on that controller, read request.raw_post for the exact bytes, verify the signature, then head :ok.

Honest limits

One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.

Frequently asked questions

Is there a Ruby gem for textbee?

No official gem. net/http is enough, and in Rails you can use Faraday or HTTParty if the project already depends on them.

Why does the webhook sample implement its own secure compare?

So it runs on Ruby 2.6, whose openssl does not ship one. On Ruby 2.7 and newer use OpenSSL.fixed_length_secure_compare, and in Rails ActiveSupport::SecurityUtils.secure_compare.

How do I test without sending a real message?

Point TEXTBEE_BASE_URL at a WEBrick or Rack stub that returns the documented response. The published samples are executed against exactly that.

Read next