Panduan · Terbit
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
endAPI 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 delapan kode Virtual Account: va_bca, 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
endNet::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_bca]
},
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
endHanya 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: falseController 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
endYang 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)
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
endPengiriman 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
endURL 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.