Panduan · Terbit
Integrasi payment gateway di SvelteKit: request.text() untuk tanda tangan webhook, src/env.js yang wajib di SvelteKit 3, dan redirect ke checkout yang ditolak tanpa opsi external
Integrasi Kasera Pay di SvelteKit terdiri dari dua route. Webhook ditangani di +server.js yang membaca await request.text() dan memverifikasinya sebelum apa pun di-parse, lalu tombol bayar adalah form action yang membuat transaksi dan mengalihkan pembeli ke halaman checkout. Di SvelteKit 3 ada dua hal baru yang wajib dipenuhi. Variabel lingkungan harus dideklarasikan di src/env.js, karena tanpa itu secret webhook bernilai undefined di build produksi. Redirect ke checkout juga harus diberi opsi external, karena tanpa opsi itu SvelteKit menolaknya dengan 500.
Semua perilaku di bawah diukur untuk panduan ini pada @sveltejs/kit 3.1.0, @sveltejs/adapter-node 6.0.0, Vite 8.3, Svelte 5.57, SDK kasera-pay 0.1.0, Node 22, dan Postgres 16, pada build produksi yang dijalankan dengan node build, dengan SvelteKit 2.70.3 sebagai pembanding. Build produksi itu penting, karena proteksi CSRF SvelteKit tidak aktif di vite dev. Kontrak lengkapnya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Paket, variabel lingkungan, dan tabel dedupe
npm install kasera-pay pgJebakan pertama SvelteKit 3 justru ada sebelum kode pembayaran ditulis. Proyek lama yang membaca secret dengan import { env } from "$env/dynamic/private" tetap berhasil di-build, hanya dengan peringatan IMPORT_IS_UNDEFINED di keluaran build. Pada pengukuran, tanpa src/env.js nilai env.KASERA_PAY_WEBHOOK_SECRET adalah undefined walaupun variabelnya terpasang di server. Akibatnya setiap pengiriman webhook dijawab 500 dengan Zero-length key is not supported di log, dan koneksi database pun ikut kosong. Deklarasikan semuanya di satu file:
// src/env.js
import { defineEnvVars } from "@sveltejs/kit/env";
const prefixed = (prefix) => (value) => {
if (!value?.startsWith(prefix)) throw new Error(`harus berawalan ${prefix}`);
return value;
};
export const variables = defineEnvVars({
// kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY: { schema: prefixed("kp_") },
KASERA_PAY_WEBHOOK_SECRET: { schema: prefixed("whsec_") },
DATABASE_URL: {},
SITE_URL: {},
});Variabel yang dideklarasikan tanpa schema wajib ada, tetapi string kosong tetap lolos. Pada pengukuran, aplikasi yang dijalankan dengan KASERA_PAY_WEBHOOK_SECRET= kosong tetap menyala. Validator awalan di atas menutup celah itu: tanpa secret, atau dengan secret yang salah tempel, aplikasi berhenti saat start dengan env_invalid alih-alih menyala lalu menolak setiap webhook. Secret webhook diambil dari dashboard, menu Developer, dan berbeda per mode.
// src/lib/server/kasera.js
import { KaseraPay } from "kasera-pay";
import pg from "pg";
import { KASERA_PAY_KEY, DATABASE_URL } from "$app/env/private";
export const kasera = new KaseraPay(KASERA_PAY_KEY);
export const pool = new pg.Pool({ connectionString: DATABASE_URL });Pengiriman webhook bersifat at-least-once, jadi event yang sama bisa datang lebih dari sekali dengan id yang sama. Penjaganya primary key, bukan pemeriksaan “sudah ada atau belum” sebelum menulis.
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);2. Body mentah: request.text(), bukan request.json()
Jalan pintas yang paling sering dicoba adalah memverifikasi JSON.stringify(await request.json()). Pada pengukuran, jalan pintas itu lolos untuk payload biasa dan gagal 400 untuk payload yang sama dengan nama pelanggan CV Maju & Jaya. Payload webhook Kasera Pay menulis &, <, dan > sebagai \u0026, \u003c, dan \u003e, sedangkan JSON.stringify menuliskannya kembali apa adanya. Isinya sama, byte-nya berbeda, dan HMAC dihitung atas byte.
request di SvelteKit adalah Request standar, dan body-nya hanya bisa dibaca sekali. Handler yang memanggil request.json() lebih dulu lalu request.text() untuk verifikasi gagal 500 dengan Body is unusable: Body has already been read. Baca teksnya sekali, verifikasi, dan ambil objeknya dari hasil verifikasi.
3. Route webhook
// src/routes/webhooks/kasera-pay/+server.js
import { error } from "@sveltejs/kit";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
import { KASERA_PAY_WEBHOOK_SECRET } from "$app/env/private";
import { pool } from "#lib/server/kasera.js";
export async function POST({ request }) {
// Byte persis yang dikirim. Jangan JSON.stringify(await request.json()).
const body = await request.text();
let event;
try {
event = await constructWebhookEvent(
body,
request.headers.get("kasera-signature-v1") ?? "",
KASERA_PAY_WEBHOOK_SECRET,
);
} catch (err) {
// error() melempar, jadi baris di bawahnya tidak berjalan.
if (err instanceof SignatureError) error(400, "invalid signature");
throw err;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// Primary key yang menjadi penjaga dedupe. Pengiriman kembar yang
// tiba bersamaan menunggu di baris ini, lalu mendapat rowCount 0.
const { rowCount } = await client.query(
"INSERT INTO webhook_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING",
[event.id, event.type],
);
if (rowCount === 1 && event.type === "payment.paid") {
await client.query(
"UPDATE orders SET status = 'dibayar', payment_request_id = $2 WHERE number = $1",
[event.data.external_id, event.data.payment_request_id],
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err; // SvelteKit menjawab 500, dan pengiriman diulang.
} finally {
client.release();
}
return new Response("ok");
}Yang terukur pada kode ini. Payload dengan \u0026 diterima 200 dan pesanannya menjadi dibayar. application/json; charset=utf-8 juga lolos. Header dengan dua entri v1, yang muncul selama 24 jam setelah rotasi signing secret, diterima kalau salah satunya cocok. Stempel waktu 400 detik yang lalu ditolak 400, karena toleransinya lima menit. Tanda tangan yang dibuat dengan secret lain ditolak 400 dengan body {"status":400,"message":"invalid signature"}, dan pesanannya tidak berubah. GET ke URL yang sama dijawab 405 dengan header Allow: POST.
4. error(400) menghentikan handler, Response tanpa return tidak
error() dari @sveltejs/kit melempar, jadi baris sesudahnya tidak pernah berjalan. Yang tidak melempar adalah membuat objek Response, dan bentuk inilah yang lazim muncul pada verifikasi buatan sendiri:
// JANGAN: verifikasi buatan sendiri yang membuat Response tanpa return.
export async function POST({ request }) {
const body = await request.text();
const ok = await verifyWebhook(body, request.headers.get("kasera-signature-v1") ?? "", secret);
if (!ok) {
Response.json({ error: "invalid signature" }, { status: 400 }); // dibuat, lalu dibuang
}
await tandaiLunas(JSON.parse(body)); // tetap jalan
return new Response("ok"); // pengirim menerima 200
}Pada pengukuran, event payment.paid bertanda tangan palsu yang dikirim ke handler seperti ini dijawab 200 ok, dan pesanannya ditandai dibayar. Tidak ada peringatan di log. Saat error(400, ...) di route bagian 3 diganti dengan new Response(..., { status: 400 }) tanpa return untuk pengujian, baris throw err di bawahnya melempar ulang SignatureError, sehingga event palsu dijawab 500 dan pesanannya tidak berubah. Itu tetap salah, karena 500 berarti pengiriman palsu diulang sampai tujuh kali, tetapi tidak menandai pesanan lunas. Setelah dikembalikan ke error(), event palsu dijawab 400.
5. CSRF bawaan dan hooks.server.js
Build produksi SvelteKit menolak POST lintas situs yang tampak seperti kiriman formulir, dan webhook adalah POST dari luar situs. Pada pengukuran, pengiriman webhook lolos karena membawa Content-Type: application/json, termasuk saat diberi header Origin asing. Permintaan bertipe text/plain, application/x-www-form-urlencoded, atau tanpa Content-Type sama sekali dijawab 403 Cross-site POST form submissions are forbidden. Jadi kalau webhook diuji dengan curl -d tanpa header Content-Type, 403 itu berasal dari SvelteKit, bukan dari tanda tangan. Pengiriman sungguhan selalu membawa application/json.
// src/hooks.server.js
import { redirect } from "@sveltejs/kit";
export async function handle({ event, resolve }) {
// Jaga area toko saja. Kasera Pay tidak membawa sesi ke /webhooks.
if (event.url.pathname.startsWith("/toko") && !(await sesiSah(event))) {
redirect(303, "/masuk");
}
return resolve(event);
}handle di hooks.server.js berjalan untuk setiap permintaan, webhook juga. Pada pengukuran, hook yang menjaga semua path kecuali /masuk menjawab setiap pengiriman webhook dengan 303 ke halaman masuk, dan pesanannya tidak pernah berubah. Pengirim webhook Kasera Pay tidak mengikuti redirect, sehingga 303 itu dicatat sebagai pengiriman gagal. Jaga path yang memang butuh sesi, seperti contoh di atas, dan biarkan /webhooks di luar.
6. Tombol bayar: form action dan redirect ke checkout
// src/routes/toko/pesanan/[number]/+page.server.js
import { error, fail, redirect } from "@sveltejs/kit";
import { KaseraPayError } from "kasera-pay";
import { SITE_URL } from "$app/env/private";
import { kasera, pool } from "#lib/server/kasera.js";
export const actions = {
bayar: async ({ params }) => {
// Key dibuat sekali per pesanan dan dipakai ulang di setiap percobaan.
const { rows } = await pool.query(
`UPDATE orders SET idempotency_key = COALESCE(idempotency_key, $2)
WHERE number = $1 AND status = 'baru'
RETURNING total, idempotency_key`,
[params.number, crypto.randomUUID()],
);
if (rows.length === 0) error(404, "pesanan tidak ditemukan atau sudah dibayar");
const order = rows[0];
let tx;
try {
tx = await kasera.createTransaction(
{
amount: order.total, // dari database, tidak pernah dari isi formulir
description: "Pesanan " + params.number,
external_id: params.number,
return_url: SITE_URL + "/toko/pesanan/" + params.number,
checkout: {},
},
{ idempotencyKey: order.idempotency_key },
);
} catch (err) {
if (err instanceof KaseraPayError) return fail(502, { error: err.code });
throw err;
}
// Di luar try: redirect() melempar, dan catch di atas akan menelannya.
redirect(303, tx.checkout_url, { external: ["https://pay.kasera.id"] });
},
};<!-- src/routes/toko/pesanan/[number]/+page.svelte -->
<script>
let { form } = $props();
</script>
<form method="POST" action="?/bayar">
<button>Bayar</button>
</form>
{#if form?.error}<p>Pembayaran belum bisa dibuat ({form.error}).</p>{/if}Dua hal yang terukur di sini. Pertama, di SvelteKit 3, redirect(303, tx.checkout_url) tanpa opsi ketiga dijawab 500 dengan redirect_external_not_allowed di log, karena URL checkout berada di domain lain. Dengan { external: ["https://pay.kasera.id"] }, form tanpa JavaScript dijawab 303 ke halaman checkout, dan form dengan use:enhance menerima {"type":"redirect"} dengan lokasi yang sama. Daftar origin lebih sempit daripada external: true, yang menerima domain apa pun.
Kedua, redirect() bekerja dengan melempar. Kalau dipanggil di dalam try, catch-nya ikut menangkapnya: pada pengukuran, catch yang menangani galat SDK menerima objek Redirect, dan pembeli mendapat 500 alih-alih halaman checkout. Ini terjadi juga di SvelteKit 2. Karena itu tx dideklarasikan di luar try dan redirect dipanggil sesudahnya. Kalau API menolak, misalnya validation_failed, fail(502) mengembalikan kodenya ke halaman.
Header Idempotency-Key dikirim SDK dari opsi idempotencyKey. Pada pengukuran, dua kali menekan Bayar untuk pesanan yang sama mengirim key yang sama, karena key disimpan di baris pesanan dengan COALESCE dalam satu pernyataan. Pesanan yang sudah dibayar tidak lagi lolos syarat status = 'baru' dan dijawab 404. Bentuk yang sama untuk Node tanpa framework meta ada di panduan Node.js dan Express.
7. Dedupe di bawah pengiriman kembar bersamaan
Event baru yang sama dikirim 30 kali bersamaan ke build produksi. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris untuk event itu, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Saat pemeriksaan rowCount === 1 dihapus untuk pengujian, pengiriman yang sama menghasilkan 30 pembaruan. Catatan event dan pembaruan pesanan berada dalam satu transaksi: saat tabel pesanan dibuat hilang di tengah pengujian, pengiriman dijawab 500 dan tidak ada baris event yang tersimpan, lalu pengiriman ulang setelah tabelnya kembali dijawab 200 dan memenuhi pesanannya.
8. Sebelum go-live
Daftarkan URL webhook persis seperti route-nya. Opsi trailingSlash bawaan SvelteKit adalah never, dan pada pengukuran /webhooks/kasera-pay/ dijawab 308, yang tidak diikuti pengirim. Path dengan huruf besar dijawab 404. URL webhook wajib https dan mengarah ke alamat publik. Kepulangan pembeli ke return_url bukan bukti bayar; penanda lunas hanya payment.paid bertanda tangan atau pembacaan GET /v1/transactions/{id} dari server. Kalau stok dilepas saat payment.expired atau payment.failed, siapkan jalur untuk menariknya kembali karena payment.paid masih bisa menyusul. Urutan pemeriksaan lainnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Proyeknya masih SvelteKit 2. Apa yang berbeda dari panduan ini?
Diukur pada SvelteKit 2.70.3 dengan adapter-node 5.5.7: $env/dynamic/private membaca variabel lingkungan proses apa adanya tanpa src/env.js, dan redirect(303, url) ke halaman checkout bekerja tanpa opsi external. Satu perbedaan kecil di CSRF: SvelteKit 2 meloloskan POST tanpa Content-Type sama sekali, sedangkan SvelteKit 3 menolaknya dengan 403; kiriman formulir ditolak di kedua versi. Yang sama persis: request.text() meloloskan payload berisi \u0026, garis miring akhir dijawab 308, dan redirect yang dipanggil di dalam try tertangkap oleh catch-nya. Di SvelteKit 2, konfigurasi tetap di svelte.config.js dan alias $lib tetap ada.
Kenapa build di CI gagal dengan env_invalid padahal variabelnya hanya dibaca saat aplikasi berjalan?
Pada pengukuran, vite build di SvelteKit 3 ikut memvalidasi variabel yang dideklarasikan di src/env.js, jadi build tanpa KASERA_PAY_KEY dan DATABASE_URL berhenti dengan Value is missing. Build dengan nilai pengganti yang lolos validasi, misalnya kp_build dan whsec_build, berhasil, dan nilai itu tidak terbawa: aplikasi yang sama lalu dijalankan dengan secret aslinya dan memverifikasi event dengan 200. Jangan memasukkan secret live ke lingkungan build hanya untuk meloloskan langkah ini.
Apa yang dilihat pengirim kalau database sedang mati?
Galat yang dilempar dari handler dijawab 500 oleh SvelteKit dengan body {"status":500,"message":"Internal Error"}, tanpa pesan Postgres, dan galat lengkapnya tercetak di log server. 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam.
Import $lib/server/... gagal setelah upgrade ke SvelteKit 3. Apa penggantinya?
SvelteKit 3 menghapus alias $lib dan menyarankan #lib. Di proyek pengukuran, #lib dipetakan lewat field imports di package.json, yaitu "#lib/*": "./src/lib/*", dan contoh di panduan ini memakai bentuk itu. Pesan galatnya juga menyebut jalan lain: menambahkan alias $lib ke konfigurasi SvelteKit di vite.config.js.