Panduan · Terbit
Integrasi payment gateway di Node.js dan Express: satu urutan middleware yang menentukan semua pengiriman webhook ditolak atau diterima
Panduan ini memasang Kasera Pay di aplikasi Node.js yang memakai Express, tanpa SDK dan tanpa package tambahan apa pun di luar Express sendiri. Yang dipakai hanya fetch yang sudah ada di Node sejak versi 18 dan node:crypto bawaan. Tidak ada SDK Node.js resmi Kasera Pay, dan tidak ada yang perlu dipasang lewat npm untuk mengikuti panduan ini.
Satu hal di bawah ini yang paling sering membuat integrasi Express gagal, dan penyebabnya bukan kriptografi melainkan urutan: express.raw() pada route webhook harus terpasang sebelum express.json() yang global, karena parser yang jalan lebih dulu yang memenangkan body dan body sebuah permintaan hanya bisa dibaca satu kali. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay, dan ringkasan metode aktif beserta tarifnya di halaman API QRIS untuk developer.
Kalau aplikasinya bukan Express, kontraknya sama persis dan yang berbeda hanya perkakasnya: integrasi payment gateway di Next.js App Router, 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 webhook, dan hanya event itulah yang menjadi penentu bahwa uangnya masuk.
Kepulangan pembeli ke return_url bukan bukti pembayaran, karena halaman itu bisa dibuka siapa saja termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penuhi pesanan pada webhook. Kalau webhook bukan pilihan yang tepat untuk aplikasi ini, pertimbangannya dibandingkan utuh di perbandingan menunggu webhook melawan menanyakan status berkala.
1. Kredensial dan versi
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. Mode live dan mode tes adalah endpoint terpisah dengan secret masing-masing, dan secret tes tidak pernah bisa memverifikasi payload live.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.idContoh di bawah ditulis untuk Express 5, versi stabil saat panduan ini ditulis, yang mensyaratkan Node 18 ke atas. Bagian yang berbeda di Express 4 disebut di tempatnya, dan ada satu perbedaan yang bukan kosmetik sama sekali.
2. Klien kecil yang membungkus dua endpoint
Satu berkas untuk dua endpoint yang benar-benar dipakai. Tidak ada HTTP client yang perlu dipasang, karena fetch sudah menjadi bagian dari Node.
// kasera-pay.js
// fetch sudah ada di Node sejak versi 18, jadi tidak ada HTTP client yang
// perlu dipasang. Express 5 sendiri mensyaratkan Node 18 ke atas.
const BASE_URL = process.env.KASERA_PAY_BASE_URL || "https://pay.kasera.id";
async function createTransaction(input, idempotencyKey) {
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, dan satu-satunya hal yang mencegah satu pesanan
// menjadi dua pembayaran. Dibuat sekali, lalu dipakai apa adanya pada
// setiap percobaan ulang.
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(input),
});
const body = await res.json();
if (!res.ok) {
const err = new Error("Kasera Pay " + res.status + " " + (body?.error?.code || "unknown"));
err.status = res.status;
err.code = body?.error?.code;
throw err;
}
return body;
}
async function getTransaction(id) {
const res = await fetch(BASE_URL + "/v1/transactions/" + id, {
headers: { Authorization: "Bearer " + process.env.KASERA_PAY_KEY },
});
if (!res.ok) throw new Error("Kasera Pay " + res.status);
return res.json();
}
module.exports = { createTransaction, getTransaction };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. Urutan middleware, dan kenapa ini bagian terpenting
Bentuk yang paling lazim di aplikasi Express adalah memasang app.use(express.json()) sekali di dekat bagian atas berkas, lalu menaruh seluruh route di bawahnya. Bentuk itu benar untuk semua route kecuali satu, dan route webhook adalah pengecualiannya.
// app.js
const express = require("express");
const { createTransaction } = require("./kasera-pay");
const webhook = require("./webhook");
const app = express();
// URUTAN DI BAWAH INI YANG MENENTUKAN SEGALANYA.
// Route webhook dipasang lebih dulu, dengan parser mentahnya sendiri.
// Kalau express.json() di bawahnya naik ke atas baris ini, body sudah
// habis terbaca dan express.raw() pada route webhook tidak lagi
// menghasilkan Buffer.
app.post("/webhooks/kasera-pay", express.raw({ type: "application/json" }), webhook);
// Baru setelah itu parser JSON untuk seluruh route lain.
app.use(express.json());
app.post("/pesanan/:id/bayar", async (req, res) => {
const order = await db.orders.find(req.params.id);
// Key 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.orders.update(order.id, { idempotency_key: key });
}
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.SITE_URL + "/pesanan/" + order.number,
payment_methods: ["qris", "va_bca"],
},
key,
);
await db.orders.update(order.id, { payment_request_id: tx.id });
res.redirect(303, tx.checkout_url);
});
app.listen(3000);Body sebuah permintaan HTTP adalah stream yang habis begitu dibaca. Parser mana pun yang jalan lebih dulu yang membacanya, dan setelah itu byte aslinya tidak tersimpan di mana pun. Kalau express.json() global berada di atas route webhook, maka saat express.raw() pada route itu dipanggil, tidak ada lagi yang bisa dibacanya: req.body sudah berisi objek JavaScript hasil parsing, dan pemeriksaan Buffer.isBuffer mengembalikan false.
Akibatnya bukan galat yang terbaca sebagai galat. Menyusun objek itu kembali menjadi string lewat JSON.stringify menghasilkan teks yang isinya sama tetapi byte-nya tidak: spasi dan baris baru pada payload asli hilang, dan panjangnya berubah. HMAC dihitung atas byte, jadi tanda tangan yang dihitung ulang tidak akan pernah cocok, sementara di log payload-nya tetap terlihat benar dan masuk akal. Yang terlihat oleh developer adalah seluruh pengiriman yang sah ditolak dengan alasan tanda tangan tidak valid, padahal secret-nya benar.
Karena itu route webhook dipasang lebih dulu dengan parser mentahnya sendiri, dan express.json() menyusul di bawahnya. Alternatifnya adalah memberi express.json() opsi verify untuk menyimpan salinan Buffer mentah, tetapi cara di atas lebih sulit dirusak oleh orang berikutnya yang memindahkan satu baris.
4. Route webhook
Tiga hal terjadi di sini sebelum apa pun disentuh: body mentah dipastikan benar-benar mentah, tanda tangannya diverifikasi, lalu id event didaftarkan supaya pengiriman kedua dari event yang sama tidak mengerjakan pesanannya dua kali.
// webhook.js
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
module.exports = async function webhook(req, res) {
// express.raw() menaruh Buffer di req.body. Kalau Content-Type yang
// datang tidak cocok dengan type parser-nya, req.body justru menjadi
// objek kosong {}, bukan Buffer, dan {}.toString() menghasilkan
// "[object Object]" yang gagal verifikasi dengan pesan membingungkan.
// Satu baris ini yang membedakan keduanya.
if (!Buffer.isBuffer(req.body)) {
return res.status(400).send("raw body required");
}
const raw = req.body.toString("utf8");
const signature = req.get("Kasera-Signature-V1");
const secret = process.env.KASERA_PAY_WEBHOOK_SECRET;
if (!signature || !secret || !verify(raw, signature, secret)) {
// Selain 2xx berarti ditolak, dan pengiriman akan diulang. Itu memang
// yang diinginkan kalau yang salah adalah secret yang terpasang.
return res.status(400).send("invalid signature");
}
const event = JSON.parse(raw);
// 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 kolomnya yang menjadi penjaga, bukan pemeriksaan
// "sudah ada atau belum" yang dua pengiriman bersamaan akan lewati.
try {
await db.webhookEvents.insert({ id: event.id, type: event.type });
} catch (err) {
if (isUniqueViolation(err)) return res.sendStatus(200);
throw err;
}
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.
res.sendStatus(200);
};
function verify(rawBody, header, secret) {
// 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);
});
}Pemeriksaan Buffer.isBuffer di baris pertama bukan kehati-hatian berlebihan, melainkan penjaga untuk keadaan kedua yang bentuknya persis sama tetapi sebabnya berbeda. express.raw() hanya bekerja pada permintaan yang Content-Type-nya cocok dengan type yang diberikan. Kalau yang datang bertipe lain, parser itu melewatkannya begitu saja dan req.body tetap pada nilai bawaannya, yaitu objek kosong. Bukan undefined, sehingga tidak ada galat yang terlempar sendiri, dan memanggil toString() atasnya menghasilkan teks “[object Object]” yang gagal verifikasi dengan pesan yang tidak menunjuk ke sebab sebenarnya sama sekali.
Verifikasinya sendiri sama di semua bahasa. Header Kasera-Signature-V1 berisi t=<unix>,v1=<hex>, yang ditandatangani adalah t dan body mentah yang disambung dengan satu titik, toleransi waktunya lima menit, dan selama rotasi signing secret bisa muncul lebih dari satu entri v1 sehingga cocok dengan salah satunya sudah cukup. Perbandingan memakai timingSafeEqual, yang menuntut panjang kedua Buffer sama lebih dulu karena fungsi itu melempar kalau panjangnya berbeda.
Dedupe bersandar pada unique index di database, bukan pada pemeriksaan “sudah ada atau belum” sebelum penulisan: dua pengiriman event yang sama bisa tiba bersamaan dan keduanya akan membaca “belum ada” sebelum salah satunya sempat menulis.
5. Error handler, dan satu perbedaan Express 4 yang bukan kosmetik
// Dipasang paling bawah, setelah seluruh route.
// Di Express 5 galat yang dilempar dari handler async sampai ke sini
// dengan sendirinya. Di Express 4 tidak: galatnya menjadi unhandled
// rejection dan permintaannya menggantung tanpa jawaban sama sekali.
app.use((err, req, res, next) => {
console.error(err);
// 500 pada route webhook berarti "coba lagi nanti", dan itu benar:
// database yang sedang tidak bisa dihubungi bukan alasan menyatakan
// event ini selesai.
res.status(500).json({ error: "internal" });
});Di Express 5, galat yang dilempar dari sebuah handler async diteruskan sendiri ke error handler di atas. Pengirimnya menerima 500, dan 500 berarti pengiriman diulang sesuai jadwal percobaan ulang, yang memang perilaku yang benar untuk database yang sedang tidak bisa dihubungi.
Di Express 4 hal yang sama tidak terjadi. Galat dari handler async menjadi unhandled rejection pada level proses, error handler tidak pernah dipanggil, dan permintaannya menggantung tanpa jawaban apa pun sampai kehabisan waktu. Bedanya nyata bagi integrasi pembayaran: satu kegagalan sesaat yang seharusnya menghasilkan percobaan ulang berubah menjadi pengiriman yang tidak pernah dijawab, dan jatah percobaannya habis oleh waktu tunggu dan bukan oleh kegagalan yang terbaca. Di Express 4, bungkus handler async supaya galatnya sampai ke next().
// Hanya untuk Express 4. Tidak diperlukan di Express 5.
const asyncRoute = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.post(
"/webhooks/kasera-pay",
express.raw({ type: "application/json" }),
asyncRoute(webhook),
);6. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, jadi localhost tidak bisa didaftarkan langsung dan selama membangun perlu terowongan yang menerbitkannya ke alamat publik. Satu mode boleh memegang sampai lima endpoint, masing-masing dengan URL, nama, dan signing secret sendiri.
Tiga hal yang layak dicoba di mode tes. Pertama, pengiriman yang sah harus diterima: kalau seluruhnya ditolak, hampir selalu urutan middleware di bagian 3 yang penyebabnya. Kedua, event yang sama dikirim dua kali harus menghasilkan satu pesanan terpenuhi dan satu balasan 200 tanpa kerja tambahan. Ketiga, kegagalan database di tengah harus menghasilkan 500 dan bukan permintaan yang menggantung, yang merupakan cara paling cepat memastikan pembungkus async di Express 4 benar-benar terpasang. Perlu diketahui juga bahwa masa berlaku permintaan pembayaran default 60 menit dengan plafon akun 24 jam, dan kedaluwarsa tidak pernah dikirim sebagai webhook, sehingga pelepasan stok dijalankan dari expires_at yang disimpan sendiri.
Pertanyaan yang sering muncul
Kenapa express.raw harus dipasang sebelum express.json, bukan sesudahnya?
Karena body sebuah permintaan HTTP adalah stream yang hanya bisa dibaca satu kali, dan parser yang jalan lebih dulu yang membacanya. Kalau express.json() dipasang sebagai middleware global di atas route webhook, parser itu sudah menghabiskan stream-nya dan mengisi req.body dengan objek JavaScript sebelum express.raw() pada route webhook sempat jalan, sehingga req.body bukan lagi Buffer dan byte aslinya sudah tidak ada di mana pun. Yang ditandatangani Kasera Pay adalah byte persis seperti yang dikirim, jadi menyusun ulang objek itu menjadi string menghasilkan HMAC yang berbeda dan setiap pengiriman yang sah ikut ditolak.
Apakah Kasera Pay punya SDK Node.js atau package npm resmi?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai fetch yang sudah ada di Node sejak versi 18 dan modul node:crypto bawaan. Tidak ada package yang perlu dipasang di luar Express sendiri, dan tidak ada rilis package yang perlu ditunggu setiap kali API bertambah.
Apa bedanya Express 4 dan Express 5 untuk route webhook?
Perbedaan yang paling menentukan ada pada galat yang dilempar dari handler async. Di Express 5, galat seperti itu diteruskan sendiri ke error handler, sehingga pengirimnya menerima 500 dan pengiriman diulang sesuai jadwal percobaan ulang. Di Express 4, galat yang sama menjadi unhandled rejection dan permintaannya menggantung tanpa jawaban apa pun sampai kehabisan waktu, sehingga sebuah kegagalan database berubah menjadi pengiriman yang tidak pernah dijawab. Di Express 4, bungkus handler async dengan pembungkus yang memanggil next(err), atau pakai satu package pembungkus.
Kenapa req.body bisa menjadi objek kosong padahal express.raw sudah dipasang?
Karena express.raw({ type: "application/json" }) hanya bekerja pada permintaan yang Content-Type-nya cocok dengan type tersebut. Kalau yang datang bertipe lain, parser itu melewatkannya dan req.body tetap pada nilai bawaannya, yaitu objek kosong, bukan Buffer dan bukan undefined. Memanggil toString() atasnya menghasilkan teks "[object Object]" yang tentu saja gagal verifikasi, dengan pesan galat yang tidak menunjuk ke sebab sebenarnya. Periksa Buffer.isBuffer(req.body) lebih dulu dan tolak yang bukan Buffer dengan pesannya sendiri.
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 kepulangan sebaiknya membaca ulang status dari server, bukan menyimpulkannya dari fakta bahwa halamannya terbuka.
Perlukah membalas 200 lebih dulu lalu mengerjakan pesanannya di belakang?
Untuk pekerjaan yang selesai dalam hitungan detik, tidak perlu: kerjakan lalu balas, supaya kegagalan benar-benar menghasilkan percobaan ulang. Pemisahan itu baru berguna kalau pemenuhan pesanan memang lama, misalnya memanggil layanan lain yang lambat. Kalau dipisah, dedupe id event tetap harus terjadi sebelum membalas 200, karena setelah 200 terkirim tidak ada lagi percobaan ulang yang bisa menyelamatkan pekerjaan yang gagal di belakang.