Ruby on Rails powers some of the world's most successful SaaS platforms, marketplaces, and subscription businesses. Adding USDT crypto payments to your Rails application used to mean writing low-level blockchain code or relying on clunky third-party plugins. With the Paychainly API, you can drop in a clean service object, render a QR code checkout page, and verify cryptographically signed webhooks in under an hour.
This guide walks through a complete production-ready integration: creating payment sessions, displaying the checkout UI, handling Paychainly webhooks securely, and dispatching background fulfillment jobs.
Why Crypto Payments in Rails?
Before diving into code, it's worth understanding why merchants add USDT payments alongside (or instead of) card processors:
- No chargebacks. Blockchain transactions are irreversible — once confirmed, the payment cannot be disputed or clawed back.
- Lower fees. Paychainly charges a flat minimum of $0.60 or 1% per transaction, compared to the 2.9% + $0.30 that card networks charge.
- No geographic restrictions. Any customer with a BSC-compatible wallet can pay, regardless of their bank or country.
- Fast settlement. USDT sweeps to your master wallet within minutes of blockchain confirmation — no multi-day holds.
Prerequisites
- Ruby 3.x / Rails 7.x application
- A Paychainly account and API key
- An HTTPS webhook endpoint reachable from the internet (use
ngrokfor local development) - An
Ordermodel with fields for payment address, amount, status, and transaction hash
Step 1: Add Dependencies
Paychainly exposes a standard REST API, so no custom gem is required. Add faraday for HTTP and rqrcode for QR code generation:
# Gemfile
gem 'faraday'
gem 'faraday-json'
gem 'rqrcode'
Run bundle install, then add your credentials to Rails encrypted credentials or environment variables:
PAYCHAINLY_API_KEY=pk_live_xxxxxxxxxxxxxxxx
PAYCHAINLY_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx
Never commit these to version control. Use rails credentials:edit or a secrets manager in production.
Step 2: Create a Service Object
Encapsulate all Paychainly API calls in a single service class to keep your controllers clean:
# app/services/paychainly_service.rb
require 'faraday'
require 'faraday/json'
require 'openssl'
class PaychainlyService
BASE_URL = 'https://api.paychainly.com/api/v1'.freeze
def initialize
@api_key = ENV.fetch('PAYCHAINLY_API_KEY')
@webhook_secret = ENV.fetch('PAYCHAINLY_WEBHOOK_SECRET')
@conn = Faraday.new(url: BASE_URL) do |f|
f.request :json
f.response :json
f.headers['x-api-key'] = @api_key
end
end
# Creates a new deposit session.
# Returns the session data hash including depositAddress, amount, expiresAt.
def create_payment(amount_usd:, order_id:, metadata: {})
response = @conn.post('/payments') do |req|
req.body = {
amount: amount_usd.to_f,
currency: 'USDT',
externalReference: order_id.to_s,
metadata: metadata
}
end
raise "Paychainly error: #{response.body}" unless response.success?
response.body['data']
end
# Returns true if the HMAC-SHA256 signature matches the raw request body.
def valid_signature?(raw_body, signature_header)
expected = OpenSSL::HMAC.hexdigest('SHA256', @webhook_secret, raw_body)
ActiveSupport::SecurityUtils.secure_compare(expected, signature_header.to_s)
end
end
Using ActiveSupport::SecurityUtils.secure_compare is critical here — it performs a constant-time string comparison that prevents timing attacks against your HMAC check.
Step 3: Routes and Controller
# config/routes.rb
resources :orders do
member do
post :create_crypto_payment
get :checkout
get :status
end
end
post '/webhooks/paychainly', to: 'webhooks#paychainly'
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
before_action :authenticate_user!
def create_crypto_payment
@order = Order.find(params[:id])
service = PaychainlyService.new
payment = service.create_payment(
amount_usd: @order.total_cents / 100.0,
order_id: @order.id,
metadata: { email: current_user.email }
)
@order.update!(
payment_address: payment['depositAddress'],
payment_amount: payment['amount'],
payment_expires_at: Time.zone.parse(payment['expiresAt']),
payment_status: 'pending'
)
redirect_to checkout_order_path(@order)
rescue => e
Rails.logger.error("Paychainly: #{e.message}")
redirect_to @order, alert: 'Could not start crypto payment. Please try again.'
end
def checkout
@order = Order.find(params[:id])
end
def status
@order = Order.find(params[:id])
render json: { status: @order.payment_status }
end
end
Step 4: QR Code Checkout View
Use rqrcode to generate an SVG QR code inline — no external API calls, no privacy concerns:
<%# app/views/orders/checkout.html.erb %>
<div class="crypto-checkout">
<h2>Pay with USDT on BNB Smart Chain</h2>
<p>Send exactly <strong><%= @order.payment_amount %> USDT</strong> to:</p>
<% qr = RQRCode::QRCode.new(@order.payment_address) %>
<%= qr.as_svg(module_size: 4, standalone: true).html_safe %>
<p class="mono"><%= @order.payment_address %></p>
<p>Network: <strong>BNB Smart Chain (BEP-20)</strong></p>
<p>Session expires: <%= @order.payment_expires_at.strftime('%H:%M UTC') %></p>
<div id="payment-status"
data-order-id="<%= @order.id %>"
data-poll-url="<%= status_order_path(@order, format: :json) %>">
<p>Waiting for payment confirmation...</p>
</div>
</div>
Step 5: Webhook Handler with HMAC Verification
Paychainly sends a signed POST request to your webhook URL the moment a deposit is detected on-chain. Always verify the signature before touching your database:
# app/controllers/webhooks_controller.rb
class WebhooksController < ActionController::API
before_action :verify_paychainly_signature
def paychainly
payload = JSON.parse(request.body.read)
event_type = payload['event']
case event_type
when 'deposit_detected'
handle_deposit(payload['data'])
end
head :ok
rescue JSON::ParserError
head :bad_request
end
private
def verify_paychainly_signature
raw_body = request.body.read
request.body.rewind
sig = request.headers['X-Paychainly-Signature']
service = PaychainlyService.new
unless service.valid_signature?(raw_body, sig)
Rails.logger.warn('[Paychainly] Invalid webhook signature — rejected')
head :unauthorized
end
end
def handle_deposit(data)
order = Order.find_by(id: data['externalReference'])
return unless order
return if order.payment_status == 'confirmed' # idempotency guard
order.update!(
payment_status: 'confirmed',
tx_hash: data['txHash'],
paid_at: Time.current
)
OrderFulfillmentJob.perform_later(order.id)
end
end
The idempotency guard (return if order.payment_status == 'confirmed') ensures that duplicate webhook deliveries — which can occur on any HTTP-based webhook system — do not trigger double fulfillment.
Step 6: Background Fulfillment Job
# app/jobs/order_fulfillment_job.rb
class OrderFulfillmentJob < ApplicationJob
queue_as :default
def perform(order_id)
order = Order.find(order_id)
return if order.fulfilled?
ActiveRecord::Base.transaction do
order.fulfill!
OrderMailer.confirmation(order).deliver_later
end
end
end
Step 7: Client-Side Status Polling
For a seamless customer experience, poll the status endpoint and redirect automatically when payment is confirmed:
// app/javascript/payment_poller.js
const el = document.getElementById('payment-status');
if (!el) return;
const { pollUrl } = el.dataset;
const interval = setInterval(async () => {
try {
const res = await fetch(pollUrl, { headers: { 'Accept': 'application/json' } });
const data = await res.json();
if (data.status === 'confirmed') {
clearInterval(interval);
window.location.href = pollUrl.replace('/status', '/confirmation');
}
} catch (_) { /* network blip — keep polling */ }
}, 5000);
Testing with RSpec
Mock the Paychainly API in unit tests and verify the HMAC logic directly:
# spec/services/paychainly_service_spec.rb
RSpec.describe PaychainlyService do
let(:service) { described_class.new }
let(:secret) { ENV['PAYCHAINLY_WEBHOOK_SECRET'] }
describe '#valid_signature?' do
it 'accepts a correct HMAC-SHA256 signature' do
body = '{"event":"deposit_detected"}'
sig = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
expect(service.valid_signature?(body, sig)).to be true
end
it 'rejects a tampered payload' do
expect(service.valid_signature?('tampered', 'badsig')).to be false
end
end
end
For end-to-end testing, enable Paychainly sandbox mode and use the POST /api/sandbox/credit endpoint to simulate an incoming deposit without spending real USDT.
Production Checklist
- Store all secrets in Rails encrypted credentials or a vault — never in
.envfiles committed to git - Return
HTTP 200from your webhook endpoint immediately; push all processing to a background job - Log incoming webhook payloads (excluding sensitive fields) for debugging and audit trails
- Display a countdown timer in the checkout UI so customers know when their session expires
- Use a unique
externalReferenceper order to safely deduplicate webhook events - Test the full sandbox flow before switching to your live API key
With this setup, your Ruby on Rails application can accept USDT payments on BNB Smart Chain with cryptographically verified webhooks, background fulfillment, and a smooth QR code checkout — all without touching low-level blockchain code. The Paychainly API abstracts away RPC management, gas handling, and fund sweeping so you can stay focused on your Rails app.