Panduan · Terbit
Integrasi payment gateway di Yii2: yii2-httpclient yang diam-diam mengirim form-urlencoded, getRawBody() untuk tanda tangan webhook, dan upsert() yang berubah menjadi INSERT biasa tanpa unique index
Panduan ini memasang Kasera Pay di aplikasi Yii 2 dengan dua komponen yang sudah dimiliki kebanyakan proyek Yii2: yii2-httpclient untuk memanggil API, dan Query Builder bawaan untuk mencatat event webhook. Klien API dan controller webhook di bawah dijalankan pada yiisoft/yii2 2.0.55, yii2-httpclient 2.0.17, PHP 8.3, dan PostgreSQL 16 sebelum ditulis di sini. Jalur yang lebih singkat untuk proyek di PHP 8.1 ke atas adalah SDK PHP resmi (kasera/kasera-pay).
Kontrak API-nya sama dengan yang dibahas di integrasi payment gateway di PHP tanpa framework. Yang khas Yii2 ada tiga, dan ketiganya gagal tanpa pesan yang menunjuk ke penyebabnya:
yii2-httpclientmengirimsetData()sebagaiapplication/x-www-form-urlencodedkecuali formatnya diganti, dan tidak melempar exception untuk status 4xx.json_encode(getBodyParams())menghasilkan tanda tangan yang cocok di uji lokal dan tidak cocok begitu body memuat garis miring atau huruf non-ASCII.upsert()membaca unique index dari skema. Tanpa index itu, ia diam-diam menjadiINSERTbiasa, dan dedupe-nya hilang.
Alurnya, sebelum menulis kode
Server membuat permintaan pembayaran, pembeli dibawa ke checkout_url dan membayar di sana, lalu Kasera Pay mengirim payment.paid bertanda tangan ke endpoint webhook. Hanya event itu yang menentukan bahwa uangnya masuk. Kepulangan pembeli ke return_url adalah navigasi, bukan bukti: halaman itu bisa dibuka siapa saja. Metode yang aktif hari ini adalah QRIS dan Virtual Account tujuh bank, dan daftar yang berlaku dibaca dari GET /v1/payment_methods, seperti dijelaskan di dokumentasi metode pembayaran.
1. Kredensial: variabel lingkungan, bukan config/params.php
Di template Yii2, config/params.php dan config/db.php ikut ter-commit. Kunci API yang ditulis di sana masuk riwayat git, dan kunci yang pernah masuk riwayat git harus dianggap bocor meski komitnya sudah dihapus. Yang ditulis di berkas itu cukup nama variabel lingkungannya.
<?php
// config/params.php
// Berkas ini ikut ter-commit di template Yii2, jadi yang ditulis di sini hanya
// nama variabel lingkungannya. Nilainya diisi di server, bukan di repositori.
// kp_test_ selama membangun, kp_live_ setelah go-live.
return [
'kaseraPay' => [
'apiKey' => getenv('KASERAPAY_API_KEY') ?: '',
'baseUrl' => 'https://pay.kasera.id',
'webhookSecret' => getenv('KASERAPAY_WEBHOOK_SECRET') ?: '',
'toleranceSeconds' => 300,
],
];2. Klien API: format JSON dan isOk
Format bawaan permintaan di yii2-httpclient adalah urlencoded. Tanpa mengubahnya, setData() mengirim amount=150000&customer%5Bname%5D=Budi, dan API menjawab 422 invalid_body dengan pesan “request body is not valid JSON”. Menyetelnya sekali di requestConfig lebih aman daripada mengingat setFormat() di setiap pemanggilan.
Hal kedua: send() mengembalikan response untuk status apa pun. Kode yang langsung membaca $response->data['checkout_url'] tanpa memeriksa isOk mendapat null dari body galat, lalu galatnya muncul jauh dari penyebabnya.
<?php
// components/KaseraPayClient.php
namespace app\components;
use RuntimeException;
use Yii;
use yii\httpclient\Client;
class KaseraPayClient
{
private Client $http;
public function __construct()
{
$this->http = new Client([
'baseUrl' => Yii::$app->params['kaseraPay']['baseUrl'],
// Tanpa baris ini setData() mengirim body sebagai
// application/x-www-form-urlencoded, dan API menolaknya dengan
// 422 invalid_body "request body is not valid JSON".
'requestConfig' => ['format' => Client::FORMAT_JSON],
'responseConfig' => ['format' => Client::FORMAT_JSON],
]);
}
/**
* $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', $payload, [
'Idempotency-Key' => $idempotencyKey,
]);
}
public function retrieveTransaction(string $id): array
{
return $this->send('GET', 'v1/transactions/' . rawurlencode($id));
}
private function send(string $method, string $url, ?array $data = null, array $headers = []): array
{
$request = $this->http->createRequest()
->setMethod($method)
->setUrl($url)
->addHeaders(array_merge([
'Authorization' => 'Bearer ' . Yii::$app->params['kaseraPay']['apiKey'],
'Accept' => 'application/json',
], $headers))
->setOptions(['timeout' => 15]);
if ($data !== null) {
$request->setData($data);
}
$response = $request->send();
// yii2-httpclient tidak melempar exception untuk 4xx atau 5xx.
// Tanpa pemeriksaan isOk, body galat ikut dikembalikan sebagai hasil.
if (! $response->isOk) {
throw new RuntimeException(
'Kasera Pay ' . $response->statusCode . ': ' . ($response->data['error']['code'] ?? 'unknown'),
);
}
return $response->data;
}
}3. Controller checkout: kunci idempotency milik pesanan
Kunci idempotency dibuat sekali, disimpan bersama pesanannya, lalu dipakai lagi apa adanya di setiap percobaan ulang. Kunci baru setiap percobaan membatalkan gunanya tanpa galat apa pun, dan tidak ada field lain yang menutupinya: external_id dan merchant_ref hanya label, jadi dua create dengan external_id sama tetap menjadi dua permintaan pembayaran. Penjelasan lengkapnya ada di dokumentasi idempotency.
<?php
// controllers/CheckoutController.php
namespace app\controllers;
use app\components\KaseraPayClient;
use Yii;
use yii\helpers\Url;
use yii\web\Controller;
class CheckoutController extends Controller
{
public function actionPay(int $id)
{
$db = Yii::$app->db;
$order = $db->createCommand('SELECT * FROM orders WHERE id = :id', [':id' => $id])->queryOne();
// 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));
$db->createCommand()->update('orders',
['kasera_idempotency_key' => $order['kasera_idempotency_key']],
['id' => $id])->execute();
}
$payment = (new KaseraPayClient())->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' => Url::to(['checkout/selesai', 'id' => $id], true),
], $order['kasera_idempotency_key']);
$db->createCommand()->update('orders',
['kasera_payment_id' => $payment['id']],
['id' => $id])->execute();
return $this->redirect($payment['checkout_url']);
}
}$this->redirect() meneruskan URL absolut apa adanya, jadi pembeli langsung dibawa ke halaman pembayaran Kasera Pay.
4. Route webhook dan CSRF
Yii2 memeriksa token CSRF di beforeAction untuk setiap POST. Pengiriman webhook tidak membawa token, jadi tanpa pengecualian Yii2 menjawab 400 dengan pesan “Unable to verify your data submission” sebelum action-nya sempat berjalan. Kasera Pay menganggap jawaban selain 2xx sebagai gagal dan mengulangnya sampai tujuh kali dalam sekitar 33 jam. Matikan enableCsrfValidation hanya di controller webhook, dan pastikan controller itu tidak mewarisi AccessControl dari base controller yang mengalihkan tamu ke halaman login.
// config/web.php, di dalam 'components' => ['urlManager' => [...]]
'rules' => [
'POST webhook/kasera-pay' => 'kasera-pay-webhook/handle',
],5. Tanda tangan atas getRawBody()
Header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas t, titik, lalu body mentah, seperti dirinci di dokumentasi webhook. Tiga hal harus benar sekaligus:
- Body dari
getRawBody(). Saat diuji,json_encode(getBodyParams())menghasilkan tanda tangan yang cocok untuk body ringkas berisi ASCII saja, lalu gagal 400 begituexternal_idberisiINV/2026/10/001(PHP menulis garis miring sebagai\/) atau nama pelanggan berisi huruf seperti “é”. Penomoran invoice dengan garis miring sangat umum di Indonesia, jadi kegagalan ini biasanya baru muncul di produksi. - Toleransi lima menit terhadap
t. Tanpa itu, pengiriman lama yang tersadap bisa diputar ulang selamanya. - Semua entri
v1, dibandingkan denganhash_equals. Setelah rotasi signing secret, header membawa dua entriv1selama 24 jam.
6. Dedupe lewat upsert() dan unique index
Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lebih dari sekali dengan id yang sama. Di PostgreSQL, upsert($table, $columns, false) menjadi INSERT ... ON CONFLICT DO NOTHING dan execute() mengembalikan 0 untuk duplikat. Ini lebih tepat daripada menangkap IntegrityException: di PostgreSQL, pelanggaran unique membatalkan seluruh transaksi yang sedang berjalan, termasuk pemenuhan pesanannya.
Bagian yang mudah terlewat: upsert() menyusun klausa konfliknya dari unique index yang terbaca di skema tabel. Tanpa index, yang dihasilkan adalah INSERT biasa. Dalam uji 30 pengiriman kembar bersamaan, versi dengan unique index mencatat satu event dan memenuhi pesanan sekali. Versi tanpa index mencatat 30 event dan memenuhi pesanan 30 kali, dan ke-30 pengirimannya tetap dijawab 200. Kalau enableSchemaCache aktif di produksi, jalankan ./yii cache/flush-schema setelah migrasi agar index barunya terbaca.
<?php
// ./yii migrate/create create_kasera_pay_events_table
public function safeUp()
{
$this->createTable('{{%kasera_pay_events}}', [
'id' => $this->bigPrimaryKey(),
'event_id' => $this->string(64)->notNull(),
'created_at' => $this->dateTime()->notNull(),
]);
// Baris inilah yang mengerjakan dedupe-nya. upsert() membaca unique index
// dari skema tabel; tanpa index ini ia menghasilkan INSERT biasa.
$this->createIndex('ux_kasera_pay_events_event_id', '{{%kasera_pay_events}}', 'event_id', true);
}<?php
// controllers/KaseraPayWebhookController.php
namespace app\controllers;
use Yii;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\Response;
class KaseraPayWebhookController extends Controller
{
// Pengirimnya server Kasera Pay, bukan peramban: tidak ada token CSRF yang
// bisa disertakan. Dimatikan hanya di controller ini, dan yang
// menggantikannya adalah pemeriksaan tanda tangan di bawah.
public $enableCsrfValidation = false;
public function behaviors()
{
return ['verbs' => ['class' => VerbFilter::class, 'actions' => ['handle' => ['POST']]]];
}
public function actionHandle()
{
Yii::$app->response->format = Response::FORMAT_JSON;
$params = Yii::$app->params['kaseraPay'];
// getRawBody() mengembalikan byte persis seperti dikirim.
// getBodyParams() dan post() sudah diurai, dan hasil urai tidak bisa
// dipakai memeriksa tanda tangan.
$raw = Yii::$app->request->getRawBody();
$header = Yii::$app->request->headers->get('Kasera-Signature-V1', '');
if (! $this->verify($raw, $header, $params['webhookSecret'], $params['toleranceSeconds'])) {
Yii::$app->response->statusCode = 400;
return ['error' => 'bad signature'];
}
$event = json_decode($raw, true);
$db = Yii::$app->db;
// Catatan event dan pemenuhan pesanan ada dalam satu transaksi. Kalau
// fulfil() melempar, id event ikut batal dan jawabannya 500, jadi
// pengiriman ulang memenuhi pesanannya, bukan dijawab 200 sebagai duplikat.
$transaction = $db->beginTransaction();
try {
// upsert(..., false) menjadi INSERT ... ON CONFLICT DO NOTHING di
// PostgreSQL. Unique index yang memutuskan, bukan SELECT lebih dulu.
$inserted = $db->createCommand()
->upsert('kasera_pay_events', [
'event_id' => $event['id'],
'created_at' => date('Y-m-d H:i:s'),
], false)
->execute();
if ($inserted > 0 && ($event['type'] ?? '') === 'payment.paid') {
$this->fulfil($event['data']);
}
$transaction->commit();
} catch (\Throwable $e) {
$transaction->rollBack();
Yii::error('kasera pay webhook: ' . $e->getMessage(), __METHOD__);
Yii::$app->response->statusCode = 500;
return ['ok' => false];
}
// Duplikat tetap dijawab 200: yang tidak boleh terjadi dua kali adalah
// pemenuhan pesanannya, bukan jawabannya.
return ['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;
}
// Tanpa batas waktu ini, pengiriman lama yang tersadap bisa diputar
// ulang 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
{
Yii::$app->db->createCommand()->update(
'orders',
['status' => 'paid', 'paid_at' => $payment['paid_at']],
['and', ['number' => $payment['external_id']], ['!=', 'status', 'paid']],
)->execute();
}
}Di MySQL, upsert(..., false) menjadi ON DUPLICATE KEY UPDATE yang menulis ulang nilai yang sama, dan execute() tetap mengembalikan 0 untuk duplikat selama opsi PDO MYSQL_ATTR_FOUND_ROWS tidak dinyalakan.
7. Mengujinya
Kunci berawalan kp_test_ membuat objek yang berperilaku seperti aslinya tanpa uang berpindah, dengan signing secret webhook terpisah dari mode live. Sisi webhook bisa diuji tanpa Kasera Pay sama sekali dengan menandatangani body sendiri:
# kirim-webhook.sh: menandatangani body sendiri lalu mengirimnya ke lokal.
SECRET=whsec_... # signing secret mode tes dari dashboard
BODY='{"id":"evt_1","type":"payment.paid","data":{"external_id":"INV/2026/10/001","paid_at":"2026-10-19T12:00:00+07:00"}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -s -w ' %{http_code}\n' -X POST http://localhost:8080/webhook/kasera-pay \
-H 'Content-Type: application/json' \
-H "Kasera-Signature-V1: t=$T,v1=$SIG" \
--data-raw "$BODY"Kirim sekali dan harapkan 200. Kirim lagi dan harapkan 200 tanpa pesanan dipenuhi ulang. Ganti SECRET dan harapkan 400. Mundurkan T sepuluh menit dan harapkan 400. Lalu rusak kodenya satu per satu dan pastikan yang merah adalah yang seharusnya: mengganti getRawBody() dengan json_encode(getBodyParams()) harus menjatuhkan pengiriman pertama karena body di atas berisi garis miring, dan menghapus unique index harus membuat tabel event berisi lebih dari satu baris setelah 30 pengiriman bersamaan.
Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Untuk proyek PHP lain di tim yang sama, panduan CodeIgniter 4 dan panduan Laravel membahas jebakan yang setara di kerangka masing-masing.
Pertanyaan yang sering muncul
Apakah ada extension Yii2 atau SDK resmi untuk Kasera Pay?
Tidak ada extension khusus Yii2, tetapi SDK PHP resmi ada: composer require kasera/kasera-pay, untuk PHP 8.1 ke atas, dan bisa dipakai di aplikasi Yii2 yang berjalan di PHP versi itu. Rinciannya ada di pay.kasera.id/docs/api/sdks. Panduan ini memakai yii2-httpclient karena banyak proyek Yii2 sudah memasangnya, dan karena dua jebakannya (format bawaan dan 4xx yang tidak melempar) justru yang paling sering membuat integrasi pertama gagal.
Kenapa webhook ditolak 400 padahal tanda tangannya belum diperiksa?
Karena validasi CSRF Yii2 berjalan di beforeAction, sebelum action dipanggil. Pengiriman webhook tidak membawa token CSRF, jadi Yii2 menjawab 400 Bad Request dengan pesan Unable to verify your data submission, dan Kasera Pay mengulang pengirimannya sampai tujuh kali dalam sekitar 33 jam. Matikan enableCsrfValidation hanya di controller webhook, bukan di seluruh aplikasi.
Kenapa json_encode(getBodyParams()) tidak boleh dipakai memeriksa tanda tangan, padahal di uji lokal lolos?
Karena lolosnya kebetulan. Untuk body ASCII yang ringkas dan tanpa garis miring, json_encode menghasilkan byte yang sama dengan aslinya. Begitu body memuat garis miring, misalnya nomor invoice INV/2026/10/001, json_encode menuliskannya sebagai \/, dan huruf non-ASCII pada nama pelanggan ditulis sebagai \u. HMAC-nya berubah dan pengiriman yang sah ditolak 400. getRawBody() mengembalikan byte persis seperti dikirim.
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.
Versi Yii berapa yang dipakai contoh ini?
yiisoft/yii2 2.0.55 dan yiisoft/yii2-httpclient 2.0.17 di PHP 8.3, dengan PostgreSQL 16 untuk uji pengiriman bersamaan. Perilaku yang dibahas, yaitu format bawaan urlencoded di yii2-httpclient, enableCsrfValidation, getRawBody(), dan upsert() di QueryBuilder, sudah ada di seri 2.0 jauh sebelum versi itu.