How to Send OTP in Ruby on Rails (2026 Guide)
Send and verify SMS OTPs from a Ruby on Rails app using the StartMessaging API. Includes Net::HTTP examples, a service object, controller actions, and rspec tests.
Ruby on Rails is still a fast way to ship a backend, and adding phone verification doesn’t need to be complicated. This guide wires up the StartMessaging OTP API from a Rails app using only Net::HTTP and a clean service object.
Prerequisites
- Rails 6, 7, or 8 with Ruby 3.0+.
- A StartMessaging account and an API key.
Add the key to your Rails credentials or to .env:
# config/credentials.yml.enc
startmessaging:
api_key: sm_live_xxxxxxxxxxxxxxxxxxxx
Create a Rails Service Object
Create app/services/start_messaging_otp.rb:
require "net/http"
require "json"
require "uri"
class StartMessagingOtp
BASE_URL = "https://api.startmessaging.com".freeze
def self.send_otp(phone_number, idempotency_key: SecureRandom.uuid)
post("/otp/send", {
phoneNumber: phone_number,
idempotencyKey: idempotency_key
})
end
def self.verify_otp(request_id, code)
post("/otp/verify", { requestId: request_id, otpCode: code })
end
def self.post(path, body)
uri = URI(BASE_URL + path)
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["X-API-Key"] = Rails.application.credentials.dig(:startmessaging, :api_key)
req.body = body.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
raise "OTP API error: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body, symbolize_names: true)
end
end
Send an OTP
result = StartMessagingOtp.send_otp("+919876543210")
request_id = result.dig(:data, :requestId)
expires_at = result.dig(:data, :expiresAt)
Verify the OTP
verified = StartMessagingOtp.verify_otp(request_id, "482910").dig(:data, :verified)
Controller Actions
# app/controllers/api/auth_controller.rb
class Api::AuthController < ApplicationController
def send_otp
res = StartMessagingOtp.send_otp(params.require(:phone_number))
session[:otp_request_id] = res.dig(:data, :requestId)
render json: { ok: true, expires_at: res.dig(:data, :expiresAt) }
rescue => e
render json: { error: e.message }, status: :bad_gateway
end
def verify_otp
request_id = session.delete(:otp_request_id)
res = StartMessagingOtp.verify_otp(request_id, params.require(:code))
if res.dig(:data, :verified)
render json: { verified: true }
else
render json: { verified: false }, status: :unauthorized
end
end
end
Testing with RSpec
Stub the HTTP call so your tests don’t bill your wallet:
# spec/services/start_messaging_otp_spec.rb
require "rails_helper"
require "webmock/rspec"
RSpec.describe StartMessagingOtp do
it "sends an OTP" do
stub_request(:post, "https://api.startmessaging.com/otp/send")
.to_return(status: 200, body: { data: { requestId: "abc", expiresAt: "...", attemptsLeft: 3 } }.to_json)
res = described_class.send_otp("+919876543210")
expect(res.dig(:data, :requestId)).to eq("abc")
end
end
Best Practices
- Always use idempotency keys — see our idempotency guide.
- Throttle send actions with rack-attack or your own rate limiter to prevent abuse.
- Validate Indian numbers with the
phonelibgem before sending. - Never log OTP codes or request bodies in production.
FAQ
See pricing or read our end-to-end OTP verification flow guide for the architectural picture.
StartMessaging Team
StartMessaging Team