← All Posts
Integrations

Integrating USDT Payments in Ruby on Rails with Paychainly

May 21, 2026· 6 min read

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 ngrok for local development)
  • An Order model 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 .env files committed to git
  • Return HTTP 200 from 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 externalReference per 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.

← Back to Blog
ruby on railsUSDTcrypto paymentsBNB Smart Chainwebhook verificationPaychainlyRailsstablecoin