Panduan · Terbit · Diperbarui

Integrasi payment gateway di Ruby on Rails: request.raw_post dan bukan params, webhook yang ditolak 422 oleh CSRF, dan redirect ke halaman pembayaran yang diblokir Rails sendiri

Panduan ini memasang Kasera Pay di aplikasi Ruby on Rails 7.1 atau lebih baru, tanpa gem tambahan: Net::HTTP untuk memanggil API, OpenSSL untuk tanda tangan webhook, dan Active Record untuk dedupe. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay.

Kontraknya sama dengan panduan bahasa lain. Yang membuat Rails berbeda adalah tiga hal yang dikerjakan kerangka kerjanya sebelum kode sendiri berjalan, dan ketiganya merusak pembayaran tanpa galat yang jelas di log aplikasi: perlindungan CSRF yang menjawab webhook dengan 422, params yang sudah diurai dari body sehingga byte yang ditandatangani harus diambil dari tempat lain, dan perlindungan open redirect yang menolak pengalihan pembeli ke halaman pembayaran. Pembaca yang datang dari PHP bisa membandingkannya dengan panduan Laravel, yang mengerjakan urutan yang sama.

1. Kredensial, metode yang aktif, dan dua kolom baru

# db/migrate/20260923000000_add_kasera_pay.rb
class AddKaseraPay < ActiveRecord::Migration[7.1]
  def change
    add_column :orders, :kasera_idempotency_key, :string
    add_column :orders, :kasera_payment_request_id, :string

    # Id event dari body webhook adalah primary key-nya. Unique index inilah
    # yang menjadi penjaga dedupe, bukan kode Ruby.
    create_table :kasera_events, id: :string do |t|
      t.string :event_type, null: false
      t.timestamps
    end
  end
end

API key dibawa sebagai bearer token, berawalan kp_test_ selama membangun dan kp_live_ setelah go-live, dibaca dari KASERA_PAY_KEY. Signing secret webhook diambil dari dasbor, menu Developer, dan berbeda per mode: secret tes tidak pernah bisa memverifikasi payload live. Keduanya boleh juga disimpan di Rails.application.credentials selama nilai tes dan live tidak tertukar antar environment. Metode yang bisa disebut di payment_methods saat ini adalah qris dan tujuh kode Virtual Account: va_bri, va_bni, va_mandiri, va_permata, va_cimb, va_danamon, dan va_maybank. QRIS dikenai 0,7% + Rp 250 per transaksi berhasil dan Virtual Account Rp 5.000 tetap.

2. Klien dengan batas waktu yang ditulis sendiri

# app/services/kasera_pay/client.rb
require "net/http"

module KaseraPay
  class Error < StandardError
    attr_reader :status, :code, :request_id

    def initialize(status, code, request_id)
      @status, @code, @request_id = status, code, request_id
      super("kasera pay #{status} #{code} request_id=#{request_id}")
    end

    # 429 dan 5xx boleh diulang dengan Idempotency-Key yang sama.
    # Sisanya akan ditolak lagi dengan body yang sama.
    def retryable? = status == 429 || status >= 500
  end

  class Client
    HOST = "pay.kasera.id"

    def initialize(key: ENV.fetch("KASERA_PAY_KEY"))
      @key = key
    end

    def create_transaction(body, idempotency_key:)
      req = Net::HTTP::Post.new("/v1/transactions")
      req["Authorization"] = "Bearer #{@key}"
      req["Idempotency-Key"] = idempotency_key
      req["Content-Type"] = "application/json"
      req.body = JSON.generate(body)

      # Bawaan Net::HTTP: 60 detik untuk koneksi dan 60 detik untuk jawaban.
      # Terlalu lama untuk request checkout yang sedang ditunggu pembeli.
      res = Net::HTTP.start(HOST, 443, use_ssl: true,
                            open_timeout: 5, read_timeout: 20, write_timeout: 10) do |http|
        http.request(req)
      end

      data = begin
        JSON.parse(res.body.to_s)
      rescue JSON::ParserError
        {} # balasan proxy berupa HTML tetap dilaporkan lewat statusnya
      end
      return data if res.is_a?(Net::HTTPSuccess)

      raise Error.new(res.code.to_i, data.dig("error", "code"), res["X-Request-Id"])
    end
  end
end

Net::HTTP bawaan menunggu enam puluh detik untuk membuka koneksi dan enam puluh detik lagi untuk jawaban. Pada jalur checkout itu berarti worker Puma yang tertahan sementara pembeli menekan tombol bayar sekali lagi. Lima detik untuk koneksi dan dua puluh detik untuk jawaban adalah titik awal yang wajar. Nominal adalah Integer, tidak pernah Float atau BigDecimal: rupiah di API selalu bilangan bulat.

Galat dari API berbentuk error.code, dan keputusan mengulang diambil dari kode itu, bukan dari teks pesannya. Header X-Request-Id ikut disimpan di pengecualian karena nilai itu yang disebutkan saat menghubungi dukungan; apa saja yang layak menyertainya dibahas di tulisan tentang melaporkan masalah pembayaran. Daftar kodenya ada di dokumentasi galat.

3. Membuat tagihan: key di bawah kunci baris, dan redirect yang diizinkan

# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
  def create
    order = current_user.orders.find_by!(number: params[:order_number])

    # with_lock membaca ulang baris dengan SELECT ... FOR UPDATE. Tanpa itu,
    # dua klik bersamaan sama-sama membaca nil, membuat dua key berbeda,
    # dan menghasilkan dua tagihan untuk satu pesanan.
    key = order.with_lock do
      order.update!(kasera_idempotency_key: SecureRandom.uuid) if order.kasera_idempotency_key.nil?
      order.kasera_idempotency_key
    end

    tx = KaseraPay::Client.new.create_transaction(
      {
        amount: order.total,
        description: "Pesanan #{order.number}",
        external_id: order.number,
        customer: { name: order.customer_name },
        return_url: order_url(order),
        payment_methods: %w[qris va_bri]
      },
      idempotency_key: key
    )

    order.update!(kasera_payment_request_id: tx.fetch("id"))
    # Tanpa allow_other_host, Rails 7 ke atas menolak redirect ke domain lain.
    redirect_to tx.fetch("checkout_url"), allow_other_host: true
  end
end

Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, dan dua create dengan nilai yang sama tetap menjadi dua permintaan. Pola ||= yang biasa dipakai di Ruby, tanpa with_lock, adalah periksa lalu tulis: dua request yang tiba bersamaan sama-sama melihat kolomnya kosong. with_lock membuat request kedua menunggu dan membaca key yang sudah ditulis request pertama. Key yang dibangkitkan di dalam blok retry berganti pada tiap percobaan dan proteksinya hilang tanpa peringatan. Latar belakangnya ada di tulisan tentang idempotency.

Baris terakhir adalah jebakan khas Rails 7. Aplikasi baru menyalakan raise_on_open_redirects, sehingga redirect_to ke domain lain melempar UnsafeRedirectError. Tagihan sudah terbit, pembeli melihat halaman galat, dan klik berikutnya mengembalikan tagihan yang sama berkat key yang tersimpan. Opsi allow_other_host: true dipasang di baris ini saja, untuk URL yang datang dari response API, bukan dari parameter request.

4. Controller webhook tanpa CSRF dan tanpa login

# config/routes.rb
post "/webhooks/kasera_pay", to: "kasera_pay_webhooks#create"

# app/controllers/kasera_pay_webhooks_controller.rb
# ActionController::API: tanpa CSRF, tanpa sesi, tanpa before_action login
# yang diwarisi dari ApplicationController.
class KaseraPayWebhooksController < ActionController::API
  def create
    body = request.raw_post # byte yang ditandatangani, bukan params
    unless KaseraPay::Signature.valid?(body, request.headers["Kasera-Signature-V1"])
      return head :bad_request
    end

    KaseraPay::EventHandler.call(JSON.parse(body)) # pengecualian menjadi 500, dan 500 diulang
    head :ok
  end
end

# Kalau controller-nya harus tetap mewarisi ApplicationController:
#   skip_forgery_protection
#   skip_before_action :authenticate_user!, raise: false

Controller yang mewarisi ApplicationController membawa dua hal yang menolak webhook sebelum action berjalan. Pertama, protect_from_forgery: POST tanpa token CSRF dijawab 422. Kedua, before_action untuk login seperti authenticate_user! dari Devise, yang menjawab 401 atau mengalihkan ke halaman login dengan 302. Pengiriman webhook tidak pernah mengikuti pengalihan, jadi 302 itu pun dihitung gagal. Yang terlihat bukan galat di aplikasi, melainkan deretan percobaan gagal di log pengiriman webhook dengan kode status itu di kolom respons endpoint. ActionController::API menghindari keduanya sekaligus.

Rails sudah mengurai body JSON menjadi params sebelum action berjalan, dan kebiasaan membaca params[:data] adalah bentuk yang pasti salah untuk verifikasi. HMAC dihitung atas byte, bukan atas isi yang setara. request.raw_post mengembalikan byte yang diterima apa adanya, termasuk setelah Rails mengurainya.

5. Verifikasi tanda tangan

# app/services/kasera_pay/signature.rb
module KaseraPay
  module Signature
    TOLERANCE_SECONDS = 300

    # Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
    def self.valid?(body, header, secret: ENV.fetch("KASERA_PAY_WEBHOOK_SECRET"),
                    now: Time.now.to_i)
      return false if header.blank?

      parts = header.split(",")
      t = parts.shift.to_s.delete_prefix("t=")
      return false unless t.match?(/\A\d+\z/)
      return false if (now - t.to_i).abs > TOLERANCE_SECONDS

      expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{body}")

      # Selama 24 jam setelah rotasi secret ada dua entri v1.
      parts.any? do |part|
        part.start_with?("v1=") &&
          ActiveSupport::SecurityUtils.secure_compare(part.delete_prefix("v1="), expected)
      end
    end
  end
end

Yang ditandatangani adalah t, satu titik, dan body mentah, dengan HMAC-SHA256. Timestamp yang melenceng lebih dari lima menit ditolak, dan itulah yang mencegah kiriman yang disadap diputar ulang belakangan. Perbandingannya memakai ActiveSupport::SecurityUtils.secure_compare, yang waktunya tidak bergantung pada letak karakter pertama yang berbeda, bukan ==. Rotasi secretnya dibahas di rotasi signing secret tanpa kehilangan satu pun event.

6. Dedupe dan status pesanan dalam satu transaksi

# app/services/kasera_pay/event_handler.rb
module KaseraPay
  class EventHandler
    def self.call(event)
      # test.ping dari dasbor tidak membawa data; cukup dijawab 200.
      return unless event.fetch("type").start_with?("payment.")

      data = event.fetch("data")
      order = nil

      ActiveRecord::Base.transaction do
        # Kiriman kedua dengan id yang sama menunggu di unique index sampai
        # yang pertama selesai, lalu gagal di baris ini.
        KaseraEvent.create!(id: event.fetch("id"), event_type: event.fetch("type"))

        order = Order.lock.find_by(number: data["external_id"])
        next if order.nil? # tagihan yang dibuat dari dasbor, bukan dari toko ini

        case event["type"]
        when "payment.paid"
          # Uang menang: payment.paid tetap dicatat walaupun payment.expired datang lebih dulu.
          matches = data.fetch("amount") == order.total
          order.update!(status: matches ? "lunas" : "perlu_diperiksa", paid_at: data["paid_at"])
        when "payment.expired", "payment.failed"
          order.update!(status: "dilepas") if order.status == "menunggu_pembayaran"
        end
      end

      # Di luar transaksi: pekerjaan yang boleh tertunda, dan yang aman diulang.
      FulfillOrderJob.perform_later(order.id) if order&.status == "lunas"
    rescue ActiveRecord::RecordNotUnique
      nil # sudah pernah diproses; controller tetap membalas 200
    end
  end
end

Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lagi dengan id yang sama. Primary key kasera_events.id yang menjadi penjaganya: kiriman kedua yang tiba bersamaan menunggu di index itu sampai transaksi pertama selesai, lalu gagal dengan ActiveRecord::RecordNotUnique yang ditangkap di luar transaksi. Pola ini berjalan sama di PostgreSQL dan MySQL, tidak seperti insert dengan unique_by yang bergantung pada adapter.

Catatan event dan perubahan status harus jadi atau batal bersama. Kalau event tercatat sementara update! gagal, pengiriman ulang dianggap duplikat dan pesanan tidak pernah lunas. Di dalam blok transaksi dipakai next, bukan return, karena perilaku return di dalam blok transaksi berbeda antar versi Rails. Dua keputusan lain di kode itu disengaja: nominal yang tidak cocok ditandai untuk diperiksa, bukan ditolak dengan 500 yang akan diulang tujuh kali, dan payment.paid tetap dicatat walaupun payment.expired sudah datang lebih dulu, karena uang pembeli yang diterima sebelum tenggatnya selalu menang.

7. Active Job untuk yang boleh menunggu

Server penjual punya waktu sepuluh detik untuk menjawab setiap kiriman. Email konfirmasi, panggilan ke sistem gudang, dan pembuatan faktur tidak perlu selesai dalam jendela itu, jadi semuanya masuk ke FulfillOrderJob yang diantrekan setelah transaksi selesai. Yang tidak boleh pindah ke job adalah status pesanan itu sendiri: kegagalan setelah balasan 200 terkirim tidak akan pernah diulang oleh Kasera Pay. Job-nya membaca ulang pesanan dan tidak melakukan apa pun kalau pekerjaannya sudah selesai, karena adapter antrean mana pun bisa menjalankan satu job lebih dari sekali. Satu celah yang tersisa: kalau antrean mati tepat setelah transaksi selesai, event sudah tercatat dan pengiriman ulang tidak akan mengantrekannya lagi. Pesanan berstatus lunas yang belum terpenuhi layak disapu oleh job berkala.

Sebelum go-live

# test/integration/kasera_pay_webhook_test.rb
class KaseraPayWebhookTest < ActionDispatch::IntegrationTest
  def signed_headers(body, t = Time.now.to_i)
    sig = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("KASERA_PAY_WEBHOOK_SECRET"), "#{t}.#{body}")
    { "CONTENT_TYPE" => "application/json", "Kasera-Signature-V1" => "t=#{t},v1=#{sig}" }
  end

  test "kiriman sah diterima, body yang diubah satu karakter ditolak" do
    body = file_fixture("payment_paid.json").read
    # params berupa String dikirim apa adanya sebagai body mentah.
    post "/webhooks/kasera_pay", params: body, headers: signed_headers(body)
    assert_response :ok

    post "/webhooks/kasera_pay", params: body.sub("150000", "150001"),
                                 headers: signed_headers(body)
    assert_response :bad_request
  end
end

URL webhook wajib https dan mengarah ke alamat publik. Empat hal yang layak diuji di mode tes: kiriman sah diterima dengan 200, body yang diubah satu karakter ditolak, event yang sama dikirim dua kali hanya mengubah pesanan sekali, dan database yang dimatikan di tengah menghasilkan 500, bukan 200. Menguji dari mesin lokal lewat tunnel dibahas di tulisan tentang webhook di localhost dan mode tes, dan daftar pemeriksaan lengkapnya ada di checklist sebelum go-live.

Pertanyaan yang sering muncul

Apakah ada gem resmi Kasera Pay untuk Ruby on Rails?

Tidak ada, dan panduan ini tidak membutuhkannya. Net::HTTP, JSON, dan OpenSSL sudah ada di pustaka standar Ruby, dan ActiveSupport::SecurityUtils ikut bersama Rails. Kalau tipe permintaan ingin dibangkitkan dari kontrak API, spesifikasi OpenAPI Kasera Pay bisa diunduh tanpa kunci, dengan catatan bahwa verifikasi webhook dan kunci idempotensi tetap ditulis tangan.

Kenapa webhook ditolak 422 padahal route-nya sudah benar?

Karena controller itu mewarisi ActionController::Base, dan Rails memasang protect_from_forgery secara bawaan. POST tanpa token CSRF menghasilkan ActionController::InvalidAuthenticityToken yang dijawab 422. Kiriman Kasera Pay tidak punya sesi dan tidak membawa token itu. Pakai ActionController::API untuk controller webhook, atau tulis skip_forgery_protection di controller itu saja, jangan mematikan perlindungan CSRF untuk seluruh aplikasi. Setiap balasan selain 2xx dihitung gagal dan diulang sampai 7 kali dalam kisaran 33 jam.

Kenapa tanda tangan tidak pernah cocok walaupun secret-nya benar?

Hampir selalu karena HMAC dihitung atas params.to_json atau request.body.read yang sudah dibaca sebelumnya. Rails mengurai body JSON menjadi params sebelum action berjalan, dan menyusun ulang JSON dari hash itu menghasilkan byte yang berbeda: spasi hilang dan format angka bisa berubah. request.raw_post mengembalikan byte yang diterima apa adanya. Penyebab kedua adalah secret mode yang tertukar: secret tes tidak pernah bisa memverifikasi payload live.

Kenapa webhook dari tunnel ke mesin lokal dijawab 403 Blocked hosts?

Karena HostAuthorization di environment development hanya menerima host yang terdaftar. Domain tunnel harus ditambahkan ke config.hosts di config/environments/development.rb, misalnya config.hosts << "nama-tunnel.example". Di production pengaturan ini biasanya kosong, jadi masalahnya hanya muncul saat menguji dari lokal.

Bolehkah seluruh pemrosesan webhook dipindah ke Active Job?

Tidak seluruhnya. Pencatatan id event dan perubahan status pesanan harus selesai sebelum membalas 200, karena kegagalan setelah balasan terkirim tidak akan pernah diulang oleh Kasera Pay. Yang layak dipindah ke job adalah pekerjaan lambat yang boleh tertunda, seperti email dan pemrosesan gudang, dengan job yang membaca ulang status pesanan sehingga aman dijalankan dua kali. Server penjual juga punya waktu sepuluh detik untuk menjawab, dan pekerjaan lambat di dalam controller adalah cara tercepat melewatinya.

Mulai sekarang

Terima pembayaran pertama minggu ini.

Daftar gratis, buat payment link atau pakai API, dan lihat statusnya berubah otomatis. Tanpa biaya bulanan, biaya transaksi hanya untuk pembayaran yang berhasil.

Dapatkan di Google PlayDownload di App Store