Panduan · 10 September 2026
Integrasi payment gateway di Laravel 13: dari config sampai webhook yang tidak bisa dipalsukan
Panduan ini memasang Kasera Pay di aplikasi Laravel 13 tanpa package tambahan apa pun. Yang dipakai hanya HTTP client bawaan Laravel, ditambah hash_hmac dan hash_equals dari PHP. Tidak ada SDK PHP resmi Kasera Pay, dan tidak ada yang perlu dipasang lewat Composer untuk mengikuti panduan ini.
Semua contoh di bawah dijalankan pada Laravel 13.31 dengan PHP 8.4, dan diuji dengan feature test yang ada di bagian akhir halaman: sebelas test, termasuk yang memastikan pengiriman dengan secret salah ditolak, pengiriman lama ditolak, dan satu event yang datang dua kali hanya memenuhi pesanan sekali. Ringkasan metode yang aktif beserta tarifnya ada di halaman API QRIS untuk developer, dan referensi endpoint lengkapnya di dokumentasi API Kasera Pay.
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 halaman penjual bukan bukti pembayaran. Halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penuhi pesanan pada webhook, bukan pada redirect.
1. Kredensial dan konfigurasi
API key dibawa sebagai bearer token dan berawalan kp_test_ selama membangun, kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer.
# .env — kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...// config/services.php
'kasera_pay' => [
'key' => env('KASERA_PAY_KEY'),
'base_url' => env('KASERA_PAY_BASE_URL', 'https://pay.kasera.id'),
'webhook_secret' => env('KASERA_PAY_WEBHOOK_SECRET'),
],Key hanya dipakai dari server. Jangan pernah menaruhnya di kode frontend atau di repositori publik.
2. Service pembuat permintaan pembayaran
Satu kelas kecil yang membungkus dua endpoint yang benar-benar dipakai. Perhatikan retry di sana: percobaan ulang aman justru karena Idempotency-Key-nya stabil, dan tanpa key yang stabil retry itu berbahaya.
<?php
// app/Services/KaseraPay.php
namespace App\Services;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
class KaseraPay
{
public function __construct(
private readonly string $key,
private readonly string $baseUrl,
) {}
public function createTransaction(array $payload, string $idempotencyKey): array
{
$response = $this->request()
->withHeaders(['Idempotency-Key' => $idempotencyKey])
->post('/v1/transactions', $payload);
return $this->decode($response);
}
public function retrieveTransaction(string $id): array
{
return $this->decode($this->request()->get("/v1/transactions/{$id}"));
}
private function request()
{
return Http::baseUrl($this->baseUrl)
->withToken($this->key)
->acceptJson()
->asJson()
->timeout(15)
// Retry is safe justru karena Idempotency-Key-nya stabil.
->retry(2, 200, throw: false);
}
private function decode(Response $response): array
{
$response->throw();
return $response->json();
}
}// app/Providers/AppServiceProvider.php — register()
$this->app->singleton(KaseraPay::class, fn () => new KaseraPay(
(string) config('services.kasera_pay.key'),
(string) config('services.kasera_pay.base_url'),
));3. Controller: buat permintaan, lalu antar pembeli
Bagian yang paling sering salah ada di baris pertama: key dibuat sekali dan disimpan bersama pesanannya, lalu dipakai lagi apa adanya di setiap percobaan ulang. Membuat key 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 dua permintaan pembayaran.
<?php
// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;
use App\Services\KaseraPay;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class CheckoutController extends Controller
{
public function store(Request $request, KaseraPay $kasera)
{
$order = DB::table('orders')->where('id', (int) $request->input('order_id'))->first();
// Key dibuat sekali lalu disimpan, dan setiap percobaan ulang memakai
// key yang sama. Key baru per percobaan menghapus proteksinya diam-diam.
if ($order->idempotency_key === null) {
DB::table('orders')->where('id', $order->id)
->update(['idempotency_key' => (string) Str::uuid()]);
$order = DB::table('orders')->where('id', $order->id)->first();
}
$transaction = $kasera->createTransaction([
'amount' => (int) $order->amount,
'description' => $order->description,
'external_id' => (string) $order->id,
'checkout' => ['steps' => ['customer', 'payment_method', 'payment']],
], $order->idempotency_key);
DB::table('orders')->where('id', $order->id)
->update(['kasera_transaction_id' => $transaction['id']]);
return redirect()->away($transaction['checkout_url']);
}
}4. Route webhook, dan kenapa harus lepas dari CSRF
Webhook adalah POST server-ke-server: tidak ada sesi, tidak ada token CSRF yang bisa disertakan. Tanpa pengecualian, setiap pengiriman dijawab 419, dianggap gagal, lalu diulang sampai tujuh kali. Sejak Laravel 11 pengecualiannya diatur di bootstrap/app.php, bukan lagi lewat properti $except pada middleware VerifyCsrfToken.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
// Webhook adalah POST server-ke-server tanpa sesi dan tanpa token CSRF.
// Yang mengotentikasinya adalah tanda tangannya.
$middleware->validateCsrfTokens(except: ['kasera-pay/webhook']);
})// routes/web.php
Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout.store');
Route::post('/kasera-pay/webhook', KaseraPayWebhookController::class);5. Verifikasi tanda tangan
Setiap pengiriman membawa header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t + "." + rawBody. Ada tiga hal yang harus benar sekaligus, dan melewatkan salah satunya membuat verifikasinya terlihat jalan padahal tidak melindungi apa pun.
- Body mentah, bukan hasil parse ulang. Yang ditandatangani adalah byte persis seperti dikirim.
json_decodelalujson_encodelagi mengubah spasi dan bisa mengubah urutan kunci, sehingga pengiriman yang sah ikut ditolak. - 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, dengan perbandingan timing-safe. Setelah rotasi secret, header membawa dua entriv1selama 24 jam, satu per secret, jadi pengiriman diterima kalau salah satu cocok. Gunakanhash_equals, bukan===.
<?php
// app/Support/KaseraPaySignature.php
namespace App\Support;
class KaseraPaySignature
{
public const TOLERANCE_SECONDS = 300;
public static function verify(
string $rawBody,
?string $header,
string $secret,
?int $now = null,
int $tolerance = self::TOLERANCE_SECONDS,
): bool {
if ($header === null || $header === '') {
return false;
}
$parts = array_map('trim', explode(',', $header));
$timestamp = null;
$signatures = [];
foreach ($parts 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;
}
$now ??= time();
if (abs($now - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
$matched = false;
foreach ($signatures as $signature) {
// Tanpa return lebih awal: semua kandidat dibandingkan supaya lama
// prosesnya tidak menyingkap entri mana yang cocok.
$matched = hash_equals($expected, $signature) || $matched;
}
return $matched;
}
}6. Handler webhook: dedupe, lalu penuhi pesanan
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, bukan pada SELECT lebih dulu. Dua pengiriman yang datang bersamaan sama-sama membaca "belum ada" kalau keputusannya diambil sebelum menulis.
// database/migrations/xxxx_create_kasera_pay_tables.php — up()
Schema::create('kasera_pay_events', function (Blueprint $table) {
$table->id();
// Unique index inilah yang membuat dedupe-nya benar di bawah pengiriman
// bersamaan. Tanpa ini, insertOrIgnore tidak menahan apa pun.
$table->string('event_id')->unique();
$table->timestamp('created_at')->nullable();
});<?php
// app/Http/Controllers/KaseraPayWebhookController.php
namespace App\Http\Controllers;
use App\Support\KaseraPaySignature;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class KaseraPayWebhookController extends Controller
{
public function __invoke(Request $request): Response
{
// Body mentah, bukan hasil parse ulang. Lihat catatan di bawah.
$rawBody = $request->getContent();
$verified = KaseraPaySignature::verify(
$rawBody,
$request->header('Kasera-Signature-V1'),
(string) config('services.kasera_pay.webhook_secret'),
);
if (! $verified) {
return response('', 400);
}
$event = json_decode($rawBody, true);
$eventId = $event['id'] ?? $request->header('Kasera-Event-Id');
if (! is_string($eventId) || $eventId === '') {
return response('', 400);
}
// Pengiriman bersifat at-least-once: id event yang sama bisa datang
// lebih dari sekali. Unique index pada event_id yang jadi penentu,
// bukan SELECT lebih dulu, karena dua pengiriman bisa bersamaan.
$firstDelivery = DB::table('kasera_pay_events')->insertOrIgnore([
'event_id' => $eventId,
'created_at' => now(),
]) === 1;
if ($firstDelivery && ($event['type'] ?? null) === 'payment.paid') {
$this->fulfil($event['data'] ?? []);
}
// 2xx menghentikan percobaan ulang. Selain 2xx, pengiriman diulang
// dengan exponential backoff sampai tujuh kali dalam sekitar 33 jam.
return response('', 200);
}
private function fulfil(array $data): void
{
$transactionId = $data['payment_request_id'] ?? '';
DB::table('orders')
->where('kasera_transaction_id', $transactionId)
->update(['status' => 'paid', 'paid_at' => $data['paid_at'] ?? now()]);
// Apa pun arti "penuhi pesanan" di aplikasinya: kirim barangnya, buka
// aksesnya, kirim tautan unduhannya. Inilah efek samping yang tidak
// boleh terjadi dua kali.
}
}Jawab 2xx begitu event tercatat. Jawaban selain 2xx membuat pengiriman diulang dengan exponential backoff sampai tujuh kali dalam rentang sekitar 33 jam, jadi pekerjaan berat sebaiknya diantrikan, bukan dikerjakan di dalam handler.
7. Mode tes dan feature test
API key kp_test_ membuat objek yang berperilaku seperti aslinya tanpa uang berpindah. Pembayaran tes tidak pernah terkonfirmasi sendiri, jadi hasilnya diarahkan sendiri memakai token dari checkout_url, yaitu bagian setelah /p/.
# Ambil token dari checkout_url (bagian setelah /p/), lalu arahkan hasilnya.
curl -X POST https://pay.kasera.id/api/v1/checkout/{token}/simulate-payment \
-H "Content-Type: application/json" \
-d '{ "outcome": "succeeded" }'Untuk pengujian di CI, tidak ada panggilan jaringan yang diperlukan sama sekali: Http::fake() menutup sisi create, dan sisi webhook cukup ditandatangani sendiri di dalam test. Test di bawah ini adalah yang dipakai memverifikasi seluruh kode di halaman ini.
<?php
// tests/Feature/KaseraPayTest.php
namespace Tests\Feature;
use App\Services\KaseraPay;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class KaseraPayTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config()->set('services.kasera_pay.key', 'kp_test_example');
config()->set('services.kasera_pay.base_url', 'https://pay.kasera.id');
config()->set('services.kasera_pay.webhook_secret', 'whsec_example');
}
private function signed(string $body, string $secret = 'whsec_example', ?int $t = null): string
{
$t ??= time();
return 't=' . $t . ',v1=' . hash_hmac('sha256', $t . '.' . $body, $secret);
}
private function deliver(string $body, ?string $header)
{
return $this->call('POST', '/kasera-pay/webhook', [], [], [], array_filter([
'HTTP_KASERA-SIGNATURE-V1' => $header,
'CONTENT_TYPE' => 'application/json',
]), $body);
}
public function test_create_sends_bearer_token_and_idempotency_key(): void
{
Http::fake(['pay.kasera.id/v1/transactions' => Http::response([
'id' => 'payreq_9b2f',
'status' => 'pending',
'checkout_url' => 'https://pay.kasera.id/p/tok_abc',
], 201)]);
$result = app(KaseraPay::class)->createTransaction(
['amount' => 150000, 'description' => 'Kaos komunitas'],
'order-1234',
);
$this->assertSame('https://pay.kasera.id/p/tok_abc', $result['checkout_url']);
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer kp_test_example')
&& $request->hasHeader('Idempotency-Key', 'order-1234')
&& $request['amount'] === 150000);
}
public function test_payload_signed_with_the_wrong_secret_is_rejected(): void
{
$body = json_encode(['id' => 'evt_2', 'type' => 'payment.paid', 'data' => []]);
$this->deliver($body, $this->signed($body, 'whsec_wrong'))->assertStatus(400);
}
public function test_delivery_older_than_the_tolerance_is_rejected(): void
{
$body = json_encode(['id' => 'evt_3', 'type' => 'payment.paid', 'data' => []]);
$this->deliver($body, $this->signed($body, 'whsec_example', time() - 400))
->assertStatus(400);
}
public function test_either_signature_is_accepted_during_rotation(): void
{
$body = json_encode(['id' => 'evt_4', 'type' => 'payment.paid', 'data' => []]);
$t = time();
$header = 't=' . $t
. ',v1=' . hash_hmac('sha256', $t . '.' . $body, 'whsec_old')
. ',v1=' . hash_hmac('sha256', $t . '.' . $body, 'whsec_example');
$this->deliver($body, $header)->assertOk();
}
public function test_the_same_event_delivered_twice_fulfils_once(): void
{
$id = DB::table('orders')->insertGetId([
'amount' => 150000, 'kasera_transaction_id' => 'payreq_9b2f',
]);
$body = json_encode([
'id' => 'evt_5',
'type' => 'payment.paid',
'data' => ['payment_request_id' => 'payreq_9b2f'],
]);
$header = $this->signed($body);
$this->deliver($body, $header)->assertOk();
$this->deliver($body, $header)->assertOk();
$this->assertSame(1, DB::table('kasera_pay_events')->where('event_id', 'evt_5')->count());
$this->assertSame('paid', DB::table('orders')->find($id)->status);
}
public function test_the_endpoint_verifies_the_raw_body_not_a_re_encoded_one(): void
{
$id = DB::table('orders')->insertGetId([
'amount' => 150000, 'kasera_transaction_id' => 'payreq_9b2f',
]);
// Spasi yang tidak akan direproduksi json_encode. Kalau handler-nya
// meng-encode ulang payload sebelum di-hash, HMAC-nya tidak cocok lagi
// dan pengiriman yang sah justru ditolak.
$body = '{"id":"evt_8", "type":"payment.paid", "data":{"payment_request_id":"payreq_9b2f"}}';
$this->deliver($body, $this->signed($body))->assertOk();
$this->assertSame('paid', DB::table('orders')->find($id)->status);
}
}Satu catatan tentang cara membaca test seperti ini: sebuah 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 getContent() dengan payload yang di-encode ulang harus menjatuhkan test body mentah; menghapus dedupe harus menjatuhkan test pengiriman ganda.
Kesalahan yang paling sering muncul
- Memenuhi pesanan saat pembeli kembali ke halaman penjual. Kepulangan itu navigasi, bukan bukti. Kalau perlu memastikan sesuatu saat pembeli kembali, panggil
GET /v1/transactions/:iddari server. - Membuat
Idempotency-Keybaru di setiap percobaan ulang. Proteksinya hilang tanpa error apa pun. - Memverifikasi tanda tangan atas payload yang sudah di-parse ulang, lalu menyimpulkan tanda tangannya rusak.
- Memakai header lama
Kasera-Signaturetanpa-V1. Header itu masih terkirim tetapi sudah deprecated: tidak melindungi dari replay dan tidak punya masa tenggang rotasi. - Menjawab selain 2xx untuk event yang sudah pernah diproses. Event duplikat tetap dijawab 200; yang membedakan adalah tidak memenuhi pesanannya dua kali.
Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Untuk batas nominal dan laju permintaan, lihat batas dan laju permintaan; untuk daftar metode yang aktif hari ini beserta tarifnya, dokumentasi metode pembayaran. Kalau yang dibutuhkan hanya menagih tanpa menulis kode sama sekali, tautan pembayaran Kasera Pay menyelesaikannya tanpa integrasi.
Pertanyaan yang sering muncul
Apakah Kasera Pay punya SDK PHP atau package Laravel resmi?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai HTTP client bawaan Laravel, yaitu facade Http, ditambah hash_hmac dan hash_equals dari PHP. Tidak ada dependensi tambahan yang perlu dipasang, dan tidak ada package yang perlu menunggu rilis setiap kali API bertambah.
Kenapa route webhook harus dikecualikan dari CSRF?
Karena pengirimnya server Kasera Pay, bukan browser pembeli, jadi tidak ada sesi dan tidak ada token CSRF yang bisa disertakan. Tanpa pengecualian itu, setiap pengiriman dijawab 419 dan dianggap gagal, lalu diulang sampai tujuh kali. Yang mengotentikasi pengiriman adalah tanda tangan di header Kasera-Signature-V1, bukan CSRF.
Kenapa verifikasi harus memakai body mentah, bukan hasil json_decode?
Karena yang ditandatangani adalah byte persis seperti yang dikirim. Melakukan json_decode lalu json_encode lagi mengubah spasi dan bisa mengubah urutan kunci, sehingga HMAC-nya berbeda dan pengiriman yang sah ikut ditolak. Di Laravel, ambil dengan $request->getContent(), dan jangan pakai $request->all() untuk keperluan ini.
Apakah cukup mengandalkan Idempotency-Key untuk mencegah pembayaran ganda?
Untuk sisi create, ya, dan hanya header itu yang melakukannya. Header ini opsional, dan tanpa header itu setiap percobaan menjadi permintaan pembayaran tersendiri. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, bukan penjamin keunikan. Syaratnya key dibuat sekali lalu disimpan, karena key baru di setiap percobaan ulang menghapus proteksinya.
Bagaimana menguji alur ini tanpa uang sungguhan?
Pakai API key kp_test_. Objek yang dibuatnya membawa livemode: false, biayanya sama, dan webhook-nya dikirim ke endpoint mode tes dengan signing secret-nya sendiri. Pembayaran tes tidak pernah terkonfirmasi sendiri, jadi hasilnya diarahkan sendiri lewat endpoint simulate-payment memakai token dari checkout_url.
Versi Laravel berapa yang dipakai contoh ini?
Laravel 13, khususnya pada 13.31 dengan PHP 8.4. Bagian yang paling terikat versi adalah pengecualian CSRF, yang sejak Laravel 11 diatur lewat validateCsrfTokens di bootstrap/app.php dan bukan lagi lewat properti $except pada middleware VerifyCsrfToken. Sisanya, yaitu facade Http, hash_hmac, dan hash_equals, sudah stabil jauh sebelum itu.