Blogs/Technology

From Razorpay to Global Payment Gateway Architecture

Written byAravindan Udayasekaran
Aug 20, 2026
9 Min Read
From Razorpay to Global Payment Gateway Architecture Hero

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.

Too Long? Read This First

- 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:

  1. A booking or donation could no longer find its payment through one relationship.
  2. Status mapping, amount handling and webhook code were duplicated.
  3. 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
end

The 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 }
end

The 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
end

The 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
end

Stripe, 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
end

The 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
end

The 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
end

This 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.

Author-Aravindan Udayasekaran
Aravindan Udayasekaran

With 11+ years experience, shaping cutting-edge apps. I thrive on transforming concepts into functional and efficient systems. Catch me playing cricket and belting out karaoke in my leisure time

Share this article

Phone

Next for you

8 Best GraphQL Libraries for Node.js in 2025 Cover

Technology

Aug 4, 202613 min read

8 Best GraphQL Libraries for Node.js in 2025

8 Best GraphQL Libraries for Node.js in 2026 Too Long? Read This First - Choose Apollo Server when you need a mature ecosystem, GraphOS integration, plugins, or Apollo Federation. - Choose GraphQL Yoga for a modern, portable server with Fetch API compatibility and built-in support for subscriptions over Server-Sent Events. - Choose Mercurius when your application already uses Fastify and runtime efficiency is a major priority. - Use GraphQL.js when you need the official JavaScript implementati

9 React Native Animation Libraries and Tools Compared Cover

Technology

Aug 4, 202615 min read

9 React Native Animation Libraries and Tools Compared

Too Long? Read This First - Use React Native Reanimated for gesture-driven, interruptible, and performance-sensitive interface animations. - Use the built-in Animated API for simple fades, transforms, and timed sequences without another dependency. - Pair React Native Gesture Handler with Reanimated for swipes, dragging, pinching, rotation, and other touch-driven experiences. - Use Lottie React Native for non-interactive motion graphics supplied by designers. - Choose React Native Skia for cust

9 Critical Practices for Secure Web Application Development Cover

Technology

Aug 4, 202616 min read

9 Critical Practices for Secure Web Application Development

Too Long? Read This First - Define security requirements and model threats before implementation begins. - Treat authentication, account recovery, and MFA as one complete identity system. - Apply server-side authorization to every protected action and object. - Prevent injection with parameterized APIs, structured validation, safe output handling, and restricted outbound requests. - Protect sessions and tokens throughout their complete lifecycle. - Minimise sensitive data and manage encryption