Panduan · Terbit
Integrasi payment gateway di Nuxt: runtimeConfig yang kosong saat build, server route untuk webhook yang membaca readRawBody, dan satu transaksi Postgres sebagai penulis status lunas
Aplikasi Nuxt memasang Kasera Pay di sisi server Nitro, tidak pernah di komponen Vue: satu server route server/api/bayar.post.ts yang membuat permintaan pembayaran, dan satu server/routes/kasera-webhook.post.ts yang menerima kabar pembayaran. Empat hal khas Nuxt menentukan hasilnya. Kunci API dan signing secret disimpan di runtimeConfig privat yang dibiarkan kosong saat build, lalu diisi variabel NUXT_ saat server berjalan. Tanda tangan diverifikasi dari readRawBody, bukan dari objek hasil readBody. Dedupe event dan pembaruan pesanan ditulis dalam satu transaksi database. Dan aplikasinya di-deploy dengan server, karena nuxt generate tidak punya tempat untuk webhook.
Contoh di bawah memakai Nuxt 4.5, Postgres lewat pg, dan SDK JavaScript resmi versi 0.1.0. Seluruh kodenya di-build dengan nuxt build dan dijalankan dengan Node 22 saat panduan ini ditulis, termasuk 40 pengiriman event yang sama secara bersamaan. Kalau proyeknya memakai Next.js, bentuk yang setara ada di panduan integrasi Next.js.
Alurnya, sebelum menulis kode
Halaman pesanan memanggil /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 delapan bank. Kasera Pay mengirim payment.paid bertanda tangan ke /kasera-webhook, dan server menandai pesanan lunas. Kepulangan pembeli ke return_url bukan bukti apa pun. Status permintaan pembayaran adalah pending, succeeded, dan expired; payment.paid adalah nama event-nya, bukan status.
1. Paket, rahasia, dan runtimeConfig
npm install kasera-pay pg
npm install -D @types/pg
# .env, dibaca nuxt dev. Jangan di-commit.
NUXT_KASERA_PAY_KEY=kp_test_...
NUXT_KASERA_PAY_WEBHOOK_SECRET=whsec_...
NUXT_DATABASE_URL=postgres://...// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: "2026-09-01",
runtimeConfig: {
// Sengaja kosong. Diisi saat server berjalan oleh
// NUXT_KASERA_PAY_KEY, NUXT_KASERA_PAY_WEBHOOK_SECRET, NUXT_DATABASE_URL.
kaseraPayKey: "",
kaseraPayWebhookSecret: "",
databaseUrl: "",
// Tidak ada apa pun milik Kasera Pay di bawah public.
},
});Nuxt hanya mengisi kunci yang dideklarasikan di runtimeConfig: variabel NUXT_KASERA_PAY_KEY mengisi kaseraPayKey, dan variabel NUXT_ lain yang tidak punya pasangan diabaikan. Dua kesalahan yang terlihat wajar perlu dihindari, dan keduanya diuji saat panduan ini ditulis. Pertama, menulis kaseraPayKey: process.env.KASERA_PAY_KEY. Nilai itu dibaca saat build dan tersimpan sebagai teks biasa di .output/server/chunks/nitro/nitro.mjs, lalu server tetap memakainya walaupun variabelnya tidak ada saat berjalan. Kunci live jadi ikut di setiap image dan cache CI. Kedua, menaruh apa pun di bawah public: nilai itu ikut terkirim di HTML setiap halaman.
Kunci kp_test_ dan kp_live_ diganti bersamaan dengan signing secret-nya. Webhook mode tes dan live adalah endpoint terpisah dengan secret masing-masing, dan secret yang tidak cocok membuat setiap pengiriman ditolak 400.
CREATE 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()
);2. Klien SDK dan pool database
// server/utils/kasera.ts
import { KaseraPay } from "kasera-pay";
import type { H3Event } from "h3";
export function kasera(event: H3Event) {
return new KaseraPay(useRuntimeConfig(event).kaseraPayKey);
}
// server/utils/db.ts
import pg from "pg";
let pool: pg.Pool | undefined;
export function db() {
pool ??= new pg.Pool({ connectionString: useRuntimeConfig().databaseUrl, max: 10 });
return pool;
}File di server/utils diimpor otomatis ke semua server route. useRuntimeConfig(event) diberi event supaya nilai yang diisi per permintaan, misalnya di preset Cloudflare, ikut terbaca. Di preset Cloudflare kode server berjalan di workerd, dan SDK 0.1.0 butuh opsi fetch yang dibungkus di sana; alasannya ada di panduan Cloudflare Workers. Di Node, Vercel, dan Netlify, bentuk di atas cukup.
3. Server route yang membuat permintaan pembayaran
// server/api/bayar.post.ts
import { KaseraPayError } from "kasera-pay";
export default defineEventHandler(async (event) => {
// Memastikan pesanan ini milik pengguna yang login adalah tugas aplikasi.
const { orderId } = await readBody<{ orderId: string }>(event);
const { rows } = await db().query(
"SELECT id, total, status, attempt FROM orders WHERE id = $1",
[orderId],
);
const order = rows[0];
if (!order) throw createError({ statusCode: 404, statusMessage: "not_found" });
if (order.status === "LUNAS") throw createError({ statusCode: 409, statusMessage: "sudah_lunas" });
try {
const tx = await kasera(event).createTransaction(
{
amount: Number(order.total), // bigint datang sebagai string
description: "Pesanan " + order.id,
external_id: order.id,
return_url: "https://toko.example/pesanan/" + order.id,
checkout: {},
},
{ 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],
);
return { checkoutUrl: tx.checkout_url };
} catch (e) {
if (e instanceof KaseraPayError) throw createError({ statusCode: 502, statusMessage: e.code });
throw e;
}
});Total dibaca dari database, tidak pernah dari body, supaya pembeli tidak bisa mengirim nominalnya sendiri. Ada satu jebakan yang khas Postgres dan Node: pg mengembalikan kolom bigint sebagai string. Tanpa Number(), SDK mengirim “amount”: “150000”, dan API menolaknya dengan 400 invalid_body yang menyebut bahwa amount harus angka.
Kunci idempotensinya dibentuk dari id pesanan dan nomor percobaan. Key yang sama dengan body yang sama selalu mengembalikan permintaan asli, termasuk yang sudah kedaluwarsa, jadi id saja membuat pembeli yang kembali setelah 60 menit tidak bisa membayar lagi. Nomor percobaan dinaikkan oleh webhook saat payment.expired tiba. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Alasannya ada di idempotency untuk pembayaran.
Dari halaman Vue, server route itu dipanggil seperti ini:
<!-- app/pages/pesanan/[id].vue -->
<script setup lang="ts">
const route = useRoute();
async function bayar() {
const { checkoutUrl } = await $fetch("/api/bayar", {
method: "POST",
body: { orderId: route.params.id },
});
await navigateTo(checkoutUrl, { external: true });
}
</script>4. Server route webhook
// server/routes/kasera-webhook.post.ts
import { constructWebhookEvent, SignatureError } from "kasera-pay";
export default defineEventHandler(async (event) => {
let ev;
try {
ev = await constructWebhookEvent(
(await readRawBody(event)) ?? "", // byte mentah, bukan JSON.stringify(readBody)
getHeader(event, "kasera-signature-v1") ?? "",
useRuntimeConfig(event).kaseraPayWebhookSecret,
);
} catch (e) {
if (e instanceof SignatureError) {
setResponseStatus(event, 400);
return "bad signature";
}
throw e;
}
// Tipe kasera-pay 0.1.0 baru mengenal payment.paid dan test.ping.
const type: string = ev.type;
const data = ("data" in ev ? ev.data : undefined) as
| { external_id?: string | null; payment_request_id?: string; paid_at?: string | null }
| undefined;
if (!data?.external_id) return "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") {
await client.query(
"UPDATE orders SET status = 'LUNAS', paid_at = coalesce($2, now()) WHERE id = $1",
[data.external_id, data.paid_at ?? null],
);
} 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);
setResponseStatus(event, 500); // Kasera Pay mengulang
return "retry";
} finally {
client.release();
}
return "ok";
});Route ini sengaja diletakkan di server/routes, bukan server/api, sehingga alamatnya /kasera-webhook tanpa awalan /api. URL yang didaftarkan di dashboard harus sama persis dengan letak filenya; /api/kasera-webhook untuk file di server/routes dijawab 404.
readRawBody mengembalikan body persis seperti yang dikirim, dan string itulah yang diverifikasi. Memanggil readBody lebih dulu tidak merusaknya, karena h3 menyimpan body mentahnya. Yang merusak adalah JSON.stringify(await readBody(event)): isinya sama, byte-nya bisa berbeda, dan pada pengujian dengan body berspasi pengiriman yang sah ditolak 400. constructWebhookEvent memeriksa header Kasera-Signature-V1, toleransi waktu lima menit, dan dua entri v1 selama rotasi signing secret.
Dedupe dan pembaruan pesanan berada dalam satu transaksi. Sisipan ke kasera_events adalah penentunya: pengiriman kedua dari event yang sama menunggu sampai yang pertama selesai, lalu tidak menyisipkan apa pun, sehingga pembaruannya dilewati. Pada pengujian 40 pengiriman payment.expired yang sama secara bersamaan, nomor percobaan naik tepat satu kali. Tanpa pemeriksaan rowCount, angka yang sama naik 40 kali. Pemeriksaan payment_request_id menjaga urutan antar percobaan: kabar kedaluwarsa dari percobaan lama yang terlambat tiba tidak menimpa pesanan yang sedang menunggu percobaan baru. payment.paid selalu menang, dari percobaan mana pun.
Pengiriman bersifat at-least-once, dan jawaban selain 2xx membuat Kasera Pay mencoba lagi sampai total tujuh kali dalam kurang lebih 33 jam. Itu sebabnya galat database dijawab 500, bukan 200. Event kedaluwarsa hanya dikirim ke endpoint yang mencentangnya; endpoint lama menerima payment.paid saja sampai diubah. Rinciannya ada di referensi webhook.
5. Deploy: harus ada server
nuxt build menghasilkan .output/server/index.mjs yang dijalankan dengan node .output/server/index.mjs, dan variabel NUXT_ diberikan pada proses itu, bukan pada langkah build. nuxt generate tidak cocok untuk aplikasi ini: hasilnya file statis tanpa server, sehingga tidak ada yang bisa menyimpan kunci API atau menerima webhook. 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 juga bisa dipicu lewat endpoint simulasi di dokumentasi mode tes. nuxt dev berjalan di localhost:3000, jadi webhook ke sana butuh terowongan, misalnya cloudflared tunnel --url http://localhost:3000. Lima hal yang layak dicoba: grep kunci tes di folder .output setelah build tidak menemukan apa pun; ketukan ganda menghasilkan satu permintaan pembayaran; event yang dikirim ulang tidak mengubah apa pun; simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru; dan tabel yang sengaja diganti namanya membuat webhook dijawab 500.
Pertanyaan yang sering muncul
Kenapa kunci API tidak boleh ditulis sebagai process.env.KASERA_PAY_KEY di nuxt.config.ts?
Karena nilai di nuxt.config dibaca saat build dan disimpan ke hasil build. Saat panduan ini ditulis, build Nuxt 4.5 dengan kaseraPayKey: process.env.KASERA_PAY_KEY menyimpan kunci itu sebagai teks biasa di .output/server/chunks/nitro/nitro.mjs, dan server memakainya walaupun variabel lingkungan tidak ada lagi saat berjalan. Siapa pun yang memegang artefak build, image Docker, atau cache CI memegang kuncinya. Biarkan nilainya string kosong dan isi lewat NUXT_KASERA_PAY_KEY saat server berjalan.
Bolehkah pembayaran dibuat langsung dari komponen Vue dengan useFetch ke pay.kasera.id?
Tidak. Pemanggilan ke /v1/transactions butuh kunci API, dan apa pun yang dibutuhkan kode komponen ikut sampai ke peramban. Komponen hanya memanggil server route milik aplikasi sendiri, misalnya /api/bayar, dan server route itulah yang memegang kunci. Nilai di runtimeConfig.public juga bukan tempatnya: nilai itu dikirim di setiap halaman HTML.
Kenapa membuka URL webhook di peramban menghasilkan 404?
Karena file berakhiran .post.ts hanya melayani POST. Permintaan GET jatuh ke router halaman Vue, yang tidak menemukan halamannya dan menjawab 404. Itu normal. Untuk menguji endpoint, tekan Kirim event percobaan di dashboard atau pakai tombol simulasi di mode tes.
Apakah ini berjalan kalau Nuxt di-deploy dengan nuxt generate?
Tidak. nuxt generate menghasilkan file statis tanpa server, sehingga server/api dan server/routes tidak ikut ter-deploy dan tidak ada alamat yang bisa menerima webhook atau menyimpan kunci API. Pakai nuxt build dengan preset yang punya server, misalnya Node, Vercel, atau Netlify, atau pisahkan backend pembayaran ke layanan lain.