Panduan · Terbit
Integrasi payment gateway di CakePHP: Http\Client yang mengirim form-urlencoded, body webhook yang sudah habis dibaca BodyParserMiddleware, dan save() yang menjawab setiap duplikat dengan 500
Panduan ini memasang Kasera Pay di aplikasi CakePHP 5 dengan komponen yang sudah ada di template cakephp/app: Cake\Http\Client untuk memanggil API, query builder ORM untuk mencatat event webhook, dan cakephp/migrations untuk tabelnya. Semua kode di bawah dijalankan pada cakephp/cakephp 5.4.3, PHP 8.3, dan PostgreSQL 16 sebelum ditulis di sini, termasuk uji 30 pengiriman webhook kembar bersamaan. Jalur yang lebih singkat untuk sisi pemanggilan API adalah SDK PHP resmi (kasera/kasera-pay).
Kontrak API-nya sama dengan yang dibahas di integrasi payment gateway di PHP tanpa framework. Yang khas CakePHP ada empat, dan tidak satu pun menghasilkan pesan yang menunjuk ke penyebabnya:
Http\Clientmengirim array sebagaiapplication/x-www-form-urlencodedkecuali opsitypediisijson, dan tidak melempar exception untuk status 4xx.CsrfProtectionMiddlewaremenolak webhook dengan 403 sebelum controller dipanggil.BodyParserMiddlewaresudah membaca stream body sampai habis, jadigetBody()->getContents()di controller mengembalikan string kosong.- Aturan
isUnique()hasilbin/cake bake, dipakai lewatsave()di dalamtransactional(), menjawab setiap pengiriman ulang dengan 500.
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: env(), bukan config/app.php
Di template cakephp/app, config/app.php ikut ter-commit, sedangkan config/app_local.php dan config/.env ada di .gitignore. Kunci API yang ditulis di app.php masuk riwayat git, dan kunci yang pernah masuk riwayat git harus dianggap bocor meski komitnya sudah dihapus. Berkas konfigurasi tersendiri yang hanya berisi nama variabel lingkungan aman untuk di-commit.
<?php
// config/kasera_pay.php, dimuat dari config/bootstrap.php:
// Configure::load('kasera_pay');
// Berkas ini boleh ter-commit karena isinya hanya nama variabel lingkungan.
// Nilainya diisi di server atau di config/.env, yang keduanya tidak masuk git.
// kp_test_ selama membangun, kp_live_ setelah go-live.
return [
'KaseraPay' => [
'apiKey' => env('KASERAPAY_API_KEY', ''),
'baseUrl' => env('KASERAPAY_BASE_URL', 'https://pay.kasera.id'),
'webhookSecret' => env('KASERAPAY_WEBHOOK_SECRET', ''),
'toleranceSeconds' => 300,
],
];Isi juga APP_FULL_BASE_URL dengan alamat https toko, misalnya https://toko.example. Di belakang reverse proxy atau load balancer, Router::url(..., true) tanpa App.fullBaseUrl bisa menghasilkan alamat http://, dan dengan kunci live API menolak return_url yang bukan https dengan 422 validation_failed. Dengan kunci tes, http://localhost masih diterima, jadi masalahnya baru muncul saat go-live.
2. Klien API: type json dan isSuccess()
Tanpa opsi type, Client::post() mengubah array menjadi form. Saat diuji, body yang terkirim adalah amount=150000&description=Pesanan+INV%2F2026%2F10%2F001&customer%5Bname%5D=Budi, dan API menjawab 422 invalid_body dengan pesan “request body is not valid JSON”. Dengan 'type' => 'json', array yang sama dikirim sebagai JSON dan header Accept ikut disetel.
Hal kedua: Client mengembalikan response untuk status apa pun. Tanpa pemeriksaan, permintaan yang dijawab 404 dikembalikan sebagai hasil biasa berisi body galat. Periksa isSuccess(), bukan isOk(): isOk() bernilai benar untuk 200 sampai 399, termasuk pengalihan yang tidak diikuti karena opsi redirect bawaan Client mati.
<?php
// src/Service/KaseraPayClient.php
declare(strict_types=1);
namespace App\Service;
use Cake\Core\Configure;
use Cake\Http\Client;
use RuntimeException;
class KaseraPayClient
{
private Client $http;
public function __construct()
{
$this->http = Client::createFromUrl(Configure::read('KaseraPay.baseUrl'));
}
/**
* $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 $path, array $data = [], array $headers = []): array
{
$options = [
// Tanpa 'type' => 'json', Client mengirim array sebagai
// application/x-www-form-urlencoded, dan API menolaknya dengan
// 422 invalid_body "request body is not valid JSON".
'type' => 'json',
'headers' => $headers + [
'Authorization' => 'Bearer ' . Configure::read('KaseraPay.apiKey'),
],
'timeout' => 15,
];
$response = $method === 'get'
? $this->http->get($path, [], $options)
: $this->http->post($path, $data, $options);
// Client tidak melempar exception untuk 4xx atau 5xx. isOk() juga
// menganggap 3xx berhasil, jadi yang diperiksa adalah isSuccess().
if (! $response->isSuccess()) {
throw new RuntimeException(sprintf(
'Kasera Pay %d: %s',
$response->getStatusCode(),
$response->getJson()['error']['code'] ?? 'unknown',
));
}
return $response->getJson();
}
}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. Saat diuji, dua kali membuka /checkout/pay/1 mengirim Idempotency-Key yang sama dan keduanya dijawab 302 ke checkout_url.
<?php
// src/Controller/CheckoutController.php
declare(strict_types=1);
namespace App\Controller;
use App\Service\KaseraPayClient;
use Cake\Routing\Router;
class CheckoutController extends AppController
{
public function pay(string $id)
{
$orders = $this->fetchTable('Orders');
// Kunci idempotency milik PESANAN, bukan milik percobaan. updateAll()
// bersyarat: dua klik bersamaan sama-sama melihat kunci kosong, tetapi
// hanya satu yang berhasil mengisinya. Keduanya lalu membaca ulang dan
// memakai kunci yang tersimpan.
$orders->updateAll(
['kasera_idempotency_key' => bin2hex(random_bytes(16))],
['id' => $id, 'kasera_idempotency_key IS' => null],
);
$order = $orders->get($id);
$payment = (new KaseraPayClient())->createTransaction([
'amount' => (int)$order->amount,
'description' => 'Pesanan ' . $order->number,
'external_id' => $order->number,
'customer' => ['name' => $order->customer_name],
'return_url' => Router::url(['controller' => 'Checkout', 'action' => 'selesai', $id], true),
], $order->kasera_idempotency_key);
$orders->updateAll(['kasera_payment_id' => $payment['id']], ['id' => $id]);
return $this->redirect($payment['checkout_url']);
}
}Action selesai adalah halaman terima kasih. Kalau halaman itu menampilkan status, ambil statusnya dari database sendiri yang diisi webhook, atau dari retrieveTransaction(), bukan dari parameter di URL.
4. Route webhook dan CSRF
Template cakephp/app memasang CsrfProtectionMiddleware untuk seluruh aplikasi. Pengiriman webhook tidak membawa cookie atau token CSRF, jadi jawabannya 403 dengan pesan “Missing or incorrect CSRF cookie type” sebelum controller sempat berjalan. Kasera Pay menganggap jawaban selain 2xx sebagai gagal dan mengulangnya sampai tujuh kali dalam sekitar 33 jam.
skipCheckCallback() mengecualikan satu controller saja. Di template, urutan middleware-nya RoutingMiddleware, BodyParserMiddleware, lalu CSRF, jadi saat callback dipanggil, parameter route sudah tersedia dan pengecualiannya bisa ditulis per controller, bukan per pola URL.
// src/Application.php, di dalam middleware(), menggantikan baris CSRF bawaan template
->add((new CsrfProtectionMiddleware([
'httponly' => true,
]))->skipCheckCallback(function (ServerRequestInterface $request): bool {
// Pengirim webhook adalah server Kasera Pay, bukan peramban, jadi
// tidak membawa token CSRF. Hanya route ini yang dikecualikan;
// penggantinya adalah pemeriksaan tanda tangan di controller.
return ($request->getAttribute('params')['controller'] ?? null) === 'KaseraPayWebhook';
}));// config/routes.php, di dalam $routes->scope('/', ...)
$builder->post('/webhook/kasera-pay', 'KaseraPayWebhook::handle');
$builder->get('/checkout/pay/{id}', 'Checkout::pay')->setPass(['id']);5. Tanda tangan atas body mentah
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
(string)$this->request->getBody().BodyParserMiddlewaremembaca stream yang sama dengangetContents()untuk mengisigetData(). Saat diuji,getBody()->getContents()di controller mengembalikan string kosong dan setiap pengiriman yang sah ditolak 400. Cast ke string memutar stream ke awal. HasilgetData()yang di-json_encodeulang juga tidak bisa dipakai: PHP menulis garis miring padaINV/2026/10/001sebagai\/, dan HMAC-nya berubah. - 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 event: ON CONFLICT, bukan aturan isUnique()
Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lebih dari sekali dengan id yang sama. Jalan yang terlihat paling CakePHP adalah tabel hasil bake dengan $rules->isUnique(['event_id']) lalu save(). Di dalam transactional(), jalan itu gagal dengan cara yang tidak terduga: save() membuka transaksi bersarang, aturan yang gagal me-rollback transaksi itu, dan commit transaksi luar melempar “Cannot commit transaction - rollback() has been already called in the nested transaction”.
Hasil ujinya, dengan unique index terpasang:
| Cara mencatat event | 3 pengiriman berurutan | 30 pengiriman bersamaan |
|---|---|---|
save() + isUnique() | 200, 500, 500 | 1 dijawab 200, 29 dijawab 500 |
save(..., ['atomic' => false]) + isUnique() | 200, 200, 200 | pada satu dari tiga putaran, 1 dijawab 500 (unique violation) |
insertQuery() + ON CONFLICT DO NOTHING | 200, 200, 200 | 30 dijawab 200 pada ketiga putaran |
Di semua baris, pesanannya dipenuhi tepat sekali, karena fulfil() mengunci baris pesanan dengan FOR UPDATE dan memeriksa statusnya. Yang berbeda adalah jawabannya. Duplikat yang dijawab 500 dikirim ulang sampai jatah percobaannya habis, dan log pengiriman di dashboard penuh dengan kegagalan yang sebenarnya bukan kegagalan. Tanpa unique index, isUnique() hanyalah SELECT sebelum INSERT: pada salah satu putaran uji, dua baris event dengan id yang sama tetap tertulis.
ON CONFLICT (event_id) DO NOTHING menyerahkan keputusannya ke unique index, dan rowCount() bernilai 0 untuk duplikat. Kalau index-nya lupa dibuat, PostgreSQL menolak setiap INSERT dengan SQLSTATE[42P10] dan semua pengiriman dijawab 500. Itu kegagalan yang langsung terlihat, bukan dedupe yang diam-diam hilang.
<?php
// config/Migrations/20261025000000_CreateKaseraPayEvents.php
// bin/cake migrations migrate
declare(strict_types=1);
use Migrations\BaseMigration;
class CreateKaseraPayEvents extends BaseMigration
{
public function change(): void
{
$this->table('kasera_pay_events')
->addColumn('event_id', 'string', ['limit' => 64, 'null' => false])
->addColumn('created', 'datetime', ['null' => false])
// Index inilah yang mengerjakan dedupe-nya. Aturan isUnique() di
// tabel ORM hanya SELECT sebelum INSERT, dan tidak menahan
// pengiriman kembar yang datang bersamaan.
->addIndex(['event_id'], ['unique' => true])
->create();
}
}<?php
// src/Controller/KaseraPayWebhookController.php
declare(strict_types=1);
namespace App\Controller;
use Cake\Core\Configure;
use Cake\I18n\DateTime;
use Cake\Log\Log;
use RuntimeException;
use Throwable;
class KaseraPayWebhookController extends AppController
{
public function handle()
{
$this->request->allowMethod('post');
$config = Configure::read('KaseraPay');
// (string) memutar stream ke awal lalu membaca seluruhnya.
// getBody()->getContents() mengembalikan string kosong di sini, karena
// BodyParserMiddleware sudah membaca stream yang sama sampai habis.
// getData() sudah diurai, 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->json(400, ['error' => 'bad signature']);
}
$event = json_decode($raw, true);
$events = $this->fetchTable('KaseraPayEvents');
try {
// 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.
$events->getConnection()->transactional(function () use ($events, $event): void {
// ON CONFLICT DO NOTHING: unique index yang memutuskan, bukan
// SELECT lebih dulu. rowCount() bernilai 0 untuk duplikat.
$inserted = $events->insertQuery()
->insert(['event_id', 'created'])
->values(['event_id' => $event['id'], 'created' => DateTime::now()])
->epilog('ON CONFLICT (event_id) DO NOTHING')
->execute()
->rowCount();
if ($inserted > 0 && ($event['type'] ?? '') === 'payment.paid') {
$this->fulfil($event['data']);
}
});
} catch (Throwable $e) {
Log::error('kasera pay webhook: ' . $e->getMessage());
return $this->json(500, ['ok' => false]);
}
// Duplikat tetap dijawab 200: yang tidak boleh terjadi dua kali adalah
// pemenuhan pesanannya, bukan jawabannya.
return $this->json(200, ['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
{
$orders = $this->fetchTable('Orders');
$order = $orders->find()
->where(['number' => $payment['external_id'] ?? ''])
->epilog('FOR UPDATE')
->first();
// Melempar di sini membatalkan id event dan menjawab 500, jadi Kasera Pay
// mengirim ulang dan masalahnya terlihat di log, bukan hilang diam-diam.
if ($order === null) {
throw new RuntimeException('tidak ada pesanan ' . ($payment['external_id'] ?? ''));
}
if ((int)$order->amount !== (int)$payment['amount']) {
throw new RuntimeException('nominal tidak cocok untuk pesanan ' . $order->id);
}
if ($order->status === 'paid') {
Log::warning('pembayaran kedua untuk pesanan lunas ' . $order->id);
return;
}
// updateAll() yang mengenai nol baris tetap "berhasil". Tanpa
// pemeriksaan ini id event ter-commit dan setiap pengiriman ulang
// dijawab 200 sebagai duplikat, padahal pesanannya belum terpenuhi.
$n = $orders->updateAll(
['status' => 'paid', 'paid_at' => new DateTime($payment['paid_at'])],
['id' => $order->id, 'status !=' => 'paid'],
);
if ($n !== 1) {
throw new RuntimeException("pemenuhan mengenai $n baris");
}
}
private function json(int $status, array $body)
{
return $this->response
->withStatus($status)
->withType('application/json')
->withStringBody((string)json_encode($body));
}
}fetchTable() di CakePHP 5 membutuhkan kelas tabelnya, jadi buat src/Model/Table/KaseraPayEventsTable.php dan OrdersTable.php, cukup kelas kosong yang mewarisi Cake\ORM\Table. Klausa ON CONFLICT berlaku di PostgreSQL dan SQLite. Di MySQL padanannya INSERT IGNORE atau ON DUPLICATE KEY UPDATE, yang tidak diuji untuk panduan ini.
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 ke bin/cake server, yang mendengarkan di port 8765:
# kirim-webhook.sh: menandatangani body sendiri lalu mengirimnya ke bin/cake server.
SECRET=whsec_... # signing secret mode tes dari dashboard
BODY='{"id":"evt_1","type":"payment.paid","data":{"external_id":"INV/2026/10/001","amount":150000,"paid_at":"2026-10-25T12:00:00+07:00","status":"succeeded"}}'
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:8765/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 cast (string) dengan getContents() harus menjatuhkan pengiriman pertama dengan 400, dan mengembalikan skipCheckCallback ke false harus menghasilkan 403. Untuk uji bersamaan, jalankan server dengan PHP_CLI_SERVER_WORKERS=16 dan kirim 30 salinan sekaligus dengan xargs -P 30; tabel event harus berisi satu baris.
Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Untuk proyek PHP lain di tim yang sama, panduan Laravel dan panduan Yii2 membahas jebakan yang setara di kerangka masing-masing.
Pertanyaan yang sering muncul
Apakah ada plugin CakePHP atau SDK resmi untuk Kasera Pay?
Tidak ada plugin khusus CakePHP, tetapi SDK PHP resmi ada: composer require kasera/kasera-pay, untuk PHP 8.1 ke atas, dan bisa dipakai di aplikasi CakePHP 5 karena CakePHP 5 sendiri membutuhkan PHP 8.1 ke atas. Rinciannya ada di pay.kasera.id/docs/api/sdks. Panduan ini memakai Cake\Http\Client karena sudah terpasang di setiap aplikasi CakePHP, dan karena dua jebakannya (format bawaan dan 4xx yang tidak melempar) yang paling sering membuat integrasi pertama gagal.
Kenapa webhook ditolak 403 padahal tanda tangannya belum diperiksa?
Karena CsrfProtectionMiddleware di template cakephp/app memeriksa setiap POST sebelum controller dipanggil. Pengiriman webhook tidak membawa cookie dan token CSRF, jadi jawabannya 403 dengan pesan Missing or incorrect CSRF cookie type, dan Kasera Pay mengulang pengirimannya sampai tujuh kali dalam sekitar 33 jam. Pasang skipCheckCallback yang hanya mengecualikan controller webhook, bukan mencabut middleware-nya dari seluruh aplikasi.
Kenapa tanda tangan selalu gagal walaupun signing secret-nya benar?
Hampir selalu karena body yang di-hash kosong. BodyParserMiddleware membaca stream body dengan getContents() untuk mengisi getData(), sehingga penunjuk stream sudah di ujung saat controller berjalan, dan getBody()->getContents() di controller mengembalikan string kosong. (string)$this->request->getBody() memutar stream ke awal dan mengembalikan byte persis seperti dikirim.