Panduan · Terbit
Integrasi payment gateway di CodeIgniter 4: satu pengecualian CSRF yang membuat webhook diam-diam dialihkan, dan getRawInput() yang menghapus tanda tangan sebelum sempat diperiksa
Panduan ini memasang Kasera Pay di aplikasi CodeIgniter 4 tanpa package tambahan apa pun. Yang dipakai hanya CURLRequest bawaan CodeIgniter ditambah hash_hmac dan hash_equals dari PHP. Tidak ada SDK resmi, dan tidak ada yang perlu dipasang lewat Composer untuk mengikuti langkah-langkah di bawah.
Kontrak API-nya sama persis dengan yang dikerjakan integrasi payment gateway di PHP tanpa framework, jadi halaman ini tidak mengulang penjelasan dasarnya. Yang dibahas di sini adalah tiga hal yang hanya muncul karena kerangkanya CodeIgniter, dan ketiganya adalah penyebab integrasi pertama gagal di tempat yang sulit dilihat.
- Filter
csrfyang, di lingkungan produksi, mengalihkan pengiriman webhook alih-alih menolaknya dengan galat, sehingga kegagalannya tidak terlihat seperti kegagalan. getRawInput()dangetJSON(), dua metode yang namanya terdengar seperti pilihan yang benar untuk memeriksa tanda tangan dan justru menghancurkan bahan yang diperiksa.- Kredensial yang berakhir di berkas
app/Config/dan ikut masuk riwayat git, karena di CodeIgniter konfigurasi memang berbentuk kelas PHP.
Alurnya, sebelum menulis kode
Ada empat hal yang bergerak, dan urutannya menentukan apa yang boleh dipercaya. Server membuat permintaan pembayaran. Pembeli dibawa ke checkout_url. Pembeli membayar di sana. Lalu Kasera Pay mengirim payment.paid bertanda tangan ke endpoint webhook, dan hanya event itulah yang menjadi penentu bahwa uangnya masuk. Kepulangan pembeli ke return_url adalah navigasi, bukan bukti: halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar.
Metode yang aktif hari ini adalah QRIS dan Virtual Account delapan bank, dan daftar yang berlaku selalu bisa dibaca dari GET /v1/payment_methods ketimbang ditulis tetap di dalam kode. Rinciannya ada di dokumentasi metode pembayaran, dan referensi endpoint lengkapnya di dokumentasi API.
1. Kredensial: .env, bukan app/Config
Di CodeIgniter konfigurasi adalah kelas PHP di dalam app/Config/, dan direktori itu ikut ter-commit. Menuliskan kunci API di sana adalah cara paling umum kunci produksi bocor pada proyek CodeIgniter, dan kunci yang pernah masuk riwayat git harus dianggap bocor meski komitnya sudah dihapus. Simpan nilainya di .env, biarkan kelas konfigurasinya kosong, dan pastikan .env benar-benar ada di .gitignore.
# .env, jangan pernah ikut ter-commit. Pastikan barisnya ada di .gitignore.
# kp_test_ selama membangun, kp_live_ setelah go-live.
kaserapay.apiKey = kp_test_...
kaserapay.baseUrl = https://pay.kasera.id
kaserapay.webhookSecret = whsec_...CodeIgniter memasangkan keduanya secara otomatis lewat penamaan kuncinya, yaitu nama kelas dalam huruf kecil, titik, lalu nama properti.
<?php
// app/Config/KaseraPay.php
// Nilai propertinya sengaja dibiarkan kosong: CodeIgniter mengisinya dari .env
// karena nama kuncinya "kaserapay.apiKey", yaitu nama kelas dalam huruf kecil,
// titik, lalu nama propertinya. Menuliskan kunci di sini membuatnya ikut
// ter-commit, dan kunci yang pernah masuk riwayat git harus dianggap bocor.
namespace Config;
use CodeIgniter\Config\BaseConfig;
class KaseraPay extends BaseConfig
{
public string $apiKey = '';
public string $baseUrl = 'https://pay.kasera.id';
public string $webhookSecret = '';
public int $toleranceSeconds = 300;
}2. Service pembuat permintaan pembayaran
Satu kelas kecil di app/Libraries/ yang membungkus dua endpoint yang benar-benar dipakai. Satu opsi di dalamnya menentukan apakah pesan galat yang diterima nanti berguna atau tidak: http_errors => false. Tanpa itu CURLRequest melempar exception begitu status 4xx datang, dan body yang memuat error.code tidak pernah sempat dibaca, sehingga yang sampai ke log hanya “422” tanpa alasannya.
<?php
// app/Libraries/KaseraPayClient.php
namespace App\Libraries;
use Config\KaseraPay;
use RuntimeException;
class KaseraPayClient
{
public function __construct(private readonly KaseraPay $config)
{
}
/**
* $idempotencyKey dibuat SEKALI per pesanan lalu disimpan. Kunci baru di
* setiap percobaan ulang menghapus proteksinya tanpa error apa pun.
*/
public function createTransaction(array $payload, string $idempotencyKey): array
{
return $this->send('post', '/v1/transactions', [
'headers' => ['Idempotency-Key' => $idempotencyKey],
'json' => $payload,
]);
}
public function retrieveTransaction(string $id): array
{
return $this->send('get', '/v1/transactions/' . rawurlencode($id));
}
private function send(string $method, string $path, array $options = []): array
{
// http_errors => false wajib: tanpa itu CURLRequest melempar sebelum
// body-nya sempat dibaca, dan body itulah yang memuat error.code.
$client = \Config\Services::curlrequest([
'baseURI' => $this->config->baseUrl,
'timeout' => 15,
'http_errors' => false,
], null, null, false);
$response = $client->request($method, $path, array_merge_recursive([
'headers' => [
'Authorization' => 'Bearer ' . $this->config->apiKey,
'Accept' => 'application/json',
],
], $options));
$status = $response->getStatusCode();
$decoded = json_decode((string) $response->getBody(), true);
if (! is_array($decoded)) {
throw new RuntimeException('Kasera Pay: response bukan JSON, status ' . $status);
}
if ($status >= 400) {
throw new RuntimeException(
'Kasera Pay ' . $status . ': ' . ($decoded['error']['code'] ?? 'unknown'),
);
}
return $decoded;
}
}3. Controller: buat permintaan, lalu antar pembeli
Bagian yang paling sering salah ada di baris pertama, dan salahnya tidak menimbulkan galat apa pun: kunci idempotency dibuat sekali, disimpan bersama pesanannya, lalu dipakai lagi apa adanya di setiap percobaan ulang. Kunci baru setiap percobaan membatalkan gunanya, dan tidak ada field lain yang menutupinya. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, jadi dua create dengan external_id sama tetap menjadi dua permintaan pembayaran yang berdiri sendiri.
<?php
// app/Controllers/Checkout.php
namespace App\Controllers;
use App\Libraries\KaseraPayClient;
class Checkout extends BaseController
{
public function pay(int $orderId)
{
$orders = db_connect()->table('orders');
$order = $orders->getWhere(['id' => $orderId])->getRowArray();
// Kunci idempotency milik PESANAN, bukan milik percobaan. Dibuat sekali,
// disimpan, lalu dipakai apa adanya pada setiap percobaan berikutnya.
if (empty($order['kasera_idempotency_key'])) {
$order['kasera_idempotency_key'] = bin2hex(random_bytes(16));
$orders->where('id', $orderId)
->update(['kasera_idempotency_key' => $order['kasera_idempotency_key']]);
}
$client = new KaseraPayClient(config('KaseraPay'));
$payment = $client->createTransaction([
'amount' => (int) $order['amount'],
'description' => 'Pesanan ' . $order['number'],
'external_id' => $order['number'],
'merchant_ref' => 'INV-' . $order['number'],
'customer' => ['name' => $order['customer_name']],
'return_url' => site_url('checkout/selesai/' . $orderId),
], $order['kasera_idempotency_key']);
$orders->where('id', $orderId)->update(['kasera_payment_id' => $payment['id']]);
return redirect()->to($payment['checkout_url']);
}
}4. Route webhook, dan pengecualian CSRF yang tidak berbunyi
Webhook adalah POST server ke server: tidak ada sesi dan tidak ada token CSRF yang bisa disertakan. Di CodeIgniter filter csrf dipasang di $globals pada app/Config/Filters.php, dan pengecualiannya ditulis sebagai daftar URI di kunci except. Yang ditulis di situ adalah pola URI, bukan nama route.
Satu hal yang membuat kesalahan ini lebih mahal di CodeIgniter daripada di kerangka lain: perilaku bawaan di lingkungan produksi bukan menolak dengan galat, melainkan mengalihkan permintaan ke halaman sebelumnya. Di lingkungan pengembangan yang muncul adalah SecurityException yang mencolok, jadi masalahnya tidak terlihat saat dicoba di lokal, lalu di produksi yang tercatat hanyalah pengalihan yang tampak wajar. Pengirimannya tetap tidak pernah sampai ke controller dan tetap tidak pernah dijawab 2xx, sehingga Kasera Pay mengulangnya dengan jeda yang melebar sampai tujuh kali dalam rentang sekitar 33 jam, dan sesudah itu berhenti. Pesanan yang sudah dibayar tidak pernah terpenuhi, tanpa satu pun baris galat.
<?php
// app/Config/Filters.php, hanya bagian yang berubah.
public array $globals = [
'before' => [
// Tanpa baris except ini, pengiriman webhook tidak pernah sampai ke
// controller. Pola di dalamnya adalah URI, bukan nama route.
'csrf' => ['except' => ['webhook/kasera-pay']],
],
'after' => [],
];<?php
// app/Config/Routes.php
$routes->post('webhook/kasera-pay', 'KaseraPayWebhook::handle');5. Verifikasi tanda tangan atas body mentah
Setiap pengiriman membawa header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t, titik, lalu body mentahnya. Tiga hal harus benar sekaligus, dan melewatkan salah satunya membuat verifikasinya terlihat jalan padahal tidak melindungi apa pun.
- Body mentah dari
getBody(). Di CodeIgniter ada tiga metode yang terlihat cocok dan dua di antaranya salah.getRawInput()menguraiphp://inputmenjadi array dangetJSON()menguraikannya sebagai JSON; keduanya menghasilkan data, bukan byte, dan menyusunnya kembali menjadi JSON mengubah spasi serta bisa mengubah urutan kunci. HanyagetBody()yang mengembalikan isi permintaan apa adanya. - Toleransi lima menit terhadap
t. Inilah yang mencegah pengiriman yang tersadap diputar ulang. Tanpa pemeriksaan ini, tanda tangan lama tetap cocok selamanya. - Semua entri
v1, dibandingkan secara timing-safe. Setelah rotasi signing secret, header membawa dua entriv1selama 24 jam, satu per secret, jadi pengiriman diterima kalau salah satunya cocok. Pakaihash_equals, bukan===.
6. Dedupe lewat unique index, bukan lewat SELECT
Pengiriman bersifat at-least-once: event yang sama bisa datang lebih dari sekali dan selalu membawa id yang sama. Dedupe berdasarkan id itu, dan serahkan keputusannya pada unique index. Dua pengiriman yang tiba bersamaan sama-sama membaca “belum ada” kalau keputusannya diambil dengan SELECT sebelum menulis, dan pesanannya dipenuhi dua kali. Di CodeIgniter, ignore(true) pada query builder menerjemahkan penyisipan itu ke bentuk yang dimiliki basis datanya sendiri, dan affectedRows() menjawab 0 ketika barisnya memang sudah ada.
<?php
// php spark make:migration CreateKaseraPayEvents
public function up()
{
$this->forge->addField([
'id' => ['type' => 'BIGINT', 'unsigned' => true, 'auto_increment' => true],
'event_id' => ['type' => 'VARCHAR', 'constraint' => 64],
'created_at' => ['type' => 'DATETIME'],
]);
$this->forge->addPrimaryKey('id');
// Baris inilah yang mengerjakan dedupe-nya. Tanpa unique, kode di controller
// hanya terlihat aman.
$this->forge->addUniqueKey('event_id');
$this->forge->createTable('kasera_pay_events');
}<?php
// app/Controllers/KaseraPayWebhook.php
namespace App\Controllers;
use CodeIgniter\Controller;
use Config\KaseraPay;
class KaseraPayWebhook extends Controller
{
public function handle()
{
$config = config(KaseraPay::class);
// getBody() mengembalikan byte persis seperti dikirim.
// getRawInput() dan getJSON() sama-sama mengurai isinya lebih dulu,
// dan hasil urai tidak bisa dipakai memeriksa tanda tangan.
$raw = (string) $this->request->getBody();
$header = $this->request->getHeaderLine('Kasera-Signature-V1');
if (! $this->verify($raw, $header, $config->webhookSecret, $config->toleranceSeconds)) {
return $this->response->setStatusCode(400)->setJSON(['error' => 'bad signature']);
}
$event = json_decode($raw, true);
// Unique index yang memutuskan, bukan SELECT lebih dulu: dua pengiriman
// yang datang bersamaan sama-sama membaca "belum ada" kalau keputusannya
// diambil sebelum menulis.
$db = db_connect();
$db->table('kasera_pay_events')
->ignore(true)
->insert(['event_id' => $event['id'], 'created_at' => date('Y-m-d H:i:s')]);
if ($db->affectedRows() === 0) {
// Sudah pernah diproses. Tetap dijawab 200: yang tidak boleh terjadi
// dua kali adalah pemenuhan pesanannya, bukan jawabannya.
return $this->response->setStatusCode(200)->setJSON(['ok' => true]);
}
if (($event['type'] ?? '') === 'payment.paid') {
$this->fulfil($event['data']);
}
return $this->response->setStatusCode(200)->setJSON(['ok' => true]);
}
private function verify(string $raw, string $header, string $secret, int $tolerance): bool
{
if ($header === '' || $secret === '') {
return false;
}
$timestamp = null;
$signatures = [];
foreach (array_map('trim', explode(',', $header)) as $part) {
if (str_starts_with($part, 't=')) {
$timestamp = substr($part, 2);
} elseif (str_starts_with($part, 'v1=')) {
$signatures[] = substr($part, 3);
}
}
if ($timestamp === null || ! ctype_digit($timestamp) || $signatures === []) {
return false;
}
// Toleransi inilah yang mencegah pengiriman lama yang tersadap
// diputar ulang. Tanpa pemeriksaan ini tanda tangan lama cocok selamanya.
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
private function fulfil(array $payment): void
{
db_connect()->table('orders')
->where('number', $payment['external_id'])
->where('status !=', 'paid')
->update(['status' => 'paid', 'paid_at' => $payment['paid_at']]);
}
}Jawab 2xx begitu event tercatat. Jawaban selain 2xx membuat pengiriman diulang, jadi pekerjaan berat sebaiknya diantrikan, bukan dikerjakan di dalam handler.
7. Menjalankan test-nya
Kunci berawalan kp_test_ membuat objek yang berperilaku seperti aslinya tanpa uang berpindah, dengan signing secret webhook yang terpisah dari mode live. Untuk pengujian sisi webhook tidak diperlukan panggilan jaringan sama sekali: cukup tandatangani sendiri body-nya di dalam test.
<?php
// tests/feature/KaseraPayWebhookTest.php
namespace Tests\Feature;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\DatabaseTestTrait;
use CodeIgniter\Test\FeatureTestTrait;
final class KaseraPayWebhookTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
private const SECRET = 'whsec_test';
private function sign(string $body, ?int $t = null): string
{
$t ??= time();
return 't=' . $t . ',v1=' . hash_hmac('sha256', $t . '.' . $body, self::SECRET);
}
public function testPengirimanSahMemenuhiPesanan(): void
{
$body = json_encode([
'id' => 'evt_1',
'type' => 'payment.paid',
'data' => ['external_id' => 'ORD-1', 'paid_at' => '2026-09-19T12:00:00+07:00'],
]);
$this->withHeaders(['Kasera-Signature-V1' => $this->sign($body)])
->withBody($body)
->post('webhook/kasera-pay')
->assertStatus(200);
$this->seeInDatabase('orders', ['number' => 'ORD-1', 'status' => 'paid']);
}
public function testTandaTanganSalahDitolak(): void
{
$body = json_encode(['id' => 'evt_2', 'type' => 'payment.paid', 'data' => []]);
$this->withHeaders(['Kasera-Signature-V1' => 't=' . time() . ',v1=deadbeef'])
->withBody($body)
->post('webhook/kasera-pay')
->assertStatus(400);
}
public function testPengirimanLamaDitolak(): void
{
$body = json_encode(['id' => 'evt_3', 'type' => 'payment.paid', 'data' => []]);
$this->withHeaders(['Kasera-Signature-V1' => $this->sign($body, time() - 600)])
->withBody($body)
->post('webhook/kasera-pay')
->assertStatus(400);
}
public function testEventYangSamaHanyaDipenuhiSekali(): void
{
$body = json_encode([
'id' => 'evt_4',
'type' => 'payment.paid',
'data' => ['external_id' => 'ORD-2', 'paid_at' => '2026-09-19T12:00:00+07:00'],
]);
for ($i = 0; $i < 2; $i++) {
$this->withHeaders(['Kasera-Signature-V1' => $this->sign($body)])
->withBody($body)
->post('webhook/kasera-pay')
->assertStatus(200);
}
$this->seeNumRecords(1, 'kasera_pay_events', ['event_id' => 'evt_4']);
}
}Satu catatan tentang cara membaca test seperti ini. Test yang belum pernah dilihat gagal belum membuktikan apa pun. Rusak dulu kodenya satu per satu, jalankan, dan pastikan yang merah adalah yang seharusnya: menghapus pemeriksaan toleransi harus menjatuhkan test pengiriman lama, mengganti getBody() dengan getRawInput() harus menjatuhkan test pengiriman sah, dan menghapus ignore(true) beserta pemeriksaan affectedRows() harus menjatuhkan test pengiriman ganda.
Kesalahan yang paling sering muncul
- Menaruh pola route, bukan pola URI, di dalam
except. Yang dicocokkan filter adalah jalur permintaannya. - Menyimpulkan pengecualian CSRF sudah benar karena di lokal tidak ada galat. Perilaku bawaannya memang berbeda antara lingkungan pengembangan dan produksi.
- Memenuhi pesanan saat pembeli kembali ke
return_url. Kalau perlu memastikan sesuatu di titik itu, panggilGET /v1/transactions/:iddari server. - Membuat
Idempotency-Keybaru di setiap percobaan ulang. Proteksinya hilang tanpa error apa pun. - Menulis daftar bank Virtual Account secara tetap di dalam kode. Metode yang sedang dimatikan sementara membuat create ditolak 422
payment_method_unavailable, dan yang menjawab daftar terkini adalahGET /v1/payment_methods. Kasus itu dibahas di metode pembayaran yang hilang dari halaman pembayaran.
Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Untuk batas nominal dan laju permintaan, lihat batas dan laju permintaan. Kalau yang dibutuhkan hanya menagih tanpa menulis kode sama sekali, menerima QRIS tanpa punya website menyelesaikannya tanpa integrasi.
Pertanyaan yang sering muncul
Apakah ada package atau SDK CodeIgniter resmi untuk Kasera Pay?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai CURLRequest bawaan CodeIgniter ditambah hash_hmac dan hash_equals dari PHP. Tidak ada dependensi yang perlu dipasang lewat Composer, dan tidak ada rilis package yang perlu ditunggu setiap kali API bertambah.
Kenapa route webhook harus dikecualikan dari filter csrf?
Karena pengirimnya server Kasera Pay, bukan peramban pembeli, jadi tidak ada sesi dan tidak ada token CSRF yang bisa disertakan. Di lingkungan produksi perilaku bawaan CodeIgniter bukan menolak dengan galat melainkan mengalihkan ke halaman sebelumnya, jadi yang tercatat di log adalah pengalihan yang terlihat wajar, bukan kegagalan yang mencolok. Pengiriman itu tetap tidak pernah sampai ke controller dan tetap tidak pernah dijawab 2xx, sehingga Kasera Pay mengulangnya sampai tujuh kali. Yang mengotentikasi pengiriman adalah tanda tangan di header Kasera-Signature-V1, bukan CSRF.
Kenapa tidak boleh memakai getRawInput() atau getJSON() untuk memeriksa tanda tangan?
Karena keduanya mengurai isi permintaan lebih dulu. getRawInput() mengubah php://input menjadi array dan getJSON() menjadi object atau array, sementara yang ditandatangani adalah byte persis seperti dikirim. Menyusun ulang hasil urai menjadi JSON menghasilkan spasi dan urutan kunci yang berbeda, HMAC-nya ikut berbeda, dan pengiriman yang sah ditolak. Yang benar adalah getBody() pada objek request, yang mengembalikan body apa adanya.
Apakah external_id sudah cukup untuk mencegah satu pesanan ditagih dua kali?
Tidak. external_id dan merchant_ref adalah label yang disimpan, dikembalikan, dan bisa difilter, bukan penjamin keunikan, jadi dua create dengan external_id yang sama tetap menjadi dua permintaan pembayaran. Yang menggabungkan percobaan ulang menjadi satu permintaan hanya header Idempotency-Key, dan hanya kalau kuncinya dibuat sekali lalu disimpan bersama pesanannya.
Bagaimana menguji alur ini tanpa uang sungguhan?
Pakai kunci berawalan kp_test_. Objek yang dibuatnya berperilaku sama tanpa uang berpindah, dan webhook mode tes dikirim dengan signing secret-nya sendiri, terpisah dari secret mode live. Untuk pengujian di CI tidak diperlukan panggilan jaringan sama sekali: sisi create ditutup dengan mengganti KaseraPayClient dengan ganda buatan sendiri, dan sisi webhook cukup ditandatangani sendiri di dalam test seperti pada contoh di halaman ini.
Versi CodeIgniter berapa yang dipakai contoh ini?
CodeIgniter 4.7, versi yang tercantum sebagai dokumentasi terbaru di codeigniter.com saat panduan ini ditulis. Bagian yang paling terikat versi adalah pengecualian CSRF lewat kunci except di dalam $globals pada app/Config/Filters.php, yang berlaku di seluruh seri 4. Sisanya, yaitu CURLRequest, getBody(), hash_hmac, dan hash_equals, sudah stabil jauh sebelumnya.