Panduan · 12 September 2026
Integrasi payment gateway di Next.js App Router: route handler yang membaca body mentah, dan kunci API yang tidak pernah menyeberang ke peramban
Panduan ini memasang Kasera Pay di aplikasi Next.js 16 dengan App Router, tanpa SDK dan tanpa package tambahan apa pun. Yang dipakai hanya fetch bawaan dan node:crypto yang sudah ada di Node. Tidak ada SDK JavaScript resmi Kasera Pay, dan tidak ada yang perlu dipasang lewat npm untuk mengikuti panduan ini.
Empat hal di bawah adalah jebakan khas framework ini, bukan jebakan umum integrasi pembayaran, dan ketiga panduan bahasa lain di situs ini tidak membahasnya: body mentah yang harus diambil dengan req.text(), awalan NEXT_PUBLIC_ yang menyalin kunci API ke peramban, redirect() yang tertelan blok catch, dan middleware yang diam-diam menelan pengiriman webhook. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay, dan ringkasan metode aktif beserta tarifnya di halaman API QRIS untuk developer.
Kalau aplikasinya bukan Next.js, kontraknya sama persis dan yang berbeda hanya perkakasnya: integrasi payment gateway di Laravel dan integrasi payment gateway di PHP tanpa framework mengerjakan hal yang sama.
Alurnya, sebelum menulis kode
Ada empat hal yang bergerak, dan urutannya menentukan apa yang boleh dipercaya. Server membuat permintaan pembayaran. Pembeli dibawa ke checkout_url. Pembeli membayar di sana. Lalu Kasera Pay mengirim payment.paid bertanda tangan ke route handler webhook, dan hanya event itulah yang menjadi penentu bahwa uangnya masuk.
Kepulangan pembeli ke return_url bukan bukti pembayaran. Halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penuhi pesanan pada webhook, bukan pada kepulangan. Apa yang benar-benar dilihat pembeli di antara kedua titik itu ada di penelusuran halaman pembayaran dari sisi pembeli.
1. Kredensial dan tempat menaruhnya
API key dibawa sebagai bearer token dan berawalan kp_test_ selama membangun, kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer.
# .env.local: kp_test_ selama membangun, kp_live_ setelah go-live.
# Tanpa awalan NEXT_PUBLIC_. Awalan itu menyalin nilainya ke bundle peramban.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.idJebakan pertama ada di nama variabelnya, bukan di nilainya. Awalan NEXT_PUBLIC_ adalah instruksi kepada Next.js untuk menyalin nilai variabel itu ke dalam bundle JavaScript yang dikirim ke peramban. Kunci API yang diberi awalan itu akan terbaca dari devtools oleh siapa pun yang membuka halaman, dan tidak ada peringatan apa pun saat itu terjadi. Kunci pembayaran tidak pernah memakai awalan tersebut.
Modul yang memegang kunci itu juga sebaiknya tidak mungkin diimpor dari komponen klien. Cara paling murah adalah disiplin impor, dan cara yang dipaksakan compiler adalah menambahkan satu package kecil bernama server-only lalu mengimpornya di baris pertama modul tersebut, sehingga build gagal kalau modul itu tersentuh dari komponen berlabel “use client”. Satu-satunya dependensi yang disebut panduan ini, dan sifatnya pilihan.
2. Klien kecil di sisi server
Satu berkas yang membungkus dua endpoint yang benar-benar dipakai. Perhatikan cache: “no-store” pada keduanya: fetch di dalam Next.js berjalan di atas Data Cache, dan sebuah create tidak boleh dijawab dari cache dalam keadaan apa pun.
// lib/kasera-pay.ts
// Hanya dipanggil dari kode server: server action, route handler, atau
// server component. Tidak pernah diimpor dari komponen berlabel "use client".
const BASE_URL = process.env.KASERA_PAY_BASE_URL ?? "https://pay.kasera.id";
export type CreateInput = {
amount: number;
description?: string;
external_id?: string;
merchant_ref?: string;
customer?: { name?: string; email?: string; phone?: string };
order_items?: { name: string; price: number; quantity: number }[];
return_url?: string;
payment_methods?: string[];
expires_in_minutes?: number;
};
export type Transaction = {
id: string;
status: "pending" | "succeeded" | "expired" | "canceled" | "failed";
amount: number;
fee: number;
net: number;
checkout_url: string;
expires_at: string;
};
export async function createTransaction(
input: CreateInput,
idempotencyKey: string,
): Promise<Transaction> {
const res = await fetch(BASE_URL + "/v1/transactions", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.KASERA_PAY_KEY,
"Content-Type": "application/json",
// Opsional di API, tetapi satu-satunya yang mencegah pembayaran ganda.
// Harus dibuat sekali lalu dipakai lagi apa adanya pada setiap retry.
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(input),
// Data Cache tidak boleh menjawab sebuah create.
cache: "no-store",
});
const body = await res.json();
if (!res.ok) {
const code = body?.error?.code ?? "unknown_error";
throw new Error("Kasera Pay " + res.status + " " + code);
}
return body as Transaction;
}
export async function getTransaction(id: string): Promise<Transaction> {
const res = await fetch(BASE_URL + "/v1/transactions/" + id, {
headers: { Authorization: "Bearer " + process.env.KASERA_PAY_KEY },
cache: "no-store",
});
if (!res.ok) throw new Error("Kasera Pay " + res.status);
return (await res.json()) as Transaction;
}Hanya Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional, dan tanpa header itu setiap percobaan menjadi permintaan pembayaran tersendiri. external_id dan merchant_ref hanya label yang disimpan, dikembalikan, dan bisa difilter; keduanya tidak pernah menyatakan bahwa dua permintaan adalah satu pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
3. Membuat permintaan dari server action
Server action adalah tempat paling wajar untuk ini di App Router, karena kodenya hanya hidup di server sementara pemanggilnya bisa berupa form di komponen klien. Kuncinya tidak pernah menyeberang.
// app/keranjang/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createTransaction } from "@/lib/kasera-pay";
import { db } from "@/lib/db";
export async function bayarPesanan(orderId: string) {
const order = await db.order.findUniqueOrThrow({ where: { id: orderId } });
// Key dibuat sekali dan disimpan bersama pesanannya. Key baru pada setiap
// percobaan menghapus proteksinya tanpa galat apa pun yang memberi tahu.
let key = order.idempotency_key;
if (!key) {
key = crypto.randomUUID();
await db.order.update({ where: { id: order.id }, data: { idempotency_key: key } });
}
let checkoutURL: string;
try {
const tx = await createTransaction(
{
amount: order.total,
description: "Pesanan " + order.number,
external_id: order.number,
customer: { name: order.customer_name, email: order.customer_email },
return_url: process.env.NEXT_PUBLIC_SITE_URL + "/pesanan/" + order.number,
payment_methods: ["qris", "va_bca"],
},
key,
);
await db.order.update({
where: { id: order.id },
data: { payment_request_id: tx.id },
});
checkoutURL = tx.checkout_url;
} catch (err) {
console.error(err);
return { error: "Pembayaran belum bisa dibuat. Coba lagi sebentar." };
}
// DI LUAR blok try. redirect() bekerja dengan melempar error internal
// (NEXT_REDIRECT), jadi memanggilnya di dalam try akan ditelan catch di
// atas dan pembeli tidak pergi ke mana-mana.
redirect(checkoutURL);
}Baris terakhir adalah jebakan kedua, dan yang paling sering menghabiskan waktu karena tidak menghasilkan galat apa pun. redirect() tidak mengembalikan nilai melainkan melempar error internal bertanda NEXT_REDIRECT yang ditangani framework di tingkat atas. Memanggilnya di dalam blok try berarti catch di bawahnya menangkap error itu lebih dulu, pesan galat yang tidak relevan dicatat, dan pembeli tetap diam di halaman yang sama sementara permintaan pembayarannya sudah terlanjur dibuat. Simpan checkout_url ke variabel di dalam try, panggil redirect() setelahnya.
4. Route handler webhook, dan jebakan nomor satu
Tanda tangan dihitung atas byte payload persis seperti yang dikirim. Membacanya dengan req.json() menghasilkan objek JavaScript, dan menyusunnya kembali menjadi string mengubah spasi serta bisa mengubah urutan kunci. HMAC-nya lalu berbeda, setiap pengiriman yang sah ditolak, dan gejalanya membingungkan karena payload-nya terlihat benar di log. Ambil await req.text() lebih dulu, verifikasi string itu, baru JSON.parse dari string yang sama. Body sebuah Request hanya bisa dibaca satu kali, jadi urutannya tidak bisa dibalik.
// app/api/webhooks/kasera-pay/route.ts
import crypto from "node:crypto";
import { db } from "@/lib/db";
import { penuhiPesanan } from "@/lib/orders";
// node:crypto butuh runtime Node. Ini bawaannya, tetapi ditulis eksplisit
// supaya setelan runtime di tempat lain tidak diam-diam memindahkannya.
export const runtime = "nodejs";
const TOLERANCE_SECONDS = 300;
type Event = {
id: string;
type: string;
livemode: boolean;
data: {
payment_request_id: string;
external_id?: string;
merchant_ref?: string;
amount: number;
paid_at: string;
};
};
export async function POST(req: Request) {
// Byte persis seperti yang dikirim. req.json() menghasilkan objek, dan
// menyusunnya kembali menjadi string mengubah spasi serta urutan kunci,
// sehingga HMAC-nya berbeda dan pengiriman yang sah ikut tertolak.
const raw = await req.text();
const signature = req.headers.get("kasera-signature-v1");
const secret = process.env.KASERA_PAY_WEBHOOK_SECRET;
if (!signature || !secret || !verify(raw, signature, secret)) {
// 4xx berarti ditolak. Kasera Pay akan mencoba ulang, dan itu memang
// yang diinginkan kalau secret-nya yang salah dipasang.
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(raw) as Event;
// Dedupe berdasarkan id event. Pengiriman bersifat at-least-once, jadi
// event yang sama bisa datang lebih dari sekali dengan id yang sama.
// Unique index pada kolom id yang menjadi penjaganya, bukan pemeriksaan
// "sudah ada atau belum" yang dua permintaan bersamaan akan lewati.
try {
await db.webhookEvent.create({ data: { id: event.id, type: event.type } });
} catch {
return new Response(null, { status: 200 });
}
if (event.type === "payment.paid") {
await penuhiPesanan(event.data.external_id, event.data.payment_request_id);
}
// 2xx apa pun menghentikan percobaan ulang. Selain 2xx, pengiriman diulang
// dengan exponential backoff sampai tujuh percobaan dalam kurang lebih
// 33 jam.
return new Response(null, { status: 200 });
}
function verify(rawBody: string, header: string, secret: string): boolean {
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
const parts = header.split(",");
const t = Number(parts[0].slice(2));
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
// Beberapa entri v1 muncul selama rotasi signing secret: secret lama dan
// secret baru menandatangani payload yang sama, dan cocok dengan salah
// satunya sudah cukup.
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = Buffer.from(p.slice(3));
const exp = Buffer.from(expected);
return sig.length === exp.length && crypto.timingSafeEqual(sig, exp);
});
}Dedupe-nya bersandar pada unique index, bukan pada pemeriksaan “sudah ada atau belum” yang dilakukan terpisah dari penyisipannya. Dua pengiriman event yang sama yang datang bersamaan akan lolos dari pemeriksaan seperti itu bersama-sama, karena keduanya membaca keadaan yang sama sebelum salah satunya menulis. Yang menolak penyisipan kedua harus basis datanya sendiri.
Perhatikan juga bahwa kedaluwarsa tidak pernah dikirim sebagai webhook. Satu-satunya event yang datang adalah payment.paid, jadi pelepasan stok atau pembatalan pesanan yang tidak dibayar dikerjakan sendiri dari expires_at yang sudah dipegang saat pembuatan. Pembahasan lengkap perilaku pengirimannya ada di dokumentasi webhook.
5. Middleware yang menelan pengiriman
Jebakan ketiga muncul di aplikasi yang sudah punya middleware.ts, misalnya untuk otentikasi atau pemilihan bahasa. Matcher bawaan yang ditulis orang biasanya mencakup seluruh path kecuali aset statis, dan itu termasuk endpoint webhook. Akibatnya pengiriman dari Kasera Pay dijawab dengan pengalihan ke halaman masuk, dianggap gagal, lalu diulang sampai tujuh kali dan berhenti. Kecualikan path-nya secara eksplisit.
// middleware.ts: matcher yang mengecualikan route webhook.
export const config = {
matcher: [
// Semua kecuali aset statis DAN kecuali endpoint webhook.
"/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)",
],
};Jebakan keempat sejenis dan datang dari platform, bukan dari framework: sebagian platform hosting memasang proteksi pratinjau pada seluruh deployment non-produksi. Endpoint webhook di lingkungan pratinjau lalu meminta otentikasi dan tidak pernah menerima satu pun pengiriman. Pakai URL produksi untuk endpoint mode live, dan untuk pengembangan lokal pakai tunnel, seperti dijelaskan di menerima webhook di localhost.
6. Halaman kepulangan yang membaca ulang, bukan menyimpulkan
Halaman return_url sebaiknya sebuah server component yang membaca status pesanan dari basis data sendiri, atau memanggil getTransaction dengan cache: “no-store”. Yang tidak boleh adalah menyimpulkan lunas dari fakta bahwa halamannya terbuka, atau dari parameter apa pun di URL-nya.
Perlu diingat bahwa webhook bisa datang beberapa detik setelah pembeli tiba di halaman itu. Halaman yang menuntut status akhir pada render pertama akan kadang menampilkan “belum dibayar” kepada orang yang baru saja membayar. Tampilkan keadaan menunggu yang jujur dan biarkan halamannya menyegarkan diri, atau baca langsung ke API untuk pesanan yang statusnya masih menggantung.
7. Mode tes dan yang diperiksa sebelum go-live
Kunci kp_test_ membuat objek yang membawa livemode: false, dengan webhook yang dikirim ke endpoint mode tes beserta signing secret-nya sendiri. Secret mode tes tidak pernah bisa memverifikasi payload mode live, jadi kesalahan memasang secret akan terlihat sebagai penolakan, bukan sebagai penerimaan yang salah.
Empat hal yang layak dijalankan sekali sebelum berpindah ke kp_live_: pengiriman dengan secret yang salah harus ditolak, pengiriman dengan stempel waktu lebih dari lima menit harus ditolak, satu event yang dikirim dua kali harus memenuhi pesanan satu kali saja, dan tautan yang dibiarkan sampai lewat masa berlaku harus membuat pesanannya dilepas oleh pekerjaan terjadwal sendiri, bukan oleh webhook yang tidak akan datang. Urutan lengkapnya ada di checklist sebelum go-live.
Kesalahan yang paling sering muncul
Membuat Idempotency-Key baru pada setiap percobaan. Key yang dibuat di dalam fungsi pembuat permintaan terlihat rapi dan menghapus seluruh proteksinya tanpa satu pun galat. Key milik pesanan, bukan milik percobaan, jadi tempatnya di basis data bersama pesanan itu.
Memverifikasi tanda tangan dengan perbandingan string biasa. Gunakan timingSafeEqual, dan periksa panjangnya lebih dulu karena fungsi itu melempar kalau kedua buffer berbeda panjang.
Menjawab webhook dengan 500 saat pemenuhan pesanan gagal karena alasan yang tidak akan pernah berubah. Pengiriman akan diulang tujuh kali untuk kesalahan yang sama. Pisahkan penerimaan event dari pemenuhan pesanannya: catat event-nya, jawab 200, lalu kerjakan pemenuhannya dengan antrian yang punya penanganan galatnya sendiri.
Menuliskan daftar metode pembayaran secara permanen di kode. Yang aktif pada sebuah akun bisa berubah tanpa rilis di sisi mana pun, jadi bacalah dari GET /v1/payment_methods. Menyebut kode yang tidak aktif pada akun akan ditolak 422 payment_method_unavailable, bukan diabaikan diam-diam.
Selanjutnya
Alur di atas sudah cukup untuk toko yang menerima pembayaran dan memenuhi pesanan sendiri. Kalau tagihannya berulang setiap bulan, susunannya berbeda dan dibahas di panduan tagihan langganan bulanan. Kalau integrasinya baru akan menggantikan tautan yang selama ini dibuat manual, dari tautan manual ke integrasi API menjelaskan kapan perpindahan itu benar-benar sepadan.
Pertanyaan yang sering muncul
Apakah Kasera Pay punya SDK JavaScript atau package npm resmi?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai fetch bawaan dan modul node:crypto yang sudah ada di Node. Tidak ada package yang perlu dipasang, dan tidak ada rilis package yang perlu ditunggu setiap kali API bertambah.
Kenapa webhook harus dibaca dengan req.text(), bukan req.json()?
Karena yang ditandatangani adalah byte persis seperti yang dikirim. req.json() menghasilkan objek JavaScript, dan menyusunnya kembali menjadi string mengubah spasi serta bisa mengubah urutan kunci, sehingga HMAC yang dihitung ulang berbeda dan pengiriman yang sah ikut ditolak. Body sebuah Request hanya bisa dibaca sekali, jadi ambil req.text() lebih dulu lalu JSON.parse dari string yang sama itu.
Kenapa kunci API tidak boleh diberi awalan NEXT_PUBLIC_?
Karena awalan itu adalah instruksi kepada Next.js untuk menyalin nilainya ke dalam bundle JavaScript yang dikirim ke peramban, tempat siapa pun bisa membacanya dari devtools. Variabel tanpa awalan itu hanya ada di proses server. Kunci API Kasera Pay hanya dipakai dari server action, route handler, atau server component.
Kenapa redirect() tidak boleh dipanggil di dalam blok try?
Karena redirect() bekerja dengan melempar error internal bertanda NEXT_REDIRECT yang ditangkap framework di tingkat atas. Blok catch di sekitarnya akan menelan error itu lebih dulu, sehingga perpindahan halamannya tidak pernah terjadi dan pembeli diam di halaman yang sama. Panggil setelah blok try selesai, dengan nilai yang disimpan ke variabel di dalamnya.
Apakah kepulangan pembeli ke return_url boleh dipakai sebagai tanda lunas?
Tidak. Halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penanda lunas hanya event payment.paid yang bertanda tangan, atau pembacaan langsung ke GET /v1/transactions/{id} dari server. Halaman return_url sebaiknya menampilkan status yang dibaca ulang dari server, bukan status yang disimpulkan dari fakta bahwa halamannya terbuka.
Versi Next.js berapa yang dipakai contoh ini?
Next.js 16.3 dengan App Router dan React 19. Bagian yang paling terikat versi adalah server action dan route handler, keduanya App Router. Pada Pages Router kontraknya sama tetapi body mentah diambil dengan cara lain, karena api route di sana mem-parse body secara otomatis dan parsing itu harus dimatikan lebih dulu lewat config bodyParser.