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/json or the body is never parsed.
  • A concern plus before_action keeps the policy readable and per-controller.
  • Strict on money, hard-block-only on signup, review to 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.
FAQ

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 →