Panduan · Terbit
Integrasi payment gateway di Slim 4: SDK PHP resmi, body webhook yang hanya utuh kalau implementasi PSR-7-nya tepat, dan Guard CSRF yang menolak setiap pengiriman
Panduan ini memasang Kasera Pay di aplikasi Slim 4 dengan SDK PHP resmi (kasera/kasera-pay) untuk memanggil API dan memeriksa tanda tangan webhook, PDO untuk PostgreSQL, dan tidak ada komponen lain. Semua kode di bawah dijalankan pada slim/slim 4.15.3, slim/psr7 1.8.0, kasera/kasera-pay 0.1.0, PHP 8.3, dan PostgreSQL 16 sebelum ditulis di sini, termasuk uji 30 pengiriman webhook kembar bersamaan. Kontrak API-nya sama dengan integrasi payment gateway di PHP tanpa framework; yang berbeda adalah tempat Slim bisa merusaknya tanpa pesan yang menunjuk ke penyebabnya:
- Body webhook mentah hanya utuh lewat
getBody()->getContents()kalau implementasi PSR-7-nya kebetulanslim/psr7. Dengannyholm/psr7, setiap pengiriman ditolak 400. Guarddarislim/csrfyang dipasang di seluruh aplikasi menjawab setiap webhook dengan 400Failed CSRF check!.addErrorMiddleware(true, ...)menampilkan pesan error API dan nama kelas SDK ke pembeli.- Tabel event tanpa
ON CONFLICTmenjawab setiap pengiriman ulang dengan 500.
Alurnya, sebelum menulis kode
Pembeli membuka /checkout/pay/{id}. Action membuat permintaan pembayaran lewat SDK dengan kunci idempotency milik pesanan, lalu mengalihkan pembeli ke checkout_url. Setelah pembeli membayar, Kasera Pay mengirim event payment.paid ke /webhook/kasera-pay, dan hanya event itu yang menandai pesanan lunas. Halaman return_url sekadar menampilkan status; alasannya dibahas di perbandingan webhook dan halaman kembali sebagai pemicu.
composer require slim/slim:^4 slim/psr7 kasera/kasera-pay1. Aplikasi dan urutan middleware
<?php
// public/index.php
declare(strict_types=1);
use App\CheckoutAction;
use App\KaseraPayWebhookAction;
use Kasera\Pay\Client;
use Slim\Factory\AppFactory;
require __DIR__ . '/../vendor/autoload.php';
// Nilainya diisi di server (atau .env yang tidak masuk git), tidak pernah di kode.
// kp_test_ selama membangun, kp_live_ setelah go-live.
$apiKey = getenv('KASERAPAY_API_KEY') ?: '';
$webhookSecret = getenv('KASERAPAY_WEBHOOK_SECRET') ?: '';
$baseUrl = getenv('KASERAPAY_BASE_URL') ?: 'https://pay.kasera.id';
$appUrl = getenv('APP_URL') ?: ''; // https://toko.example, untuk return_url
$db = new PDO(getenv('DATABASE_DSN') ?: '', getenv('DATABASE_USER') ?: null, getenv('DATABASE_PASSWORD') ?: null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$app = AppFactory::create();
// Mengurai body JSON untuk route lain. Route webhook tetap membaca body mentah
// lewat (string)$request->getBody(), bukan getParsedBody().
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
// Argumen pertama false: jangan tampilkan detail exception ke pengunjung.
$app->addErrorMiddleware(false, true, true);
$app->get('/checkout/pay/{id}', new CheckoutAction($db, new Client($apiKey, $baseUrl), $appUrl));
$app->post('/webhook/kasera-pay', new KaseraPayWebhookAction($db, $webhookSecret));
$app->run();Kredensial dibaca dari variabel lingkungan, jadi kp_test_ dan kp_live_ berganti tanpa mengubah kode. APP_URL dipakai untuk return_url: create dengan live key hanya menerima URL https, sedangkan test key juga menerima http untuk pengembangan di localhost.
Argumen pertama addErrorMiddleware menentukan apa yang dilihat pembeli saat create gagal. Dalam uji dengan nominal di bawah minimum, nilai false menampilkan halaman “Slim Application Error” polos dan menulis jejak lengkap, termasuk Kasera\Pay\Client->createTransaction(), ke log server. Nilai true menampilkan ApiException beserta pesan dari API langsung di peramban pembeli.
2. Action checkout: kunci idempotency milik pesanan
<?php
// src/CheckoutAction.php
declare(strict_types=1);
namespace App;
use Kasera\Pay\Client;
use PDO;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Exception\HttpNotFoundException;
final class CheckoutAction
{
public function __construct(
private PDO $db,
private Client $kasera,
private string $appUrl,
) {}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
// Kunci idempotency milik PESANAN, dibuat sekali lalu disimpan. UPDATE
// bersyarat: dua klik bersamaan sama-sama melihat kolom kosong, tetapi
// hanya satu yang mengisinya, dan keduanya lalu membaca kunci yang sama.
$this->db->prepare(
'UPDATE orders SET kasera_idempotency_key = ? WHERE id = ? AND kasera_idempotency_key IS NULL'
)->execute([bin2hex(random_bytes(16)), $args['id']]);
$stmt = $this->db->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$args['id']]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if ($order === false) {
throw new HttpNotFoundException($request);
}
// ApiException dari SDK (4xx, 5xx, atau status 0 network_error) dibiarkan
// naik ke ErrorMiddleware: pembeli melihat 500 polos, detailnya masuk log.
$payment = $this->kasera->createTransaction([
'amount' => (int) $order['amount'],
'description' => 'Pesanan ' . $order['number'],
'external_id' => $order['number'],
'customer' => ['name' => $order['customer_name']],
'return_url' => $this->appUrl . '/checkout/selesai/' . $order['id'],
], $order['kasera_idempotency_key']);
$this->db->prepare('UPDATE orders SET kasera_payment_id = ? WHERE id = ?')
->execute([$payment['id'], $order['id']]);
return $response->withStatus(302)->withHeader('Location', $payment['checkout_url']);
}
}README SDK menyarankan id pesanan sebagai kunci idempotency, dan itu aman selama isi pesanan tidak pernah berubah. Kunci di Kasera Pay tidak kedaluwarsa dan terikat pada body persis byte per byte, jadi pesanan yang nominalnya diubah lalu dikirim lagi dengan kunci yang sama dijawab 409 idempotency_conflict. Kunci acak yang disimpan di baris pesanan menghindari itu, dan UPDATE bersyarat membuat klik ganda memakai kunci yang sama. Dalam uji terhadap API palsu, 10 permintaan bersamaan ke pesanan yang sama mengirim 10 create dengan satu kunci yang identik; API yang asli menjawab kunci yang sudah terpakai dengan permintaan pembayaran yang pertama. Aturan lengkapnya ada di dokumentasi idempotency.
SDK melempar ApiException untuk setiap jawaban di luar 2xx, dengan status, errorCode, dan requestId. Kegagalan jaringan juga dilempar sebagai ApiException dengan status 0 dan kode network_error. SDK tidak mengulang request sendiri; kalau pengulangan dibutuhkan, ulangi dengan kunci yang sama.
3. Tabel event
-- db/schema.sql
CREATE TABLE orders (
id bigserial PRIMARY KEY,
number text NOT NULL UNIQUE,
amount bigint NOT NULL,
customer_name text NOT NULL,
status text NOT NULL DEFAULT 'pending',
paid_at timestamptz,
kasera_idempotency_key text,
kasera_payment_id text
);
-- Primary key inilah yang mengerjakan dedupe. SELECT lebih dulu lalu INSERT
-- tidak menahan dua pengiriman kembar yang datang bersamaan.
CREATE TABLE kasera_pay_events (
event_id text PRIMARY KEY,
received_at timestamptz NOT NULL DEFAULT now()
);4. Action webhook: body mentah, lalu SDK
<?php
// src/KaseraPayWebhookAction.php
declare(strict_types=1);
namespace App;
use Kasera\Pay\SignatureException;
use Kasera\Pay\Webhook;
use PDO;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RuntimeException;
use Throwable;
final class KaseraPayWebhookAction
{
public function __construct(
private PDO $db,
private string $secret,
) {}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
// (string) memutar stream ke awal lalu membaca seluruhnya. Sesudah
// BodyParsingMiddleware, getBody()->getContents() mengembalikan string
// kosong, dan json_encode(getParsedBody()) menghasilkan byte yang lain.
$raw = (string) $request->getBody();
try {
$event = Webhook::constructEvent($raw, $request->getHeaderLine('Kasera-Signature-V1'), $this->secret);
} catch (SignatureException) {
return $this->json($response, 400, ['error' => 'bad signature']);
}
try {
// Catatan event dan pemenuhan pesanan ada di satu transaksi. Kalau
// fulfil() melempar, id event ikut batal dan jawabannya 500, jadi
// pengiriman ulang memenuhi pesanannya, bukan dijawab 200 sebagai duplikat.
$this->db->beginTransaction();
$insert = $this->db->prepare(
'INSERT INTO kasera_pay_events (event_id) VALUES (?) ON CONFLICT (event_id) DO NOTHING'
);
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1 && ($event['type'] ?? '') === 'payment.paid') {
$this->fulfil($event['data']);
}
$this->db->commit();
} catch (Throwable $e) {
if ($this->db->inTransaction()) {
$this->db->rollBack();
}
error_log('kasera pay webhook: ' . $e->getMessage());
return $this->json($response, 500, ['ok' => false]);
}
// Duplikat tetap dijawab 200: yang tidak boleh terjadi dua kali adalah
// pemenuhan pesanannya, bukan jawabannya.
return $this->json($response, 200, ['ok' => true]);
}
private function fulfil(array $payment): void
{
$stmt = $this->db->prepare('SELECT id, amount, status FROM orders WHERE number = ? FOR UPDATE');
$stmt->execute([$payment['external_id'] ?? '']);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
// Melempar di sini menjawab 500, jadi Kasera Pay mengirim ulang dan
// masalahnya terlihat di log, bukan hilang diam-diam.
if ($order === false) {
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') {
error_log('pembayaran kedua untuk pesanan lunas ' . $order['id']);
return;
}
$update = $this->db->prepare("UPDATE orders SET status = 'paid', paid_at = ? WHERE id = ? AND status <> 'paid'");
$update->execute([$payment['paid_at'], $order['id']]);
if ($update->rowCount() !== 1) {
throw new RuntimeException('pemenuhan mengenai ' . $update->rowCount() . ' baris');
}
}
private function json(ResponseInterface $response, int $status, array $body): ResponseInterface
{
$response->getBody()->write((string) json_encode($body));
return $response->withStatus($status)->withHeader('Content-Type', 'application/json');
}
}Webhook::constructEvent() dari SDK membaca header Kasera-Signature-V1, menolak pengiriman yang selisih waktunya lebih dari 300 detik, menerima salah satu dari dua entri v1 selama rotasi signing secret, dan membandingkan tanda tangan dengan hash_equals. Satu-satunya yang harus disediakan aplikasi adalah body persis seperti yang dikirim, dan di situlah Slim punya tiga jalan yang hasilnya berbeda.
| Cara membaca body | slim/psr7 1.8.0 | nyholm/psr7 1.8.2 |
|---|---|---|
(string) $request->getBody() | 200 | 200 |
$request->getBody()->getContents() | 200 | 400, body kosong |
json_encode($request->getParsedBody()) | 400 | 400 |
Baris kedua adalah jebakan yang paling mahal, karena lolos selama slim/psr7 yang terpasang, lalu gagal tanpa perubahan satu baris pun di action setelah seseorang mengganti paket PSR-7. Penyebabnya: BodyParsingMiddleware membaca seluruh stream untuk mengisi getParsedBody(). slim/psr7 menyalin php://input ke cache dan mengembalikan salinan itu dari getContents() berikutnya; nyholm/psr7 mengembalikan sisa stream, yang sudah kosong. Cast (string) memutar stream ke awal di kedua implementasi.
Baris ketiga gagal di mana pun. Body yang memuat INV/2026/10/001 ditulis ulang oleh json_encode menjadi INV\/2026\/10\/001, dan & dari JSON pengirim kembali sebagai &. Byte-nya berbeda, jadi HMAC-nya berbeda.
5. Dedupe dan pemenuhan pesanan dalam satu transaksi
Pengiriman webhook bersifat at-least-once, dan Kasera Pay mengulang pengiriman yang tidak dijawab 2xx sampai tujuh kali dalam sekitar 33 jam, seperti dijelaskan di referensi webhook. Action di atas mencatat id event dan memenuhi pesanan dalam satu transaksi: kalau pemenuhan gagal, id event ikut batal, jawabannya 500, dan pengiriman berikutnya mencoba lagi.
Hasil uji dengan server php -S 16 worker: 30 pengiriman kembar bersamaan dijawab 30 kali 200, tabel event berisi satu baris, dan pesanan lunas sekali, tiga putaran berturut-turut. Setelah ON CONFLICT (event_id) DO NOTHING dihapus, tiga pengiriman berurutan event yang sama dijawab 200, 500, 500 karena primary key menolak baris kedua; pesanannya tetap terpenuhi sekali, tetapi setiap pengiriman ulang tercatat gagal di log pengiriman dashboard dan terus diulang.
6. Kalau slim/csrf dipakai
Slim tidak membawa proteksi CSRF bawaan. Aplikasi yang memasang slim/csrf dengan $app->add($guard) menolak setiap webhook dengan 400 Failed CSRF check! sebelum action dipanggil, karena pengirimnya server tanpa sesi. Pasang Guard di grup route peramban saja:
// public/index.php, kalau slim/csrf dipakai untuk formulir di aplikasi yang sama
use Slim\Routing\RouteCollectorProxy;
session_start();
$csrf = new \Slim\Csrf\Guard($app->getResponseFactory());
// Guard hanya untuk route yang dipakai peramban. Route webhook di luar grup.
$app->group('', function (RouteCollectorProxy $group) use ($db, $apiKey, $baseUrl, $appUrl): void {
$group->get('/checkout/pay/{id}', new CheckoutAction($db, new Client($apiKey, $baseUrl), $appUrl));
// ... route formulir lain
})->add($csrf);
$app->post('/webhook/kasera-pay', new KaseraPayWebhookAction($db, $webhookSecret));Dengan susunan ini, dalam uji, webhook bertanda tangan dijawab 200 dan POST formulir tanpa token tetap dijawab 400. Penggantinya untuk route webhook adalah pemeriksaan tanda tangan, bukan tidak ada pemeriksaan sama sekali.
7. Mengujinya
Jalankan aplikasi dengan PHP_CLI_SERVER_WORKERS=16 php -S localhost:8080 -t public public/index.php, buat satu pesanan bernomor INV/2026/10/001 seharga Rp 150.000, lalu kirim webhook bertanda tangan buatan sendiri:
# kirim-webhook.sh: menandatangani body sendiri lalu mengirimnya ke php -S.
# Body memuat garis miring dan & seperti JSON yang dikirim Kasera Pay.
SECRET=whsec_... # signing secret mode tes dari dashboard
BODY='{"id":"evt_1","type":"payment.paid","data":{"external_id":"INV/2026/10/001","amount":150000,"description":"Budi & Sari","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:8080/webhook/kasera-pay \
-H 'Content-Type: application/json' \
-H "Kasera-Signature-V1: t=$T,v1=$SIG" \
--data-raw "$BODY"Pengiriman pertama dan kedua harus dijawab 200, dengan satu baris di tabel event. Ganti SECRET dengan nilai lain, atau kurangi T 600 detik, dan jawabannya harus 400. Lalu rusak kodenya satu per satu dan pastikan yang merah memang yang seharusnya: json_encode($request->getParsedBody()) harus menghasilkan 400, dan menghapus ON CONFLICT harus membuat pengiriman kedua dijawab 500. Mulai ulang php -S setelah setiap perubahan, karena worker yang masih berjalan bisa menyajikan kode lama dari opcache dan membuat mutasi tampak lolos. Untuk menguji alur pembayaran dari ujung ke ujung tanpa uang sungguhan, pakai mode tes.
Selanjutnya
Sebelum menukar kp_test_ dengan kp_live_, jalankan checklist sebelum go-live. Untuk proyek PHP lain di tim yang sama, panduan CakePHP dan panduan Symfony membahas jebakan yang setara di kerangka masing-masing.
Pertanyaan yang sering muncul
Apakah SDK PHP resmi bisa dipakai di Slim 4?
Bisa. kasera/kasera-pay membutuhkan PHP 8.1 ke atas dan ekstensi curl dan json, tanpa dependensi framework, jadi cukup composer require kasera/kasera-pay. Paketnya membungkus create dan baca transaksi serta verifikasi tanda tangan webhook. SDK tidak mengulang request sendiri dan tidak punya method untuk endpoint Langganan; rinciannya ada di pay.kasera.id/docs/api/sdks.
Kenapa tanda tangan webhook lolos di laptop tetapi gagal di server?
Paling sering karena implementasi PSR-7-nya berbeda. AppFactory memakai implementasi PSR-7 apa pun yang terpasang. slim/psr7 menyimpan salinan php://input, sehingga getBody()->getContents() tetap mengembalikan body walaupun BodyParsingMiddleware sudah membacanya. nyholm/psr7 tidak, dan getContents() di titik yang sama mengembalikan string kosong. (string)$request->getBody() memutar stream ke awal dan benar di keduanya.
Kenapa webhook dijawab 400 Failed CSRF check!?
Karena Guard dari slim/csrf dipasang di seluruh aplikasi dengan $app->add(). Pengiriman webhook datang dari server Kasera Pay tanpa sesi dan tanpa token, jadi Guard menolaknya sebelum action dipanggil, dan Kasera Pay mengulang pengirimannya sampai tujuh kali dalam sekitar 33 jam. Pasang Guard di grup route yang dipakai peramban, lalu daftarkan route webhook di luar grup itu.