Waktu gue lagi bangun Toko Topup — platform jual produk digital berbasis PHP/MySQL — gue butuh payment gateway yang support QRIS dan gampang diintegrasikan tanpa harus punya rekening bisnis dulu.
Pilihan jatuh ke Pakasir. Dokumentasinya minimalis, komunitas yang nulis tentang ini hampir nol, jadi gue tulis sendiri pengalaman integrasi ini dari awal sampai production-ready — termasuk bug timezone yang cukup menyebalkan.
Apa itu Pakasir?
Pakasir adalah payment gateway lokal Indonesia yang support QRIS. Cocok untuk developer indie atau UMKM yang butuh solusi pembayaran digital tanpa proses verifikasi yang ribet.
Flow Pembayaran
Sebelum nulis kode, penting untuk paham alurnya dulu:
User checkout -> PHP kirim request ke Pakasir API -> Pakasir return QR code + expired_at -> User scan QR via mobile banking -> Pakasir update status transaksi -> PHP polling untuk cek status -> Order dikonfirmasi otomatis
Prerequisites
- PHP 7.4+
- Extension
curlaktif - Akun Pakasir dan API key dari dashboard
project slugdari dashboard Pakasir
Step 1 — Buat Transaksi QRIS
Endpoint yang dipakai: POST https://app.pakasir.com/api/transactioncreate/qris
Payload yang dikirim:
$payload = [
"project" => $slug_proyek,
"order_id" => $order_id,
"amount" => $amount,
"api_key" => $api_key
];
Kirim Request Ke pakasir
$ch = curl_init('https://app.pakasir.com/api/transactioncreate/qris');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new RuntimeException("Pakasir API error. HTTP Code: $httpCode");
}
$result = json_decode($response, true);
Kenapa curl_setopt_array?
Lebih clean daripada manggil curl_setopt satu per satu. Semua opsi terdefinisi dalam satu blok yang mudah dibaca.
Kenapa CURLOPT_SSL_VERIFYPEER => false?
Untuk menghindari SSL verification error di environment lokal atau shared hosting tertentu. Di production sebaiknya di-set true dan pastikan sertifikat CA server up to date.
Step 2 — Ambil Data dari Response
Setelah request berhasil, Pakasir return JSON dengan struktur seperti ini:
{
"payment": {
"qr_string": "THIS.IS.JUST.AN.EXAMPLE.FOR.SANDBOX.00020101021226610016ID.CO.SHOPEE.WWW01189360091800216005230208216005230303UME51440014ID.CO.QRIS.WWW.1111",
"expired_at": "2026-04-20T02:30:00.1234567890Z"
}
}
Akses field-nya:
$qr_string = $result['payment']['qr_string'];
$expired_at = $result['payment']['expired_at'];
Step 3 — Bug Timezone
Ini bagian yang paling penting dan paling jarang dibahas. expired_at dari Pakasir formatnya seperti ini:
2026-04-20T02:30:00.1234567890Z
Ada dua masalah di sini:
Masalah 1 — Microsecond terlalu panjang PHP DateTime hanya support 6 digit microsecond. Pakasir kadang return lebih dari 6 digit, yang bikin parsing gagal atau hasilnya salah.
Masalah 2 — Timezone UTC, bukan WIB expired_at dalam UTC. Kalau langsung ditampilkan ke user tanpa konversi, jam expiry-nya akan kelihatan 7 jam lebih awal dari seharusnya.
Solusinya:
$rawExpiredAt = $result['payment']['expired_at'];
$cleanExpiredAt = preg_replace('/(\.\d{6})\d+/', '$1', $rawExpiredAt);
$expiredDt = new DateTime($cleanExpiredAt, new DateTimeZone('UTC'));
$expiredDt->setTimezone(new DateTimeZone('Asia/Jakarta'));
$expired = $expiredDt->format('Y-m-d H:i:s');
Breakdown tiap baris:
- preg_replace(’/(.\d{6})\d+/’, ‘$1’, $rawExpiredAt) — potong microsecond ke 6 digit agar PHP bisa parse dengan benar
- new DateTime($cleanExpiredAt, new DateTimeZone(‘UTC’)) — parse string sebagai UTC
- setTimezone(new DateTimeZone(‘Asia/Jakarta’)) — konversi ke WIB
- format(‘Y-m-d H:i:s’) — simpan ke database dalam format standard MySQL
Step 4 — Simpan ke Database
$stmt = $pdo->prepare("
INSERT INTO orders (order_id, amount, qr_string, expired_at, status)
VALUES (:order_id, :amount, :qr_string, :expired_at, 'pending')
");
$stmt->execute([
':order_id' => $order_id,
':amount' => $amount,
':qr_url' => $qr_url,
':expired_at' => $expired
]);
Penting: simpan expired_at dalam WIB yang sudah dikonversi, bukan raw string dari Pakasir. Ini menghindari timezone confusion saat query perbandingan waktu di MySQL.
Step 5 — Tampilkan ke user
date_default_timezone_set('Asia/Jakarta');
$expired_ts = strtotime($trx['expired_at']);
$expired_time_wib = date('H:i', $expired_ts) . ' WIB';
$total_format = 'Rp ' . number_format($trx['amount'], 0, ',', '.');
Hasilnya: jam expiry yang akurat sesuai timezone user di Indonesia.
Kesimpulan
Integrasi Pakasir ke PHP sebenarnya straightforward — hanya 4 field di payload dan satu endpoint. Yang bikin tricky adalah handling expired_at-nya karena:
- Format microsecond yang tidak standard
- Response dalam UTC sementara user kita di WIB
Dua baris preg_replace + setTimezone itu yang menyelamatkan logika expiry di seluruh sistem.