Panduan · Terbit
Integrasi payment gateway di Vue.js: kunci API yang tidak boleh berawalan VITE_, proxy Vite yang hilang setelah build, dan fallback history mode yang tidak boleh menelan webhook
Aplikasi Vue berjalan di peramban pembeli, jadi Kasera Pay tidak pernah dipanggil dari komponen. Komponen memanggil backend sendiri, dan backend itulah yang memegang kunci API, membuat permintaan pembayaran, dan menerima webhook. Panduan ini memakai server Express kecil yang sekaligus menyajikan hasil vite build, sehingga aplikasi dan API berada di satu origin: tanpa CORS, dan tanpa proxy di produksi. Lima hal khas Vue menentukan berhasil atau tidaknya:
- kunci API tidak pernah diberi awalan
VITE_, karena awalan itu berarti “tanam di bundel browser”; server.proxydivite.config.jshanya ada saat pengembangan;createWebHistorymembuatreturn_urlbutuh fallback di server;- fallback itu hanya untuk GET dan dipasang paling akhir, supaya webhook tidak tertelan;
- halaman kembali membaca status dari backend, bukan dari query string.
Semua kode di bawah dijalankan saat panduan ini ditulis pada Vue 3.5.44, vue-router 5.4.0, Vite 8.3.5, @vitejs/plugin-vue 6.0.10, Express 5.3.0, pg 8.23.1, Postgres 16.15, Node 22.22.0, dan SDK JavaScript resmi kasera-pay 0.1.0, dengan API Kasera Pay digantikan server tiruan lewat opsi baseUrl SDK, pengiriman webhook yang ditandatangani persis seperti server Kasera Pay, dan halaman yang dibuka di Chromium. Yang belum diuji: API Kasera Pay yang asli. Kalau aplikasinya memakai Nuxt, server route-nya sudah tersedia dan bentuknya ada di panduan Nuxt.
Alurnya, sebelum menulis kode
Halaman /pesanan/:id memanggil POST /api/bayar dengan id pesanan. Server membaca total dari tabel orders, membuat permintaan pembayaran, dan mengembalikan checkout_url. Pembeli membayar dengan QRIS atau Virtual Account tujuh bank. Kasera Pay mengirim payment.paid bertanda tangan ke /kasera-webhook, dan server menandai pesanan lunas. Pembeli kembali ke return_url, yaitu halaman Vue yang sama, dan halaman itu menanyakan status pesanan ke GET /api/pesanan/:id. Status permintaan pembayaran antara lain pending, succeeded, dan expired; payment.paid adalah nama event-nya, bukan status.
1. Paket, tabel, dan tempat rahasia disimpan
# Diuji pada Node 22.22.0. package.json berisi "type": "module".
npm install vue@3.5.44 vue-router@5.4.0 express@5.3.0 kasera-pay@0.1.0 pg@8.23.1
npm install -D vite@8.3.5 @vitejs/plugin-vue@6.0.10
psql "$DATABASE_URL" -f schema.sql
# Build tidak membutuhkan secret apa pun.
npx vite build
# Secret hanya diberikan pada proses server.
DATABASE_URL=postgres://... \
KASERA_PAY_KEY=kp_test_... \
KASERA_PAY_WEBHOOK_SECRET=whsec_... \
PUBLIC_URL=https://toko.example \
node server/index.jsCREATE TABLE orders (
id text PRIMARY KEY,
total bigint NOT NULL CHECK (total > 0), -- rupiah utuh
status text NOT NULL DEFAULT 'BARU',
attempt int NOT NULL DEFAULT 0,
payment_request_id text,
paid_at timestamptz
);
CREATE TABLE kasera_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Vite hanya membuka variabel berawalan VITE_ ke kode aplikasi, dan nilainya ditanam saat build. Pada pengujian, file .env berisi VITE_KASERA_PAY_KEY=kp_test_... dan KASERA_PAY_KEY=kp_test_..., keduanya dibaca dari komponen. Yang pertama muncul utuh di dist/assets/index-*.js; yang kedua bernilai undefined di kode Vue dan tidak ada di bundel. Dua jalan pintas yang terlihat wajar ternyata sama buruknya: envPrefix: '' ditolak Vite 8 dengan pesan bahwa nilai kosong “could lead unexpected exposure of sensitive information”, dan envPrefix: ['VITE_', 'KASERA_'] diterima lalu menanam kunci tanpa awalan VITE_ itu ke bundel. Kunci API dan signing secret webhook hanya diberikan pada proses node server/index.js, dan build tidak membutuhkan keduanya.
2. vite.config.js: proxy yang hanya hidup saat pengembangan
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
// Hanya untuk npm run dev (dan vite preview). Build produksi tidak membawa proxy ini.
proxy: { '/api': 'http://localhost:3000' },
},
});Dengan proxy ini, npm run dev meneruskan /api ke server di port 3000, dan vite preview memakai proxy yang sama. Keduanya menjawab 200 saat diuji. Itulah yang menyesatkan: folder dist yang sama, disajikan server statis biasa, menjawab POST /api/bayar dengan 501 dan membuka /pesanan/A1 langsung dengan 404. Build tidak membawa proxy. Karena itu server Express di bawah menyajikan dist sendiri. Kalau frontend tetap ingin di hosting statis, hosting itu harus meneruskan /api dan /kasera-webhook ke backend dan mengarahkan path lain ke index.html.
3. Router dan halaman pesanan
// src/main.js
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import Pesanan from './Pesanan.vue';
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/pesanan/:id', component: Pesanan, props: true }],
});
createApp(App).use(router).mount('#app');<!-- src/Pesanan.vue -->
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({ id: { type: String, required: true } });
const status = ref('');
const memproses = ref(false);
const gagal = ref(false);
let timer;
async function muatStatus() {
const res = await fetch('/api/pesanan/' + encodeURIComponent(props.id));
if (!res.ok) return;
status.value = (await res.json()).status;
// Berhenti bertanya begitu status final.
if (status.value === 'LUNAS' || status.value === 'KEDALUWARSA') clearInterval(timer);
}
async function bayar() {
memproses.value = true;
gagal.value = false;
try {
const res = await fetch('/api/bayar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId: props.id }),
});
if (!res.ok) throw new Error(String(res.status));
window.location.href = (await res.json()).checkoutUrl;
} catch {
memproses.value = false;
gagal.value = true;
}
}
onMounted(() => {
muatStatus();
timer = setInterval(muatStatus, 3000);
});
// Tanpa ini interval tetap berjalan setelah pembeli pindah halaman.
onUnmounted(() => clearInterval(timer));
</script>
<template>
<p v-if="status === 'LUNAS'">Pembayaran diterima. Pesanan diproses.</p>
<template v-else>
<p v-if="status === 'MENUNGGU'">Menunggu pembayaran.</p>
<button type="button" :disabled="memproses" @click="bayar">
{{ memproses ? 'Mengalihkan...' : 'Bayar' }}
</button>
<p v-if="gagal">Pembayaran belum bisa dibuat. Coba lagi.</p>
</template>
</template>props: true membuat parameter :id masuk sebagai prop. Tombol dinonaktifkan setelah klik pertama; pada pengujian, klik ganda di Chromium menghasilkan satu POST /api/bayar, dan kunci idempotensi di server menutup sisa kemungkinannya.
Halaman ini juga halaman kembali. Checkout menambahkan ?id=...&status=succeeded ke return_url, tetapi query string itu navigasi, bukan bukti, jadi komponen tidak membacanya sama sekali. Status diambil dari backend setiap tiga detik dan polling berhenti saat statusnya final. Baris onUnmounted bukan hiasan: pada pengujian, halaman yang dibuka tujuh detik bertanya tiga kali, dan setelah pembeli pindah route tidak ada pertanyaan lagi. Tanpa baris itu, dua pertanyaan tetap terkirim dalam tujuh detik berikutnya dari halaman yang sudah tidak terlihat.
4. Server: membuat permintaan pembayaran
// server/index.js
import express from 'express';
import { fileURLToPath } from 'node:url';
import pg from 'pg';
import { KaseraPay, KaseraPayError, SignatureError, constructWebhookEvent } from 'kasera-pay';
const kasera = new KaseraPay(process.env.KASERA_PAY_KEY, {
baseUrl: process.env.KASERA_PAY_BASE_URL, // hanya untuk pengujian; kosongkan di produksi
});
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
db.on('error', (e) => console.error('pg pool:', e.message));
const dist = fileURLToPath(new URL('../dist', import.meta.url));
const app = express();
app.post('/api/bayar', express.json(), async (req, res) => {
// Memastikan pesanan ini milik pengguna yang login adalah tugas aplikasi.
const orderId = req.body?.orderId;
if (typeof orderId !== 'string') return res.status(400).json({ error: 'orderId' });
const { rows } = await db.query('SELECT id, total, status, attempt FROM orders WHERE id = $1', [
orderId,
]);
const order = rows[0];
if (!order) return res.status(404).json({ error: 'not_found' });
if (order.status === 'LUNAS') return res.status(409).json({ error: 'sudah_lunas' });
try {
const tx = await kasera.createTransaction(
{
amount: Number(order.total), // bigint datang sebagai string
description: 'Pesanan ' + order.id,
external_id: order.id,
return_url: process.env.PUBLIC_URL + '/pesanan/' + encodeURIComponent(order.id),
},
{ idempotencyKey: 'order-' + order.id + '-' + order.attempt },
);
await db.query(
"UPDATE orders SET payment_request_id = $1, status = 'MENUNGGU' WHERE id = $2 AND status <> 'LUNAS'",
[tx.id, order.id],
);
res.json({ checkoutUrl: tx.checkout_url });
} catch (e) {
if (e instanceof KaseraPayError) return res.status(502).json({ error: e.code });
throw e;
}
});
// Yang dibaca halaman kembali: status dari database sendiri, bukan dari query string.
app.get('/api/pesanan/:id', async (req, res) => {
const { rows } = await db.query('SELECT status FROM orders WHERE id = $1', [req.params.id]);
if (!rows[0]) return res.status(404).json({ error: 'not_found' });
res.json({ status: rows[0].status });
});
// express.raw HANYA di route ini: signature dihitung atas byte mentah.
app.post('/kasera-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let ev;
try {
ev = await constructWebhookEvent(
Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '',
req.get('kasera-signature-v1') ?? '',
process.env.KASERA_PAY_WEBHOOK_SECRET,
);
} catch (e) {
if (e instanceof SignatureError) return res.status(400).send('bad signature');
throw e;
}
const type = ev.type;
const data = ev.data;
if (!data?.external_id) return res.send('ignored');
const client = await db.connect();
try {
await client.query('BEGIN');
const fresh = await client.query(
'INSERT INTO kasera_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
[ev.id, type],
);
if (fresh.rowCount === 1 && type === 'payment.paid') {
const paid = await client.query(
"UPDATE orders SET status = 'LUNAS', paid_at = coalesce($2, now()) WHERE id = $1 AND total = $3",
[data.external_id, data.paid_at ?? null, data.amount ?? -1],
);
if (paid.rowCount !== 1) throw new Error('pesanan tidak ditemukan atau nominal tidak cocok');
} else if (fresh.rowCount === 1 && type === 'payment.expired') {
await client.query(
`UPDATE orders SET status = 'KEDALUWARSA', attempt = attempt + 1
WHERE id = $1 AND status <> 'LUNAS' AND payment_request_id = $2`,
[data.external_id, data.payment_request_id ?? null],
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK').catch(() => {});
console.error(e);
return res.status(500).send('retry'); // Kasera Pay mengulang
} finally {
client.release();
}
res.send('ok');
});
// Hasil npm run build, lalu fallback history mode. Dipasang PALING AKHIR,
// hanya untuk GET, supaya POST ke /api dan /kasera-webhook tidak pernah
// dijawab dengan index.html.
app.use(express.static(dist, { index: false }));
app.get('/{*path}', (req, res) => res.sendFile('index.html', { root: dist }));
app.listen(process.env.PORT || 3000);Total dibaca dari database, tidak pernah dari body, supaya pembeli tidak bisa mengirim nominalnya sendiri. pg mengembalikan kolom bigint sebagai string, jadi Number() wajib. Kunci idempotensi dibentuk dari id pesanan dan nomor percobaan. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label, dan alasannya ada di idempotency untuk pembayaran. Pada pengujian, sepuluh POST /api/bayar bersamaan untuk satu pesanan sampai ke API dengan satu kunci yang sama, order-A1-0. Setelah payment.expired tiba, nomor percobaan naik dan klik berikutnya memakai order-A2-1, sehingga pembeli mendapat permintaan baru, bukan yang sudah mati.
Contoh ini menagih pesanan sampai Rp 1.000.000. Pembayaran di atas Rp 1.000.000 wajib membawa customer.name, customer.phone atau customer.email, description minimal 10 karakter, delivery_date, dan delivery_terms_shown: true, dan tanggal itu harus sudah tampil di halaman Vue sebelum pembeli menekan Bayar. Tanpanya create ditolak 422; rinciannya di panduan pembayaran di atas Rp 1.000.000.
5. Webhook, dan urutan route yang menentukan
express.raw dipasang hanya pada route webhook, karena tanda tangan dihitung atas byte mentah. Hasil pengujian: pengiriman bertanda tangan sah dijawab 200; secret yang salah dan stempel waktu 600 detik yang lalu dijawab 400; tiga puluh pengiriman kembar bersamaan semuanya dijawab 200 dengan tepat satu baris di kasera_events; nominal yang tidak cocok dijawab 500 supaya pengiriman diulang. Saat ON CONFLICT (id) DO NOTHING dihapus, pengiriman kembar berurutan dijawab 200, 500, 500, dan setiap 500 berarti Kasera Pay mengulang event yang sebenarnya sudah tercatat.
Fallback history mode dipasang paling akhir, dan hanya untuk GET. Bentuk yang sering disalin, app.use((req, res) => res.sendFile('index.html', ...)) di atas route lain, menjawab semua metode. Saat diuji dengan fallback itu di atas route webhook, pengiriman bertanda tangan sah dijawab 200 dengan text/html, dan tidak ada yang tercatat. Kasera Pay menganggapnya terkirim, pesanan tidak pernah lunas, dan tidak ada galat di log. Di Express 5, bentuk lama app.get('*', ...) juga tidak lagi berfungsi: path-to-regexp melempar “Missing parameter name at index 1: *” saat route dipasang, dan penulisannya sekarang '/{*path}'. URL webhook didaftarkan di dashboard, menu Developer, dan wajib https ke alamat publik.
6. Menguji sebelum kunci live dipasang
Dengan kunci kp_test_, halaman checkout menampilkan tombol simulasi, dan hasil berhasil maupun kedaluwarsa bisa dipicu lewat endpoint simulasi di dokumentasi mode tes. Lima hal yang layak dicoba: grep -r kp_test_ dist/ setelah build tidak menemukan apa pun; membuka /pesanan/<id> langsung di domain produksi dijawab halaman Vue, bukan 404; pengiriman dengan secret yang salah ke /kasera-webhook dijawab 400, bukan HTML; event yang dikirim ulang tidak mengubah apa pun; dan simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru. Urutan pemeriksaan sebelum go-live yang lebih lengkap ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Bolehkah kunci API Kasera Pay disimpan sebagai VITE_KASERA_PAY_KEY di file .env?
Tidak. Vite menanam setiap variabel berawalan VITE_ yang dipakai kode ke dalam bundel. Saat panduan ini ditulis, kunci kp_test_ yang dibaca lewat import.meta.env.VITE_KASERA_PAY_KEY muncul sebagai teks biasa di dist/assets/index-*.js, file yang diunduh setiap pengunjung. Variabel tanpa awalan di file .env yang sama tidak terbaca di kode Vue dan tidak ikut ke bundel. Kunci API hanya dibaca oleh server, dari process.env, saat server berjalan.
Kenapa POST /api/bayar berhasil di npm run dev tetapi gagal setelah di-deploy?
Karena server.proxy di vite.config.js hanya dipakai server pengembangan Vite dan vite preview. npm run build menghasilkan file statis saja. Pada pengujian, folder dist yang disajikan server statis biasa menjawab POST /api/bayar dengan 501 dan membuka /pesanan/A1 langsung dengan 404. Di produksi, backend yang sama perlu menyajikan dist dan menjawab /api, atau reverse proxy yang meneruskan /api ke backend.
Apakah status=succeeded di URL kembali cukup untuk menandai pesanan lunas?
Tidak. Halaman checkout menambahkan ?id=...&status=succeeded ke return_url sebagai navigasi, dan siapa pun bisa mengetik URL itu sendiri. Pesanan ditandai lunas oleh webhook payment.paid yang tanda tangannya terverifikasi, dan halaman Vue hanya membaca status pesanan dari backend sendiri.