Panduan · 11 September 2026
Integrasi payment gateway di PHP tanpa framework: cURL, hash_hmac, dan webhook yang tidak bisa diputar ulang
Panduan ini memasang Kasera Pay di aplikasi PHP biasa: satu direktori berisi beberapa file, tanpa framework, tanpa Composer, dan tanpa SDK. Yang dipakai hanya ekstensi curl dan json bawaan PHP 8, ditambah hash_hmac dan hash_equals dari inti bahasa. Tidak ada package PHP resmi Kasera Pay, dan tidak ada yang perlu dipasang untuk mengikuti panduan ini.
Kalau aplikasinya berjalan di atas Laravel, pakai panduan integrasi Laravel sebagai gantinya: kontraknya sama, tetapi di sana HTTP client bawaan, konfigurasi services, dan Http::fake() mengerjakan sebagian besar hal ini untuk Anda. Halaman ini untuk kode yang tidak punya framework di bawahnya, termasuk skrip di dalam tema atau plugin dan aplikasi server-rendered yang ditulis sendiri.
Setiap potongan kode di bawah bukan ilustrasi. Semuanya dijalankan pada PHP 8.4 dan diuji oleh 24 pemeriksaan yang ada di bagian akhir halaman, termasuk yang memastikan pengiriman dengan secret salah ditolak, pengiriman lama ditolak, kedua entri v1 diterima saat rotasi secret, dan satu event yang datang dua kali hanya memenuhi pesanan sekali. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay, dan ringkasan metode yang aktif beserta tarifnya di halaman API QRIS untuk developer.
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.
Jalur yang dipakai di sini adalah redirect, yaitu Kasera Pay Checkout yang menampilkan pilihan metode pembayaran di halaman kami. Ini pilihan yang benar untuk hampir semua toko: satu permintaan, tidak ada layar metode pembayaran yang perlu dibangun sendiri, dan metode baru muncul tanpa perubahan kode. Jalur direct, yang meminta satu channel pembayaran tertentu dan menerima string QRIS atau nomor Virtual Account mentah untuk ditampilkan sendiri, dijelaskan di dokumentasi Direct API dan hanya masuk akal bila tampilan pembayarannya memang harus di dalam aplikasi sendiri.
1. Kredensial
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. Keduanya hanya boleh ada di sisi server: jangan pernah menaruhnya di JavaScript, di HTML, atau di file yang bisa diambil lewat URL.
# Simpan di luar document root, atau sebagai environment variable.
# kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...Kalau file konfigurasi ini berada di dalam document root, pastikan web server menolak permintaan ke file tersebut. Cara yang lebih aman adalah menaruhnya satu tingkat di atas document root, atau memasangnya sebagai environment variable di konfigurasi web server. Rinciannya ada di dokumentasi autentikasi.
2. Satu file untuk create dan verifikasi
Dua fungsi ini adalah seluruh sisi klien dari integrasinya. Yang pertama membuat permintaan pembayaran lewat POST /v1/transactions. Yang kedua memverifikasi pengiriman webhook. Tidak ada yang lain.
<?php
// Kasera Pay in plain PHP 8.x. No Composer package, no SDK: the whole
// integration is one POST to create a payment and one signed POST back.
//
// This file is the source of truth for the code shown on
// /panduan/integrasi-payment-gateway-php. app/prose-php-fixture.test.ts asserts
// the page reproduces it verbatim, so the guide cannot drift from code that is
// actually exercised by fixtures/php/test.php.
declare(strict_types=1);
const KASERA_PAY_BASE_URL = 'https://pay.kasera.id';
const KASERA_PAY_TOLERANCE_SECONDS = 300;
/**
* Create a payment request. $idempotencyKey must be stored and reused for
* every retry of the same order: a fresh key per attempt removes the
* protection without any error to say so.
*
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function kasera_pay_create_transaction(
array $payload,
string $idempotencyKey,
string $apiKey,
string $baseUrl = KASERA_PAY_BASE_URL,
): array {
$ch = curl_init($baseUrl . '/v1/transactions');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $apiKey,
'Content-Type: application/json',
'Accept: application/json',
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_THROW_ON_ERROR),
]);
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException('Kasera Pay request failed: ' . $error);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$decoded = json_decode((string) $body, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400) {
throw new RuntimeException(
'Kasera Pay returned ' . $status . ': ' . ($decoded['error']['code'] ?? 'unknown'),
);
}
return $decoded;
}
/**
* Verify a Kasera-Signature-V1 header against the raw request body.
*
* $rawBody must be the bytes that arrived. Re-encoding the parsed array
* produces different bytes for the same data and every signature then fails.
*
* $now is injectable so the tolerance can be tested without waiting.
*/
function kasera_pay_verify_signature(
string $rawBody,
?string $header,
string $secret,
?int $now = null,
int $tolerance = KASERA_PAY_TOLERANCE_SECONDS,
): bool {
if ($header === null || $header === '') {
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;
}
// Rejecting a stale timestamp is what stops a captured delivery from being
// replayed later; without it a valid signature is valid forever.
$now ??= time();
if (abs($now - (int) $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// After a secret rotation the header carries two v1 entries for 24 hours,
// one per secret, so every candidate is checked. No early return: each is
// compared so the time taken does not reveal which entry matched.
$matched = false;
foreach ($signatures as $signature) {
$matched = hash_equals($expected, $signature) || $matched;
}
return $matched;
}Tiga hal di file itu yang paling sering salah. Idempotency-Key harus dibuat sekali per pesanan lalu disimpan, karena hanya header itu yang membuat dua permintaan dianggap satu pembayaran; external_id dan merchant_ref hanya label yang disimpan dan bisa difilter. Toleransi lima menit adalah yang mencegah pengiriman yang tersadap diputar ulang nanti. Dan perbandingan tanda tangan memakai hash_equals, bukan ===, supaya lama perbandingannya tidak bergantung pada seberapa banyak karakter awal yang cocok. Kontrak lengkapnya ada di dokumentasi webhook dan dokumentasi idempotency.
3. Membuat pembayaran
Halaman ini dipanggil dari tombol bayar, menyimpan id permintaan pembayaran pada pesanan, lalu melempar pembeli ke halaman checkout.
<?php
// checkout.php — dipanggil saat pembeli menekan tombol bayar.
declare(strict_types=1);
require __DIR__ . '/kasera_pay.php';
$pdo = new PDO(getenv('DATABASE_DSN'));
$order = $pdo->prepare('SELECT * FROM orders WHERE id = :id');
$order->execute([':id' => (int) $_POST['order_id']]);
$order = $order->fetch(PDO::FETCH_ASSOC);
// Key dibuat sekali lalu disimpan, dan setiap percobaan ulang memakai key yang
// sama. Key baru per percobaan menghapus proteksinya tanpa error apa pun.
if ($order['idempotency_key'] === null) {
$order['idempotency_key'] = bin2hex(random_bytes(16));
$pdo->prepare('UPDATE orders SET idempotency_key = :key WHERE id = :id')
->execute([':key' => $order['idempotency_key'], ':id' => $order['id']]);
}
$transaction = kasera_pay_create_transaction(
[
'amount' => (int) $order['amount'],
'description' => $order['description'],
'external_id' => (string) $order['id'],
'checkout' => ['steps' => ['customer', 'payment_method', 'payment']],
],
$order['idempotency_key'],
getenv('KASERA_PAY_KEY') ?: '',
);
$pdo->prepare('UPDATE orders SET kasera_transaction_id = :trx WHERE id = :id')
->execute([':trx' => $transaction['id'], ':id' => $order['id']]);
header('Location: ' . $transaction['checkout_url'], true, 303);
exit;4. Endpoint webhook
Endpoint didaftarkan di dashboard, menu Developer, maksimal lima per mode dan masing-masing dengan signing secret sendiri. URL-nya wajib https dan mengarah ke alamat publik. Endpoint harus menjawab 2xx; selain itu pengiriman diulang dengan exponential backoff sampai tujuh kali dalam sekitar 33 jam.
<?php
// The webhook endpoint, in plain PHP. Point a Kasera Pay endpoint at the URL
// this file is served from.
//
// Source of truth for the code shown on
// /panduan/integrasi-payment-gateway-php; see kasera_pay.php.
declare(strict_types=1);
require __DIR__ . '/kasera_pay.php';
/**
* Handle one delivery. Returns the HTTP status to send back.
*
* $store is the durable side: record_event() must return false when the event
* id has been seen before. Split out so the same function can be driven by a
* database in production and by an array in the tests.
*/
function kasera_pay_handle_webhook(
string $rawBody,
?string $signatureHeader,
?string $eventIdHeader,
string $secret,
KaseraPayEventStore $store,
?callable $fulfil = null,
): int {
if (! kasera_pay_verify_signature($rawBody, $signatureHeader, $secret)) {
return 400;
}
$event = json_decode($rawBody, true);
if (! is_array($event)) {
return 400;
}
$eventId = $event['id'] ?? $eventIdHeader;
if (! is_string($eventId) || $eventId === '') {
return 400;
}
// Delivery is at-least-once, so the same id can arrive more than once, and
// two deliveries can arrive at the same moment. The unique index inside
// record_event() is the arbiter, not a SELECT taken beforehand.
$firstDelivery = $store->record($eventId);
if ($firstDelivery && ($event['type'] ?? null) === 'payment.paid' && $fulfil !== null) {
$fulfil($event['data'] ?? []);
}
// Anything but 2xx is retried with exponential backoff, up to seven
// attempts across about 33 hours.
return 200;
}
interface KaseraPayEventStore
{
/** True the first time this event id is recorded, false on every repeat. */
public function record(string $eventId): bool;
}
/** Postgres or MySQL: the UNIQUE constraint on event_id is what deduplicates. */
final class PdoEventStore implements KaseraPayEventStore
{
public function __construct(private readonly PDO $pdo) {}
public function record(string $eventId): bool
{
// CREATE TABLE kasera_pay_events (
// event_id VARCHAR(255) NOT NULL UNIQUE,
// created_at TIMESTAMP NOT NULL
// );
$statement = $this->pdo->prepare(
'INSERT INTO kasera_pay_events (event_id, created_at)
VALUES (:id, NOW()) ON CONFLICT (event_id) DO NOTHING',
);
$statement->execute([':id' => $eventId]);
return $statement->rowCount() === 1;
}
}
// Entry point when this file is served directly by a web server.
if (PHP_SAPI !== 'cli' && basename((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
$store = new PdoEventStore(new PDO(getenv('DATABASE_DSN') ?: ''));
$status = kasera_pay_handle_webhook(
// The bytes that arrived. json_decode then json_encode gives different
// bytes for the same data, and the signature no longer matches.
file_get_contents('php://input') ?: '',
$_SERVER['HTTP_KASERA_SIGNATURE_V1'] ?? null,
$_SERVER['HTTP_KASERA_EVENT_ID'] ?? null,
getenv('KASERA_PAY_WEBHOOK_SECRET') ?: '',
$store,
static function (array $data) use ($store): void {
// Whatever fulfilment means here: ship it, grant access, send the
// download link. This is the side effect that must not happen twice.
unset($store);
error_log('paid: ' . ($data['payment_request_id'] ?? ''));
},
);
http_response_code($status);
}Dedupe-nya bersandar pada UNIQUE di kolom event_id, bukan pada SELECT yang dijalankan lebih dulu. Pengiriman bersifat at-least-once dan dua pengiriman event yang sama bisa tiba bersamaan, sehingga membaca dulu lalu menulis menyisakan celah yang keduanya lewati. Yang menjadi penentu adalah hasil INSERT itu sendiri.
Perhatikan juga bahwa status pembayaran yang dipakai di sini hanya payment.paid. Status permintaan pembayaran sendiri punya lima nilai, di antaranya pending, succeeded, dan expired; daftarnya ada di dokumentasi ambil permintaan. Jangan menyimpulkan status dari event yang tidak dikirim.
5. Menjalankan test
File ini tidak membutuhkan PHPUnit maupun Composer. Jalankan php test.php dan kode keluarnya adalah jawabannya.
<?php
// Runnable tests for the PHP example on
// /panduan/integrasi-payment-gateway-php. No Composer, no PHPUnit: `php
// fixtures/php/test.php` and the exit code is the answer.
//
// Every one of these was watched go red against deliberately broken code
// before being kept — see the notes on each.
declare(strict_types=1);
require __DIR__ . '/webhook.php';
const SECRET = 'whsec_example';
const ROTATED_SECRET = 'whsec_rotated';
$failures = [];
$passed = 0;
function check(string $name, bool $ok): void
{
global $failures, $passed;
if ($ok) {
$passed++;
return;
}
$failures[] = $name;
}
function sign(string $body, string $secret = SECRET, ?int $t = null): string
{
$t ??= time();
return 't=' . $t . ',v1=' . hash_hmac('sha256', $t . '.' . $body, $secret);
}
/** In-memory stand-in for the UNIQUE index. */
final class ArrayEventStore implements KaseraPayEventStore
{
/** @var array<string, true> */
private array $seen = [];
public function record(string $eventId): bool
{
if (isset($this->seen[$eventId])) {
return false;
}
$this->seen[$eventId] = true;
return true;
}
}
$body = json_encode([
'id' => 'evt_1',
'type' => 'payment.paid',
'livemode' => false,
'data' => ['payment_request_id' => 'payreq_9b2f', 'amount' => 150000],
], JSON_THROW_ON_ERROR);
// --- signature verification -------------------------------------------------
check(
'a correctly signed delivery verifies',
kasera_pay_verify_signature($body, sign($body), SECRET),
);
// Red when hash_equals is replaced by `true`, or when the secret is ignored.
check(
'a delivery signed with the wrong secret is rejected',
! kasera_pay_verify_signature($body, sign($body, 'whsec_wrong'), SECRET),
);
// Red when the tolerance check is removed: a captured delivery replays forever.
check(
'a delivery older than the five-minute tolerance is rejected',
! kasera_pay_verify_signature($body, sign($body, SECRET, time() - 400), SECRET),
);
check(
'a delivery timestamped far in the future is rejected',
! kasera_pay_verify_signature($body, sign($body, SECRET, time() + 400), SECRET),
);
check(
'a delivery just inside the tolerance is accepted',
kasera_pay_verify_signature($body, sign($body, SECRET, time() - 299), SECRET),
);
// Red when the loop returns on the first mismatch instead of checking every
// entry: after a rotation the old secret's entry comes first and the new one
// is never reached.
$t = time();
$rotating = 't=' . $t
. ',v1=' . hash_hmac('sha256', $t . '.' . $body, SECRET)
. ',v1=' . hash_hmac('sha256', $t . '.' . $body, ROTATED_SECRET);
check(
'during rotation the first of two v1 entries verifies',
kasera_pay_verify_signature($body, $rotating, SECRET),
);
check(
'during rotation the second of two v1 entries verifies',
kasera_pay_verify_signature($body, $rotating, ROTATED_SECRET),
);
check(
'during rotation a third secret still fails',
! kasera_pay_verify_signature($body, $rotating, 'whsec_neither'),
);
check('a missing header is rejected', ! kasera_pay_verify_signature($body, null, SECRET));
check('an empty header is rejected', ! kasera_pay_verify_signature($body, '', SECRET));
check(
'a header with a timestamp and no signature is rejected',
! kasera_pay_verify_signature($body, 't=' . time(), SECRET),
);
check(
'a header with a signature and no timestamp is rejected',
! kasera_pay_verify_signature($body, 'v1=' . hash_hmac('sha256', 'x', SECRET), SECRET),
);
check(
'a non-numeric timestamp is rejected',
! kasera_pay_verify_signature($body, 't=soon,v1=abc', SECRET),
);
// The failure this example exists to prevent: signing the re-encoded array
// rather than the bytes that arrived. json_encode cannot produce this spacing,
// so a controller that re-encodes before verifying goes red here and stays
// green if the test body is built with json_encode.
$spaced = '{"id":"evt_spaced", "type":"payment.paid","data":{}}';
check(
'verification uses the raw bytes, not a re-encoded payload',
kasera_pay_verify_signature($spaced, sign($spaced), SECRET)
&& ! kasera_pay_verify_signature(
json_encode(json_decode($spaced, true), JSON_THROW_ON_ERROR),
sign($spaced),
SECRET,
),
);
// --- the endpoint -----------------------------------------------------------
$store = new ArrayEventStore();
$fulfilled = [];
$fulfil = static function (array $data) use (&$fulfilled): void {
$fulfilled[] = $data['payment_request_id'] ?? '';
};
check(
'a valid delivery returns 200',
kasera_pay_handle_webhook($body, sign($body), null, SECRET, $store, $fulfil) === 200,
);
check('the first delivery fulfils once', $fulfilled === ['payreq_9b2f']);
// Red when the dedupe is removed. Counting fulfilments rather than reading the
// final status is the point: an order reads "paid" whether it shipped once or
// twice, so a test that asserts the end state passes against the bug.
check(
'a repeated delivery of the same event id still returns 200',
kasera_pay_handle_webhook($body, sign($body), null, SECRET, $store, $fulfil) === 200,
);
check('a repeated delivery does not fulfil twice', $fulfilled === ['payreq_9b2f']);
$second = json_encode(
['id' => 'evt_2', 'type' => 'payment.paid', 'data' => ['payment_request_id' => 'payreq_aa01']],
JSON_THROW_ON_ERROR,
);
check(
'a different event id does fulfil',
kasera_pay_handle_webhook($second, sign($second), null, SECRET, $store, $fulfil) === 200
&& $fulfilled === ['payreq_9b2f', 'payreq_aa01'],
);
check(
'a badly signed delivery returns 400 and does not fulfil',
kasera_pay_handle_webhook($body, sign($body, 'whsec_wrong'), null, SECRET, $store, $fulfil) === 400
&& count($fulfilled) === 2,
);
$other = json_encode(['id' => 'evt_3', 'type' => 'payment.expired', 'data' => []], JSON_THROW_ON_ERROR);
check(
'an event of another type returns 200 and does not fulfil',
kasera_pay_handle_webhook($other, sign($other), null, SECRET, $store, $fulfil) === 200
&& count($fulfilled) === 2,
);
// This one goes through the endpoint, and it has to. The check above drives
// kasera_pay_verify_signature directly, so a controller that re-encodes the
// body before verifying leaves it green: every other body here is built with
// json_encode, and re-encoding those reproduces identical bytes. Delivering
// spacing json_encode cannot produce is what makes the mistake observable.
$spacedEvent = '{"id":"evt_raw", "type":"payment.paid","data":{"payment_request_id":"payreq_raw"}}';
check(
'the endpoint verifies the bytes that arrived, not a re-encoded payload',
kasera_pay_handle_webhook($spacedEvent, sign($spacedEvent), null, SECRET, $store, $fulfil) === 200
&& $fulfilled === ['payreq_9b2f', 'payreq_aa01', 'payreq_raw'],
);
$noId = json_encode(['type' => 'payment.paid', 'data' => []], JSON_THROW_ON_ERROR);
$before = count($fulfilled);
check(
'a payload with no id falls back to the Kasera-Event-Id header',
kasera_pay_handle_webhook($noId, sign($noId), 'evt_header', SECRET, $store, $fulfil) === 200
&& count($fulfilled) === $before + 1,
);
check(
'a payload with no id anywhere is rejected',
kasera_pay_handle_webhook($noId, sign($noId), null, SECRET, $store, $fulfil) === 400,
);
// --- report -----------------------------------------------------------------
if ($failures === []) {
echo "ok — {$passed} checks passed\n";
exit(0);
}
echo "FAILED:\n";
foreach ($failures as $name) {
echo " - {$name}\n";
}
echo count($failures) . ' of ' . ($passed + count($failures)) . " checks failed\n";
exit(1);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 dua test pengiriman lama; membuat loop rotasi berhenti pada entri pertama harus menjatuhkan test entri kedua; menghapus dedupe harus menjatuhkan test pengiriman ganda; dan meng-encode ulang body sebelum memverifikasi harus menjatuhkan test body mentah. Yang terakhir itu perlu dikirim lewat kasera_pay_handle_webhook dengan spasi yang tidak mungkin dihasilkan json_encode: memanggil fungsi verifikasinya langsung membuat test itu tetap hijau meski kodenya sudah rusak.
6. Menguji ujung ke ujung
Dengan key kp_test_, buat satu pembayaran, buka checkout_url-nya, lalu konfirmasi sendiri. Pembayaran mode tes tidak pernah terkonfirmasi sendiri.
# Pembayaran mode tes tidak pernah terkonfirmasi sendiri. Token-nya diambil
# dari bagian akhir checkout_url: https://pay.kasera.id/p/<token>
curl -X POST https://pay.kasera.id/api/p/<token>/simulate-paymentSetelah itu periksa empat hal: endpoint webhook menerima satu payment.paid, tanda tangannya lolos, pesanan berubah menjadi terbayar satu kali, dan mengirim ulang event yang sama tidak memenuhi pesanan untuk kedua kalinya. Untuk menerima webhook di mesin sendiri selama membangun, lihat webhook di localhost dan mode tes, dan untuk seluruh perilaku mode tes, dokumentasi mode tes.
Kesalahan yang paling sering muncul
- Menaruh API key di kode yang dikirim ke browser. Key ini memberi akses penuh ke akun merchant; satu-satunya tempatnya adalah sisi server.
- 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.
- Berhenti pada entri
v1pertama. Selama 24 jam setelah rotasi secret, separuh pengiriman akan ditolak. - 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 kode error yang bisa muncul dari create, daftar error. Toko WooCommerce tidak perlu menulis kode ini sama sekali dan cukup memasang plugin WooCommerce. Kalau yang dibutuhkan hanya menagih tanpa integrasi apa pun, tautan pembayaran Kasera Pay menyelesaikannya.
Pertanyaan yang sering muncul
Apakah ada SDK atau package Composer resmi Kasera Pay untuk PHP?
Tidak ada, dan panduan ini tidak membutuhkannya. Integrasinya adalah satu permintaan HTTP untuk membuat pembayaran dan satu permintaan masuk yang ditandatangani. Yang dipakai hanya ekstensi curl dan json bawaan PHP, ditambah hash_hmac dan hash_equals. Jangan memasang package pihak ketiga yang mengaku sebagai SDK Kasera Pay; tidak ada yang resmi.
Apa bedanya dengan panduan Laravel?
Kontraknya sama persis: endpoint, header, format tanda tangan, dan aturan dedupe tidak berubah. Yang berbeda hanya perkakasnya. Panduan Laravel memakai facade Http, konfigurasi services, pengecualian CSRF di bootstrap/app.php, dan feature test dengan Http::fake(). Panduan ini tidak mengandaikan framework apa pun, jadi cURL dipanggil langsung, rahasianya dibaca dari environment, dan test-nya dijalankan dengan satu perintah php. Untuk aplikasi Laravel, pakai panduan Laravel.
Kenapa tanda tangan harus diverifikasi atas body mentah?
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 PHP polos, ambil dengan file_get_contents('php://input'), lalu json_decode hanya setelah tanda tangannya lolos.
Kenapa header membawa dua entri v1 setelah rotasi secret?
Supaya rotasi tidak perlu downtime. Selama 24 jam setelah rotasi, setiap pengiriman ditandatangani dengan secret lama dan secret baru sekaligus, satu entri v1 untuk masing-masing. Verifikasi yang berhenti pada entri pertama akan menolak separuh pengiriman selama jendela itu, jadi setiap entri harus dibandingkan dan pengiriman diterima bila salah satunya cocok.
Ekstensi PHP apa saja yang dibutuhkan?
curl dan json, keduanya lazim aktif pada instalasi PHP 8. hash_hmac dan hash_equals ada di inti bahasa dan tidak butuh ekstensi. Contoh dedupe memakai PDO dengan driver database yang dipakai. Tidak ada kebutuhan lain.
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.