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.
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.
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.startPoll 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.
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
endHandle 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.
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
- How to Send SMS Programmatically with textbee: A Complete Guide
- How to Receive SMS and Process Webhooks with textbee
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in curl and shell with a REST API
- Send and receive SMS in Python with a REST API
- All languages