From Razorpay to Global Payment Gateway Architecture

We originally built our payment flow for an Indian audience, so Razorpay was the practical choice. The first version worked: a booking created a Razorpay order, the checkout returned a payment ID, and webhooks updated the payment status.
The limitations appeared when the product moved beyond India. Stripe, PayPal and later GMO introduced different objects, event names and verification rules. Our Razorpay-specific tables and services could not absorb those differences without duplication.
The solution was not simply adding a Factory class. We had to separate our internal payment model from each provider, treat webhooks as an idempotent event pipeline and keep provider-specific behaviour behind a small adapter. This article explains that redesign and the problems we would now address before adding the first gateway.
- Store money as integer minor units, not a floating-point value.
- Keep one provider-neutral payment record instead of creating a table for every gateway.
- Preserve provider-specific behaviour inside adapters; do not force every gateway into Razorpay terminology.
- Verify the signature against the raw webhook body before parsing JSON.
- Store the provider's event ID behind a unique database index so retries cannot process a payment twice.
- Return from the webhook endpoint quickly and move payment processing to a persistent background queue.
Where Our First Design Broke
Our original schema used tables such as razorpay_payments and razorpay_webhooks. When we added another provider, we repeated the pattern with another set of tables and services. That created three immediate problems:
- A booking or donation could no longer find its payment through one relationship.
- Status mapping, amount handling and webhook code were duplicated.
- Every new provider required another schema change, even when the business meaning of a payment had not changed.
The database was modelling Razorpay's vocabulary rather than our product. An order ID, PaymentIntent, Checkout Session and gateway transaction are not identical, but each represents part of the same internal process: collecting a known amount for a business entity.
The Boundary We Introduced
We split the system into two layers:
- Our payment domain stores the payable record, amount, currency and internal status.
- Gateway adapters translate provider requests and events into that domain.
This matters because a shared interface should describe what our application needs, not pretend every provider exposes the same API. We use create_checkout rather than create_order, for example, because an “order” is not a universal payment-gateway object.
1. A Provider-Neutral Payment Schema
The following migration stores amounts in minor units: paise for INR, cents for USD and the appropriate smallest unit for other currencies. This avoids floating-point rounding errors.
# db/migrate/20260820000000_create_gateway_agnostic_payments.rb
class CreateGatewayAgnosticPayments < ActiveRecord::Migration[7.1]
def change
create_table :payments do |t|
t.references :payable, polymorphic: true, null: false
t.string :gateway, null: false
t.string :external_reference
t.string :external_payment_id
t.bigint :amount_minor, null: false
t.string :currency, limit: 3, null: false
t.string :status, null: false, default: "pending"
t.datetime :paid_at
t.json :gateway_data
t.timestamps
end
add_index :payments,
%i[gateway external_reference],
unique: true
add_index :payments,
%i[gateway external_payment_id],
unique: true
create_table :payment_webhook_events do |t|
t.string :gateway, null: false
t.string :external_event_id, null: false
t.string :event_type, null: false
t.json :payload, null: false
t.string :status, null: false, default: "pending"
t.datetime :processed_at
t.text :error_message
t.timestamps
end
add_index :payment_webhook_events,
%i[gateway external_event_id],
unique: true,
name: "index_payment_webhooks_on_gateway_and_event"
end
endThe external_reference column holds the provider object used to begin checkout—for Razorpay, the Order ID. The external_payment_id column holds the completed or attempted payment identifier. If the product needs a full history of several attempts, we would add a payment_attempts table instead of repeatedly overwriting that field.
The corresponding models keep the accepted gateway and status values explicit:
# app/models/payment.rb
class Payment < ApplicationRecord
GATEWAYS = %w[razorpay stripe paypal gmo].freeze
STATUSES = %w[pending paid failed refunded].freeze
belongs_to :payable, polymorphic: true
before_validation :normalize_gateway_and_currency
validates :gateway, inclusion: { in: GATEWAYS }
validates :status, inclusion: { in: STATUSES }
validates :amount_minor,
numericality: { only_integer: true, greater_than: 0 }
validates :currency, length: { is: 3 }
validates :external_reference,
uniqueness: { scope: :gateway },
allow_nil: true
validates :external_payment_id,
uniqueness: { scope: :gateway },
allow_nil: true
private
def normalize_gateway_and_currency
self.gateway = gateway.to_s.downcase
self.currency = currency.to_s.upcase
end
end
# app/models/payment_webhook_event.rb
class PaymentWebhookEvent < ApplicationRecord
STATUSES = %w[pending processed failed].freeze
validates :gateway, :external_event_id, :event_type, presence: true
validates :status, inclusion: { in: STATUSES }
validates :external_event_id, uniqueness: { scope: :gateway }
endThe model validation produces useful application errors, while the unique indexes protect against races between concurrent requests.
2. A Small Gateway Contract
Our first abstraction attempted to make every provider implement several Razorpay-shaped methods. The revised contract is smaller. It covers only the operations the application currently shares.
# app/services/payments/gateway.rb
module Payments
CheckoutResult = Struct.new(
:reference,
:client_secret,
keyword_init: true
)
class UnsupportedGateway < StandardError; end
class InvalidWebhook < StandardError; end
class PayloadMismatch < StandardError; end
class Gateway
def create_checkout(payment:)
raise NotImplementedError
end
def signature_header
raise NotImplementedError
end
def verify_and_parse_webhook(raw_body:, signature:)
raise NotImplementedError
end
def external_event_id(headers:)
raise NotImplementedError
end
def event_type(payload)
raise NotImplementedError
end
def process_webhook(payload)
raise NotImplementedError
end
end
endThe factory creates only registered, implemented adapters. It raises an explicit error for an unsupported value instead of returning nil.
# app/services/payments/factory.rb
module Payments
class Factory
REGISTRY = {
"razorpay" => -> { Payments::Gateways::Razorpay.new }
}.freeze
def self.build(gateway)
REGISTRY.fetch(gateway.to_s.downcase).call
rescue KeyError
raise UnsupportedGateway, "Unsupported gateway: #{gateway}"
end
end
endStripe, PayPal and GMO should be added to REGISTRY only after their adapters implement and test the same application-level contract. A placeholder class with empty methods makes the factory look complete while failing at runtime.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
3. A Razorpay Adapter That Keeps Secrets Server-Side
The Razorpay SDK should be configured once during application boot. Live and test credentials must remain separate.
# config/initializers/razorpay.rb
require "razorpay"
key_id =
Rails.application.credentials.dig(:razorpay, :key_id) ||
ENV.fetch("RAZORPAY_KEY_ID")
key_secret =
Rails.application.credentials.dig(:razorpay, :key_secret) ||
ENV.fetch("RAZORPAY_KEY_SECRET")
Razorpay.setup(key_id, key_secret)The adapter creates a Razorpay order using the integer amount already stored on the local payment. It puts only the local payment ID in notes; it does not accept an entity class or amount from browser parameters.
# app/services/payments/gateways/razorpay.rb
require "json"
module Payments
module Gateways
class Razorpay < Payments::Gateway
GATEWAY = "razorpay"
SIGNATURE_HEADER = "X-Razorpay-Signature"
EVENT_ID_HEADER = "X-Razorpay-Event-Id"
PAYMENT_EVENTS = %w[payment.captured payment.failed].freeze
def create_checkout(payment:)
unless payment.gateway == GATEWAY
raise ArgumentError, "Payment gateway must be razorpay"
end
order = ::Razorpay::Order.create(
amount: payment.amount_minor,
currency: payment.currency,
receipt: "payment_#{payment.id}",
notes: { payment_id: payment.id.to_s }
)
payment.update!(external_reference: order.id)
Payments::CheckoutResult.new(reference: order.id)
end
def signature_header
SIGNATURE_HEADER
end
def verify_and_parse_webhook(raw_body:, signature:)
if signature.blank?
raise Payments::InvalidWebhook, "Missing Razorpay signature"
end
::Razorpay::Utility.verify_webhook_signature(
raw_body,
signature,
webhook_secret
)
JSON.parse(raw_body)
rescue SecurityError, JSON::ParserError => error
raise Payments::InvalidWebhook, error.message
end
def external_event_id(headers:)
headers[EVENT_ID_HEADER].presence ||
raise(Payments::InvalidWebhook, "Missing Razorpay event ID")
end
def event_type(payload)
payload.fetch("event")
rescue KeyError
raise Payments::InvalidWebhook, "Missing Razorpay event type"
end
def process_webhook(payload)
return :ignored unless PAYMENT_EVENTS.include?(event_type(payload))
entity = payload.dig("payload", "payment", "entity")
unless entity.is_a?(Hash)
raise Payments::InvalidWebhook, "Missing payment entity"
end
apply_payment_event(
entity,
captured: event_type(payload) == "payment.captured"
)
end
private
def apply_payment_event(entity, captured:)
local_payment_id = entity.dig("notes", "payment_id")
if local_payment_id.blank?
raise Payments::PayloadMismatch, "Missing local payment ID"
end
payment = Payment.lock.find_by!(
id: local_payment_id,
gateway: GATEWAY
)
validate_payment!(payment, entity)
# A delayed failure event must never downgrade a captured payment.
return :ignored if payment.status == "paid" && !captured
details = {
"method" => entity["method"],
"error_code" => entity["error_code"],
"error_description" => entity["error_description"]
}.compact
payment.update!(
external_payment_id: entity.fetch("id"),
status: captured ? "paid" : "failed",
paid_at: captured ? Time.current : nil,
gateway_data: (payment.gateway_data || {}).merge(details)
)
:processed
end
def validate_payment!(payment, entity)
matches =
payment.external_reference == entity["order_id"] &&
payment.amount_minor == Integer(entity.fetch("amount")) &&
payment.currency == entity.fetch("currency").to_s.upcase
return if matches
raise Payments::PayloadMismatch,
"Gateway amount, currency or reference does not match"
rescue KeyError, ArgumentError
raise Payments::PayloadMismatch, "Incomplete payment payload"
end
def webhook_secret
Rails.application.credentials.dig(:razorpay, :webhook_secret) ||
ENV.fetch("RAZORPAY_WEBHOOK_SECRET")
end
end
end
endThe adapter does not use payment_capture. Razorpay's current guidance is to configure capture settings at the account level and confirm captured or paid status before fulfilling the purchase.
4. Verify, Store and Queue Webhooks
Razorpay signs the raw request body. Parsing and then re-serializing the JSON before verification changes the bytes and can invalidate the signature. The controller therefore verifies first, stores the verified event and then enqueues processing.
# config/routes.rb
Rails.application.routes.draw do
post "webhooks/payments/:gateway",
to: "payment_webhooks#create"
end
# app/controllers/payment_webhooks_controller.rb
class PaymentWebhooksController < ActionController::API
def create
gateway_name = params.require(:gateway).to_s.downcase
adapter = Payments::Factory.build(gateway_name)
raw_body = request.raw_post
payload = adapter.verify_and_parse_webhook(
raw_body: raw_body,
signature: request.headers[adapter.signature_header]
)
event_id = adapter.external_event_id(headers: request.headers)
webhook = PaymentWebhookEvent.create_or_find_by!(
gateway: gateway_name,
external_event_id: event_id
) do |record|
record.event_type = adapter.event_type(payload)
record.payload = payload
record.status = "pending"
end
unless webhook.status == "processed"
ProcessPaymentWebhookJob.perform_later(webhook.id)
end
head :ok
rescue ActionController::ParameterMissing,
Payments::UnsupportedGateway,
Payments::InvalidWebhook => error
Rails.logger.warn(
event: "payment_webhook_rejected",
reason: error.message
)
head :bad_request
end
endThe create_or_find_by! call is backed by the unique index on gateway and external_event_id. If Razorpay delivers the same event again, the application reuses the stored row instead of inserting a second one.
Database or queue errors are intentionally not converted to HTTP 200. A failure response allows the gateway to retry. In production, perform_later must use a persistent queue backend; an in-process queue can lose pending payment work during a restart.
5. Process Each Event Once
The job locks the webhook row, applies the gateway update and marks the event as processed in one database transaction. Duplicate jobs can be queued, but only the first one changes the payment.
# app/jobs/process_payment_webhook_job.rb
class ProcessPaymentWebhookJob < ApplicationJob
queue_as :payments
retry_on StandardError,
wait: :polynomially_longer,
attempts: 10
def perform(webhook_event_id)
PaymentWebhookEvent.transaction do
webhook = PaymentWebhookEvent.lock.find(webhook_event_id)
unless webhook.status == "processed"
adapter = Payments::Factory.build(webhook.gateway)
adapter.process_webhook(webhook.payload)
webhook.update!(
status: "processed",
processed_at: Time.current,
error_message: nil
)
end
end
rescue StandardError => error
PaymentWebhookEvent
.where(id: webhook_event_id)
.update_all(
status: "failed",
error_message: error.message.to_s[0, 1000],
updated_at: Time.current
)
raise
end
endThis transaction protects database updates. It cannot make an external side effect—such as sending an email—transactional. We enqueue fulfilment or notification work only after the payment has been committed as paid, and those jobs have their own idempotency keys.
Creating a Checkout
The application creates its internal payment before calling the provider. Amount and ownership come from trusted server-side records rather than browser input.
payment = Payment.create!(
payable: booking,
gateway: "razorpay",
amount_minor: booking.total_amount_minor,
currency: "INR",
status: "pending"
)
checkout = Payments::Factory
.build(payment.gateway)
.create_checkout(payment: payment)
render json: {
payment_id: payment.id,
gateway: payment.gateway,
external_reference: checkout.reference
}A real checkout endpoint should also authenticate the customer, prevent repeated submissions and confirm that the customer is allowed to pay for the referenced booking.
Adding Stripe, PayPal or GMO
The new architecture isolates a gateway, but adding one is not “plug and play.” Each adapter must still answer provider-specific questions:
- What object begins checkout: an order, session, intent or transaction?
- Which webhook event is authoritative for fulfilment?
- How is the raw webhook verified?
- What unique event ID supports deduplication?
- Can events arrive out of order?
- Which currencies and minor-unit rules apply?
- How are refunds, disputes and asynchronous payment methods represented?
Only after those behaviours are implemented and tested do we register the adapter in the factory. This prevents a shared abstraction from hiding differences that matter to payment correctness.
What We Would Do Differently Now
The redesign taught us that the Factory and Strategy patterns were only part of the solution. The more important decisions were:
- Model the product's payment state rather than a provider's schema.
- Keep amounts as integer minor units from database to API.
- Never fulfil from the browser callback alone; confirm through a signed webhook or server-side status check.
- Expect duplicate and out-of-order events.
- Preserve verified webhook payloads for debugging, with appropriate access controls and retention.
- Monitor pending and failed webhook records instead of treating HTTP 200 as the end of the workflow.
- Reconcile local payment records against provider reports and settlements.
Let’s Build Your Web App Together!
We build fast, scalable, and secure web applications that help your business grow. From idea to launch, we handle it all.
These choices made the system easier to extend, but more importantly, they made failures visible and recoverable.
Frequently Asked Questions
Why not create one table for each payment gateway?
Separate tables duplicate business fields and make payments harder to query across bookings, donations or subscriptions. Provider-specific identifiers and metadata can live on a shared payment record or related attempt table.
Why store money in minor units?
Integer minor units avoid floating-point rounding. Razorpay also expects order amounts as integers in the currency's smallest supported unit.
Why store webhook events before processing them?
The stored event provides an audit trail, supports retries and allows the endpoint to respond quickly. A unique provider event ID makes processing idempotent.
Can the same webhook arrive more than once?
Yes. Razorpay documents at-least-once delivery and provides X-Razorpay-Event-Id for detecting duplicates. The handler must assume retries are normal.
Is signature verification enough to trust a payment?
No. After verifying the signature, compare the provider reference, amount and currency with the local payment. Fulfil only after the provider reports the required successful state.
Does the factory make every gateway interchangeable?
No. It gives the application one entry point while keeping provider differences isolated. Features such as subscriptions, refunds and disputes may require additional capabilities rather than one oversized interface.
Conclusion
Our move from Razorpay to a multi-gateway architecture was not mainly about supporting more SDKs. It was about defining a payment domain that belonged to our application and building a reliable boundary around external providers.
The resulting system has one place to query payments, explicit adapters for gateway behaviour and an idempotent webhook pipeline. Adding another provider still requires careful work, but it no longer requires duplicating the whole payment system.



