Panduan · Terbit
Integrasi payment gateway di React Router (dulu Remix): resource route untuk webhook, middleware yang diam-diam diabaikan di v7, dan redirect ke checkout yang tidak tertangkap catch
Di React Router framework mode, penerus Remix, integrasi Kasera Pay terdiri dari dua route. Webhook adalah resource route, yaitu route dengan action tetapi tanpa komponen, yang membaca await request.text() dan memverifikasinya sebelum apa pun di-parse. Tombol bayar adalah action di halaman pesanan yang membuat transaksi lalu mengembalikan redirect() ke halaman checkout. Dua hal yang paling sering salah: middleware yang dipasang di root.tsx ikut menjaga webhook dan menjawabnya 302, dan di React Router 7 middleware yang sama tidak berjalan sama sekali tanpa future flag.
Semua perilaku di bawah diukur untuk panduan ini pada react-router dan @react-router/* 8.5.0, Vite 8.3, React 19.3, SDK kasera-pay 0.1.0, pg 8.23, Node 22, dan Postgres 16, pada build produksi yang dijalankan dengan react-router-serve, dengan 7.18.4 sebagai pembanding. Payload uji dibuat dan ditandatangani persis seperti pengirim webhook Kasera Pay. Kontraknya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Route, modul server, dan tabel dedupe
npm install kasera-pay pg// app/routes.ts
import { type RouteConfig, layout, route } from "@react-router/dev/routes";
export default [
route("webhooks/kasera-pay", "routes/webhooks.kasera-pay.ts"),
route("masuk", "routes/masuk.tsx"),
layout("routes/toko.tsx", [
route("toko/pesanan/:number", "routes/toko.pesanan.$number.tsx"),
]),
] satisfies RouteConfig;Webhook didaftarkan di luar layout toko. Alasannya ada di bagian 5. Semua yang memegang secret ditaruh di modul berakhiran .server.ts:
// app/lib/kasera.server.ts
import { KaseraPay } from "kasera-pay";
import pg from "pg";
function env(name: string, prefix = ""): string {
const value = process.env[name];
if (!value || !value.startsWith(prefix)) {
throw new Error(`${name} kosong atau tidak berawalan "${prefix}"`);
}
return value;
}
export const KASERA_PAY_WEBHOOK_SECRET = env("KASERA_PAY_WEBHOOK_SECRET", "whsec_");
export const SITE_URL = env("SITE_URL");
// kp_test_ selama membangun, kp_live_ setelah go-live.
export const kasera = new KaseraPay(env("KASERA_PAY_KEY", "kp_"));
export const pool = new pg.Pool({ connectionString: env("DATABASE_URL") });
// Tanpa listener ini, koneksi idle yang diputus Postgres (restart, failover)
// menjadi 'error' tanpa penangan dan mematikan proses Node.
pool.on("error", (err) => console.error("pg pool:", err.message));Tiga hal terukur di modul ini. Pertama, react-router build berhasil tanpa satu pun variabel lingkungan, karena process.env tetap dibaca saat aplikasi berjalan. Tanpa fungsi env(), aplikasi dengan secret kosong tetap menyala, lalu setiap pengiriman webhook dijawab 500 dengan Zero-length key is not supported di log. Dengan fungsi itu, react-router-serve berhenti dengan kode 1 sebelum mendengarkan port.
Kedua, modul .server yang terimpor dari kode klien menggagalkan build dengan Server-only module referenced by client. Import dari action, loader, dan middleware aman karena ketiganya dibuang dari bundle klien; import yang dipakai komponen tidak. Ketiga, tanpa pool.on("error"), restart Postgres mematikan seluruh proses Node dengan terminating connection due to administrator command, dan server tidak menjawab apa pun sampai dijalankan ulang. Dengan listener itu, aplikasi tetap hidup dan pengiriman berikutnya diproses.
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);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.
2. Body mentah: request.text(), bukan request.json()
Pada pengukuran, JSON.stringify(await request.json()) lolos verifikasi untuk payload biasa dan gagal 400 untuk payload yang sama dengan nama pelanggan CV Maju & Jaya. Pengirim Kasera Pay menulis &, <, dan > sebagai \u0026, \u003c, dan \u003e, sedangkan JSON.stringify menuliskannya kembali apa adanya; HMAC dihitung atas byte. Body juga hanya bisa dibaca sekali: request.json() lalu request.text() berakhir 500 dengan Body is unusable: Body has already been read.
3. Resource route webhook
// app/routes/webhooks.kasera-pay.ts
import { data } from "react-router";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
import type { Route } from "./+types/webhooks.kasera-pay";
import { KASERA_PAY_WEBHOOK_SECRET, pool } from "~/lib/kasera.server";
export async function action({ request }: Route.ActionArgs) {
// 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) {
if (err instanceof SignatureError) throw data("invalid signature", { status: 400 });
throw err;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// Primary key yang menjadi penjaga dedupe.
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") {
const 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],
);
// UPDATE tanpa baris tetap sukses di Postgres; lempar agar id event ikut di-rollback.
if (paid.rowCount !== 1) throw new Error("pesanan untuk event ini tidak ditemukan");
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err; // React Router menjawab 500, dan pengiriman diulang.
} finally {
client.release();
}
return new Response("ok");
}Yang terukur pada kode ini. Payload biasa, payload dengan tanda &, dan test.ping dijawab 200. application/json; charset=utf-8 lolos. Header dengan dua entri v1, yang muncul selama 24 jam setelah rotasi signing secret, diterima kalau salah satunya cocok, entri pertama maupun kedua. Stempel waktu 299 detik yang lalu diterima, 400 detik ditolak 400. Tanda tangan dengan secret lain ditolak 400 dan pesanannya tidak berubah. Perhatikan bentuk body-nya: throw data("invalid signature") di resource route dikirim sebagai string JSON, lengkap dengan tanda kutipnya, dengan tipe application/json.
Galat lain dari action, termasuk galat Postgres, dijawab 500 dengan body Unexpected Server Error; pesan aslinya hanya ada di log server, tidak di log pengiriman di dashboard.
4. throw data() menghentikan action, Response tanpa return tidak
// JANGAN: verifikasi buatan sendiri yang membuat Response tanpa return.
export async function action({ request }: Route.ActionArgs) {
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, pesanannya ditandai dibayar, dan log kosong. Di route bagian 3, throw yang terhapus tidak separah itu: throw err di bawahnya melempar ulang SignatureError, sehingga event palsu dijawab 500 dan pesanan tidak berubah. Tetap salah, karena 500 membuat pengiriman palsu diulang. Setelah throw data() dikembalikan, event palsu dijawab 400.
5. Origin, middleware, dan root.tsx
React Router menolak kiriman ke route UI dari origin lain: pada pengukuran, POST ke halaman pesanan dengan Origin: https://evil.example dijawab 400 untuk tipe konten apa pun. Resource route tidak diperiksa sama sekali; webhook bertanda tangan dengan origin asing, dengan text/plain, atau tanpa Content-Type tetap dijawab 200. Jadi webhook tidak perlu didaftarkan di allowedActionOrigins, dan satu-satunya penjaganya adalah tanda tangan.
// app/routes/toko.tsx
import { Outlet, redirect } from "react-router";
import type { Route } from "./+types/toko";
import { sesiSah } from "~/lib/session.server";
// Hanya route di dalam layout ini yang dijaga. Webhook ada di luarnya.
export const middleware: Route.MiddlewareFunction[] = [
async ({ request }) => {
if (!(await sesiSah(request))) throw redirect("/masuk");
},
];
export default function Toko() {
return <Outlet />;
}Middleware di layout toko menjaga halaman pesanan: tanpa sesi, GET dan POST dijawab 302 ke /masuk, dan webhook di luar layout tidak tersentuh. Yang terukur kalau middleware yang sama dipasang di root.tsx untuk semua path kecuali /masuk: setiap pengiriman webhook dijawab 302 ke halaman masuk, tidak ada event yang tercatat, dan pesanannya tidak pernah berubah. Semua route adalah anak root, resource route juga. Pengirim webhook tidak mengikuti redirect, jadi 302 itu tercatat sebagai pengiriman gagal.
Jebakan yang lebih senyap ada di React Router 7. Di 7.18.4, ekspor middleware diabaikan tanpa future: { v8_middleware: true } di react-router.config.ts. Build hanya mencetak peringatan future flag. Pada pengukuran, halaman pesanan tanpa sesi tetap dirender 200, dan POST tanpa sesi membuat transaksi lalu dialihkan ke checkout. Di v8, middleware selalu aktif dan flag itu sudah dihapus.
6. Tombol bayar: action dan redirect ke checkout
// app/routes/toko.pesanan.$number.tsx
import { Form, data, redirect } from "react-router";
import { KaseraPayError } from "kasera-pay";
import type { Route } from "./+types/toko.pesanan.$number";
import { SITE_URL, kasera, pool } from "~/lib/kasera.server";
export async function action({ params }: Route.ActionArgs) {
// 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) throw data("pesanan tidak ditemukan atau sudah dibayar", { status: 404 });
const order = rows[0];
let tx;
try {
tx = await kasera.createTransaction(
{
amount: Number(order.total), // pg mengembalikan bigint sebagai string
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 data({ error: err.code }, { status: 502 });
throw err;
}
return redirect(tx.checkout_url);
}
export default function Pesanan({ actionData }: Route.ComponentProps) {
return (
<>
<Form method="post">
<button>Bayar</button>
</Form>
{actionData?.error && <p>Pembayaran belum bisa dibuat ({actionData.error}).</p>}
</>
);
}redirect() ke domain lain tidak membutuhkan opsi apa pun, dan statusnya 302 secara default. Pada pengukuran dengan browser sungguhan, form tanpa JavaScript menerima 302 dan membuka halaman checkout. <Form> dengan JavaScript mengirim POST ke /toko/pesanan/ORD-1.data, menerima 202 berisi instruksi redirect, lalu browser memuat penuh URL checkout. Kalau API menolak, misalnya validation_failed, action menjawab 502 dan halaman menampilkan kodenya.
Soal try: return redirect() di dalam try tidak tertangkap, karena tidak ada yang dilempar. throw redirect() di dalam try tertangkap catch-nya sebagai objek Response 302, dan catch yang menelan semua galat mengubahnya menjadi 502. Karena itu contoh di atas memanggil redirect di luar try, dan catch-nya hanya menangani KaseraPayError.
Header Idempotency-Key dikirim SDK dari opsi idempotencyKey. Dua kali menekan Bayar, dengan atau tanpa JavaScript, mengirim key yang sama karena key disimpan di baris pesanan dengan COALESCE dalam satu pernyataan. Pesanan yang sudah dibayar dijawab 404. Bentuk yang sama untuk Node tanpa framework ada di panduan Node.js dan Express.
7. Dedupe di bawah pengiriman kembar bersamaan
Event yang sama dikirim 30 kali bersamaan ke build produksi, tiga kali berturut-turut. Ketiga puluhnya dijawab 200, webhook_events memuat satu baris, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Saat pemeriksaan rowCount === 1 dihapus untuk pengujian, pengiriman yang sama menghasilkan 30 pembaruan. Saat pemeriksaan paid.rowCount !== 1 dihapus, event untuk external_id yang belum ada dijawab 200 dan id-nya tercatat, lalu setelah pesanannya dibuat, pengiriman ulang terbaca sebagai duplikat dan pesanan tetap belum dibayar. Dengan kedua pemeriksaan, event itu dijawab 500, di-rollback, dan pengiriman ulang berikutnya memenuhi pesanannya. Saat tabel pesanan dihilangkan di tengah pengujian, pengiriman dijawab 500 tanpa baris event, dan setelah tabelnya kembali, pengiriman ulang dijawab 200.
8. Sebelum go-live
React Router lebih longgar soal URL daripada kebanyakan framework: pada pengukuran, /webhooks/kasera-pay/ dengan garis miring akhir dan /webhooks/Kasera-Pay sama-sama diproses 200. Tetap daftarkan URL persis seperti route-nya, wajib https dan publik. PUT, PATCH, dan DELETE juga sampai ke action; tanpa tanda tangan sah, semuanya ditolak 400. GET ke route tanpa loader dijawab 400 dan mencetak stack trace di log setiap kali ada pemindai yang mampir. Loader kecil ini menggantinya dengan 405:
// Opsional, di file yang sama: GET dijawab 405, bukan 400 dengan stack trace di log.
export function loader() {
return new Response("method not allowed", { status: 405, headers: { Allow: "POST" } });
}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 React Router 7 atau Remix 2. Apa yang berbeda?
Diukur pada 7.18.4 dengan kode yang sama, hampir semuanya identik: request.text() meloloskan payload berisi tanda &, jalan pintas JSON.stringify gagal 400, throw data() menjawab 400, resource route menerima POST dari origin asing, route UI menolaknya 400, garis miring akhir dan huruf besar tetap diproses, dan redirect eksternal dijawab 302. Satu perbedaan berbahaya: middleware hanya berjalan di v7 kalau future.v8_middleware dinyalakan. Remix 2 memakai paket @remix-run/* dan tidak diukur untuk panduan ini.
Apakah perlu menambahkan Kasera Pay ke allowedActionOrigins?
Tidak. Pemeriksaan origin hanya berjalan untuk route UI, yaitu route yang punya komponen. Webhook adalah resource route, dan pada pengukuran pengiriman bertanda tangan dengan header Origin asing tetap dijawab 200 apa pun Content-Type-nya. Yang menjaga webhook adalah tanda tangannya, bukan origin. Tombol bayar dikirim dari halaman toko sendiri, jadi origin-nya sama dan lolos tanpa konfigurasi.
Apa yang dilihat pengirim kalau database mati?
Galat yang dilempar dari action dijawab 500 dengan body Unexpected Server Error, tanpa pesan Postgres; pesan lengkapnya hanya di log server. Pengiriman lalu diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam, dan pengiriman pertama setelah database kembali dijawab 200.