There is no Ruby SDK. For an API with one endpoint that matters, that is less of an obstacle than it sounds — the wrapper is about sixty lines of Net::HTTP, and writing it yourself means the timeout, the retry policy and the failure mode are decisions you made rather than defaults you inherited.
What follows is the shape we would actually deploy in a Rails app: a client object, a controller concern, and different strictness per action.
The client
One class, one method, standard library only. The important lines are the two timeouts and the rescue.
# app/services/sentinel.rb
require 'net/http'
require 'json'
class Sentinel
Error = Class.new(StandardError)
ENDPOINT = URI('https://maskbreak.com/v1/evaluate').freeze
Verdict = Struct.new(:decision, :risk_score, :reasons, keyword_init: true) do
def blocked? = decision == 'block'
def suspicious? = decision != 'allow'
end
def initialize(api_key: ENV.fetch('SENTINEL_KEY'), timeout: 1.5)
@api_key = api_key
@timeout = timeout
end
def evaluate(token:, account_id: nil, email: nil)
body = { token: token }
body[:accountId] = account_id if account_id
body[:email] = email if email
req = Net::HTTP::Post.new(ENDPOINT)
req['Authorization'] = "Bearer #{@api_key}"
req['Content-Type'] = 'application/json'
req.body = JSON.generate(body)
res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port,
use_ssl: true,
open_timeout: @timeout,
read_timeout: @timeout) { |http| http.request(req) }
raise Error, "http #{res.code}" unless res.is_a?(Net::HTTPSuccess)
data = JSON.parse(res.body)
Verdict.new(decision: data['decision'],
risk_score: data['risk_score'],
reasons: data['reasons'] || [])
rescue JSON::ParserError, IOError, SystemCallError, Net::OpenTimeout,
Net::ReadTimeout, OpenSSL::SSL::SSLError => e
raise Error, e.message
end
end
Two things are worth pausing on. Content-Type: application/json is not optional — without it the body is not parsed and you get an error that reads like an authentication problem. And both timeouts are set explicitly: Ruby's defaults are measured in tens of seconds, which is not a number you want in front of a checkout button.
The controller concern
Rails already has the right shape for this. A concern with a before_action, included where it is needed, and nowhere else.
# app/controllers/concerns/fraud_screened.rb
module FraudScreened
extend ActiveSupport::Concern
included do
attr_reader :verdict
end
class_methods do
# screen_with strict: true → refuse anything not clean
# screen_with → refuse only a hard block
def screen_with(strict: false, **options)
before_action(**options) { screen!(strict: strict) }
end
end
private
def sentinel
@sentinel ||= Sentinel.new
end
def screen!(strict:)
token = request.headers['X-Sentinel-Token']
if token.blank?
# A blocked collector is degraded, not hostile — unless this is money.
return render(json: { error: 'missing security token' }, status: :bad_request) if strict
return
end
@verdict = sentinel.evaluate(token: token, account_id: current_user&.id&.to_s)
refuse = strict ? @verdict.suspicious? : @verdict.blocked?
return unless refuse
Rails.logger.info("sentinel refused path=#{request.path} score=#{@verdict.risk_score} reasons=#{@verdict.reasons}")
render json: { error: 'blocked', reasons: @verdict.reasons }, status: :forbidden
rescue Sentinel::Error => e
# Fail open. A detection outage must not become a checkout outage.
Rails.logger.warn("sentinel unavailable: #{e.message}")
@verdict = nil
end
end
Per-action strictness
Now the policy is one readable line per controller, and it lives next to the actions it governs.
class WithdrawalsController < ApplicationController
include FraudScreened
screen_with strict: true, only: %i[create] # money leaving: nothing but clean
end
class RegistrationsController < ApplicationController
include FraudScreened
screen_with only: %i[create] # signup: hard blocks only
end
class CheckoutsController < ApplicationController
include FraudScreened
screen_with only: %i[create]
def create
return step_up! if verdict&.decision == 'review'
charge!
end
end
The reason signup is not strict: a plain VPN returns review, and VPN users are customers. Refusing them at registration is a cost you never see, because the person who could not sign up does not file a support ticket. The hard signals — residential proxy, Tor, automation, emulator, browser tampering — are the ones that return block, and those are worth refusing outright.
Do not do this in a background job
The temptation in Rails is to push the network call into Sidekiq and keep the request fast. It does not work for a gate: by the time the job runs, the account exists, the order is placed, or the withdrawal is queued. The verdict has to be in the request path if it is going to change what happens.
What does belong in a job is everything after the decision: writing the verdict to your own analytics table, updating a risk queue, sending the alert. Decide inline, record asynchronously.
If the extra latency genuinely does not fit, the honest alternative is shadow mode — call it inline, log the verdict, act on nothing — for a couple of weeks, and let the data tell you what enforcing would have cost.
Testing
The public sandbox key returns fixed responses for a fixed set of tokens, so the fraud paths are ordinary request specs:
curl -X POST https://maskbreak.com/v1/evaluate \
-H "Authorization: Bearer sk_test_sandbox" \
-H "Content-Type: application/json" \
-d '{"token":"test_tor"}'
# token decision risk_score reasons
# test_clean allow 0 []
# test_datacenter allow 15 [datacenter_asn]
# test_tor block 15 [tor_exit_node, anonymous_network]
# test_vpn review 65 [vpn_detected, datacenter_asn]
# test_proxy block 80 [proxy_detected, datacenter_asn]
# spec/requests/withdrawals_spec.rb
it 'refuses a withdrawal from a residential proxy' do
post '/withdrawals', params: { amount: 100 },
headers: { 'X-Sentinel-Token' => 'test_proxy' }
expect(response).to have_http_status(:forbidden)
end
it 'allows the withdrawal when the vendor is down' do
allow_any_instance_of(Sentinel).to receive(:evaluate).and_raise(Sentinel::Error)
post '/withdrawals', params: { amount: 100 },
headers: { 'X-Sentinel-Token' => 'test_proxy' }
expect(response).to have_http_status(:ok)
end
That second test is the one people leave out, and it is the one that documents your actual policy. Write it down as an assertion so nobody quietly changes it in six months.
The short version
- No gem needed. Sixty lines of
Net::HTTP, with both timeouts set explicitly. - Send
Content-Type: application/jsonor the body is never parsed. - A concern plus
before_actionkeeps the policy readable and per-controller. - Strict on money, hard-block-only on signup,
reviewto a step-up rather than a refusal. - Decide inline; write the audit trail from a job.
- Assert the fail-open path in a spec, not in a comment.
Frequently Asked Questions
Is there a Ruby SDK for Maskbreak?
No. The official SDKs are Node, Python and PHP. For Ruby the integration is a small Net::HTTP wrapper — about sixty lines including error mapping — which is enough for an API with one endpoint that matters, and it leaves the timeout and failure policy in your own code.
Should the fraud check run in a Sidekiq job?
Not as a gate. By the time a background job runs, the account already exists or the order is already placed, so the verdict cannot change the outcome. Make the call inline with a short timeout, and use a job for what comes after the decision: analytics writes, risk queues, alerts.
Why do I get an error when the request looks correct?
Almost always a missing Content-Type header. Rails does not set it for you on a hand-built Net::HTTP::Post, and without application/json the API cannot parse the body, so the failure surfaces as a request error rather than as a validation message.
What is the right timeout for a fraud check in Rails?
Short — one to two seconds on both open_timeout and read_timeout. Ruby defaults to tens of seconds, which is long enough for a vendor slowdown to become an outage of your own checkout. Pair the short timeout with a rescue that lets the request continue.
Sixty lines and a free key
The sandbox key needs no account at all. When you want live verdicts, the free tier is 1,000 requests per hour with no card.
Try Maskbreak free →