Bill payment API integration with Ruby on Rails

Add RizPay to a Rails application using a service object, sandbox credentials, durable order references and explicit handling of pending purchases.

Already registered? Open API settings and follow the business-access steps. Sandbox purchases do not deliver real services.

Reviewed

Place this service in app/services/riz_pay_sandbox.rb. Rails autoloads the class through Zeitwerk. The implementation uses Ruby's standard library and disables Net::HTTP automatic retries so the application controls recovery.

Start with an approved order

Have your controller authenticate the customer, validate the selected product and save an order. Enqueue the saved order ID after commit. The job calls this service using the order's original external reference and persists the returned transaction ID; it does not make a second order.

Create sandbox access in API settings after completing the account's business-access steps. For this airtime walkthrough, the key needs view_products, purchase_airtime and read_transactions. Keep the secret in RIZPAY_API_KEY on the server. The client below deliberately accepts only sandbox keys and uses a fixed sandbox URL.

The server client

ruby
# Ruby 3+. Save as app/services/riz_pay_sandbox.rb in Rails.
require "net/http"
require "json"
require "uri"

class RizPaySandbox
  BASE_URL = "https://my.rizpay.app/api/partners/sandbox/v1".freeze
  NETWORKS = %w[MTN AIRTEL GLO 9MOBILE].freeze
  REFERENCE = /\A\d{10}[A-Za-z0-9]{6}\z/

  class RequestError < StandardError
    attr_reader :code, :status

    def initialize(code, status)
      @code = code
      @status = status
      super("Request not confirmed. Reconcile the stored order before retrying.")
    end
  end

  def initialize(key: ENV.fetch("RIZPAY_API_KEY"))
    raise ArgumentError, "A sandbox key is required." unless key.start_with?("sk_test_")

    @key = key
  end

  def list_airtime(network: "MTN", page: 1)
    raise ArgumentError, "Choose a supported network." unless NETWORKS.include?(network)
    raise ArgumentError, "Invalid page." unless page.is_a?(Integer) && page.positive?

    request("/products/airtimes?#{URI.encode_www_form(network: network, page: page)}")
  end

  def purchase_airtime(product_id:, network:, phone_number:, amount:, external_reference:)
    raise ArgumentError, "Use the stored order reference." unless REFERENCE.match?(external_reference.to_s)
    raise ArgumentError, "Select a catalogue product." unless /\Aprd_\d+\z/.match?(product_id.to_s)
    raise ArgumentError, "Confirm the network." unless NETWORKS.include?(network)
    raise ArgumentError, "Check the number." unless /\A0[789]\d{9}\z/.match?(phone_number.to_s)
    raise ArgumentError, "Use a decimal amount string." unless amount.is_a?(String) && /\A\d+\.\d{2}\z/.match?(amount)

    request("/purchases", {
      product_id: product_id, network: network, phone_number: phone_number,
      amount: amount, external_reference: external_reference
    })
  end

  def find_order(reference)
    raise ArgumentError, "Invalid reference." unless REFERENCE.match?(reference.to_s)

    request("/account/transactions/#{reference}")
  end

  def purchase_status(id)
    raise ArgumentError, "Invalid transaction." unless /\Atxn_[A-Za-z0-9-]+\z/.match?(id.to_s)

    request("/purchases/#{id}")
  end

  private

  def request(path, body = nil)
    uri = URI("#{BASE_URL}#{path}")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.open_timeout = 5
    http.read_timeout = 20
    http.write_timeout = 20
    http.max_retries = 0
    req = (body ? Net::HTTP::Post : Net::HTTP::Get).new(uri)
    req["Authorization"] = "Bearer #{@key}"
    req["Content-Type"] = "application/json"
    req.body = JSON.generate(body) if body
    response = http.request(req)
    json = JSON.parse(response.body)
    unless response.is_a?(Net::HTTPSuccess)
      code = json.dig("error", "code").to_s
      code = "API_ERROR" unless /\A[A-Z_]{3,60}\z/.match?(code)
      raise RequestError.new(code, response.code.to_i)
    end
    json
  rescue JSON::ParserError, Timeout::Error, IOError, SystemCallError, OpenSSL::SSL::SSLError
    raise RequestError.new("UNCONFIRMED_RESPONSE", nil)
  end
end

Use the catalogue before purchasing

Call the catalogue method and read the product array in data. Use an ID from your own result, confirm its network and read its price limits. Keep product IDs as strings and use decimal strings for money. The client checks the input format; your application must still enforce the selected product's minimum and maximum amounts and its own customer authorization rules.

Use phone number 08011111111 for a configured successful sandbox airtime scenario. For pending scenarios, use 08022222222 or 08033333333. These are simulator inputs, not recipients for real top-ups. An arbitrary phone number does not necessarily produce a successful sandbox result.

Persist before sending

Generate the external reference once when you save the order. Follow RizPay's documented 16-character format: ten timestamp digits followed by six alphanumeric characters. Add a unique constraint for that reference in your own database. Pass the same stored value to the purchase method.

These methods do not automatically retry a purchase. If the response is lost, use the reference lookup method and inspect the existing transaction. A DUPLICATE_REFERENCE error is a recovery signal, not proof that the customer received airtime. Protect a local order from concurrent submissions with your application's transaction or job-locking mechanism.

Read the result correctly

An accepted purchase returns its identifier at data.id and its status at data.attributes.status. Persist the identifier with the order. Use the status method to retrieve it again, or reconcile a verified webhook. Pending must remain pending in your customer interface. Do not mark an order paid-and-delivered merely because the HTTP response was successful.

If a result is unrecognized, keep the order unresolved and investigate it. Never show exception details, keys or a raw provider response to the customer. A safe message explains that the result is being checked and provides a route to view the existing order or contact support.

Moving this into a live application

Add customer authorization, durable persistence, payment collection, webhook verification and ledger reconciliation in your own application. The client is the HTTP boundary, not a complete checkout or wallet. Your customer's payment to you and your partner purchase from RizPay are distinct records.

Review the production checklist before introducing a production client and live credentials. Keep test and live clients, keys and order records separate. The airtime reference describes the request; other product categories require their own fields and, for electricity and cable TV, verification.