Panduan · Terbit
Integrasi payment gateway di Fastify: parser body mentah yang hanya hidup di satu plugin, dan return yang menentukan event palsu ditolak atau tetap diproses
Integrasi Kasera Pay di Fastify bergantung pada dua hal yang khas Fastify, dan keduanya tidak terlihat sebagai galat saat salah. Pertama, route webhook butuh body mentah untuk verifikasi tanda tangan, sementara parser JSON bawaan Fastify sudah mengubahnya menjadi objek; jawabannya adalah parser application/json berbentuk Buffer yang dipasang di dalam satu plugin terenkapsulasi, sehingga route lain tidak tersentuh. Kedua, di handler async, reply.send() tanpa return tidak menghentikan handler: balasan 400 terkirim, lalu kode di bawahnya tetap menandai pesanan lunas.
Semua perilaku di bawah diukur untuk panduan ini pada Fastify 5.12.7, Node 22, pg 8.23, dan Postgres 16, lewat listener HTTP sungguhan maupun app.inject(). Kontraknya sama dengan panduan Node.js dan Express, termasuk klien kecil berbasis fetch untuk membuat permintaan pembayaran, jadi bagian itu tidak diulang di sini. Referensi endpoint lengkapnya ada di dokumentasi webhook.
1. Kredensial dan tabel dedupe
API key berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook diambil dari dashboard, menu Developer, dan berbeda per mode: 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.id
DATABASE_URL=postgres://...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. Kenapa JSON.stringify(request.body) tidak cukup
Jalan pintas yang paling sering dicoba adalah membiarkan parser bawaan bekerja, lalu memverifikasi JSON.stringify(request.body). Pada pengukuran, jalan pintas itu lolos untuk payload biasa. Itulah yang membuatnya berbahaya: ia lolos di mode tes, lolos di minggu pertama live, lalu gagal pada pelanggan pertama yang namanya memuat tanda &.
Payload webhook Kasera Pay disusun oleh encoder JSON yang menulis &, <, dan > sebagai \u0026, \u003c, dan \u003e. Setelah di-parse, nilainya tetap “CV Maju & Jaya”, tetapi JSON.stringify menuliskannya kembali dengan karakter & apa adanya. Isinya sama, byte-nya berbeda, dan HMAC dihitung atas byte. Pada pengujian, payload yang sama persis kecuali satu nama pelanggan berpindah dari 200 menjadi 400. Tidak ada cara memperbaikinya dari objek; verifikasi harus memakai byte yang benar-benar diterima.
3. Plugin webhook dengan parser Buffer-nya sendiri
Di Fastify, content type parser ikut aturan enkapsulasi: parser yang dipasang di dalam sebuah plugin hanya berlaku untuk route di plugin itu dan turunannya. Itu persis yang dibutuhkan. Route webhook menerima Buffer, seluruh route lain tetap menerima objek.
// webhook.js
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
module.exports = async function webhookRoutes(app, opts) {
const { pool } = opts;
// Parser ini hanya berlaku di dalam plugin ini. Route lain di aplikasi
// tetap memakai parser JSON bawaan Fastify dan tetap menerima objek.
app.addContentTypeParser(
"application/json",
{ parseAs: "buffer" },
(request, body, done) => done(null, body),
);
app.post("/webhooks/kasera-pay", async (request, reply) => {
if (!Buffer.isBuffer(request.body)) {
return reply.code(400).send("raw body required");
}
const raw = request.body.toString("utf8");
const signature = request.headers["kasera-signature-v1"];
const secret = process.env.KASERA_PAY_WEBHOOK_SECRET;
if (!signature || !secret || !verify(raw, signature, secret)) {
// "return" di sini wajib. Tanpa itu handler async terus berjalan
// setelah balasan 400 terkirim, dan event yang tidak terverifikasi
// tetap diproses di bawah.
return reply.code(400).send("invalid signature");
}
const event = JSON.parse(raw);
const client = await pool.connect();
try {
await client.query("BEGIN");
// Unique index yang menjadi penjaga dedupe. Pengiriman kembar yang
// tiba bersamaan menunggu di baris ini sampai transaksi pertama
// selesai, 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; // Fastify menjawab 500, dan pengiriman diulang.
} finally {
client.release();
}
return reply.code(200).send("ok");
});
};
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");
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);
});
}Yang terukur pada kode ini. Payload dengan \u0026 yang ditolak oleh jalan pintas di bagian 2 diterima 200. Header dengan dua entri v1, yang muncul selama rotasi signing secret, diterima kalau salah satunya cocok. Stempel waktu yang lebih tua dari lima menit ditolak 400. Content-Type dengan tambahan ; charset=utf-8 tetap masuk ke parser Buffer; text/plain tidak, dan jatuh ke pemeriksaan Buffer.isBuffer di baris pertama.
4. Return setelah reply.send, atau event palsu tetap diproses
Mengirim balasan tidak sama dengan keluar dari fungsi, dan di handler async Fastify bedanya langsung menyentuh uang. Untuk panduan ini, return sebelum reply.code(400).send("invalid signature") dihapus, lalu dikirim event payment.paid dengan tanda tangan palsu. Pengirimnya menerima 400 seperti seharusnya. Pesanan di database tetap berubah menjadi dibayar, karena handler terus berjalan ke blok transaksi di bawahnya. Satu-satunya jejak adalah peringatan Reply was already sent, did you forget to "return reply" di log.
Kebalikannya juga terukur. Handler async yang selesai tanpa memanggil reply.send() dan tanpa mengembalikan apa pun dijawab 200 dengan body kosong, dan 200 menghentikan percobaan ulang. Jalur yang lupa menjawab bukan menggantung, tetapi menyatakan event selesai. Aturannya satu: setiap reply.send() di handler async diawali return.
5. Menyusun aplikasinya, dan dua cara merusak enkapsulasi
// app.js
const Fastify = require("fastify");
const { Pool } = require("pg");
const webhookRoutes = require("./webhook");
const { createTransaction } = require("./kasera-pay");
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = Fastify({ logger: true });
// Didaftarkan langsung, TIDAK dibungkus fastify-plugin. Pembungkus itu
// sengaja mematikan enkapsulasi, sehingga parser Buffer di dalamnya
// berlaku untuk setiap route di aplikasi.
app.register(webhookRoutes, { pool });
// Route yang butuh login hidup di plugin saudara, dengan hook-nya sendiri.
// Hook onRequest yang dipasang di root akan ikut menjaga route webhook
// juga, dan Kasera Pay tidak mengirim token login apa pun.
app.register(async function tokoRoutes(app) {
app.addHook("onRequest", wajibLogin);
app.post("/pesanan/:number/bayar", async (request, reply) => {
const order = await findOrder(pool, request.params.number);
// Key disimpan bersama pesanannya, dibuat sekali, dipakai ulang pada
// setiap percobaan. Key baru per percobaan menghapus proteksinya.
const key = order.idempotency_key ?? (await simpanKeyBaru(pool, order));
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_bri"],
},
key,
);
return reply.redirect(tx.checkout_url, 303);
});
});
app.listen({ port: 3000, host: "0.0.0.0" });Cara pertama merusaknya adalah kebiasaan yang sah di tempat lain: membungkus plugin dengan fastify-plugin supaya dekorator dan hook-nya terlihat di seluruh aplikasi. Pembungkus itu ada justru untuk mematikan enkapsulasi, dan parser Buffer ikut keluar bersamanya.
// JANGAN: fastify-plugin membuat plugin ini berbagi konteks dengan root.
const fp = require("fastify-plugin");
app.register(fp(webhookRoutes), { pool });
app.post("/pesanan", async (request) => {
// Terukur pada fastify-plugin 6.0.1: request.body di sini sekarang
// Buffer, bukan objek. request.body.total bernilai undefined.
});Cara kedua adalah hook autentikasi di root. app.addHook("onRequest", ...) yang dipasang di instance utama diwarisi setiap plugin di bawahnya, termasuk plugin webhook, dan pada pengukuran setiap pengiriman dijawab 401. Pindahkan hook itu ke plugin saudara yang hanya memuat route yang memang butuh login, seperti di atas: webhook menjawab 200, route toko tetap 401 tanpa token.
6. Dedupe di bawah pengiriman kembar bersamaan
Event baru yang sama dikirim 30 kali bersamaan ke listener sungguhan. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris untuk event itu, dan pesanannya diperbarui sekali. Catatan event dan pembaruan pesanan berada dalam satu transaksi: saat tabelnya dibuat hilang di tengah pengujian, pengiriman dijawab 500, dan pengiriman ulang setelah tabelnya kembali dijawab 200 dan memenuhi pesanannya. Kalau id event disimpan di luar transaksi, pengiriman ulang itu akan menabrak primary key dan dijawab sebagai duplikat, sehingga pesanan yang sudah dibayar tidak pernah terpenuhi.
7. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, dan pengirimnya tidak mengikuti redirect, jadi daftarkan URL persis seperti route-nya. Satu mode boleh memegang sampai lima endpoint. Kepulangan pembeli ke return_url bukan bukti bayar; penanda lunas hanya payment.paid bertanda tangan atau pembacaan GET /v1/transactions/{id} dari server. Route di atas hanya menangani payment.paid; kalau stok dilepas saat payment.expired, siapkan jalur untuk menariknya kembali karena payment.paid masih bisa menyusul. Urutan pemeriksaan lainnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Kenapa JSON.stringify(request.body) kadang lolos verifikasi dan kadang tidak?
Karena tanda tangan dihitung atas byte persis yang dikirim, dan payload webhook Kasera Pay disusun dengan encoder JSON yang menulis tanda &, <, dan > sebagai \u0026, \u003c, dan \u003e. JSON.stringify menulis karakter yang sama apa adanya. Pada payload tanpa karakter itu hasilnya kebetulan identik byte demi byte, sehingga verifikasi lolos di mode tes. Begitu ada pelanggan bernama CV Maju & Jaya, byte-nya berbeda dan pengiriman yang sah ditolak. Satu-satunya cara yang selalu benar adalah memverifikasi byte mentah.
Haruskah removeContentTypeParser dipanggil sebelum addContentTypeParser untuk application/json?
Tidak pada Fastify 5.12.7. Memasang parser application/json baru di dalam plugin menggantikan parser bawaan untuk plugin itu saja, tanpa galat, dan route di luar plugin tetap menerima objek hasil parsing. Yang menentukan cakupannya adalah tempat parser dipasang, bukan pemanggilan remove.
Apakah Kasera Pay punya SDK untuk Node.js?
Ada. SDK JavaScript/TypeScript resmi bisa dipasang dengan npm install kasera-pay (Node 20.19 ke atas), dan rinciannya ada di pay.kasera.id/docs/api/sdks. Itu jalur yang lebih singkat untuk membuat permintaan pembayaran. Verifikasi webhook tetap membutuhkan byte mentah, jadi parser per plugin di panduan ini tetap diperlukan apa pun klien yang dipakai.
Kenapa route webhook menjawab 404 padahal URL-nya terlihat benar?
Router Fastify mencocokkan path persis. Pada pengukuran untuk panduan ini, /webhooks/kasera-pay/ dengan garis miring di akhir dan /Webhooks/kasera-pay dengan huruf besar sama-sama dijawab 404, dan GET ke route yang hanya punya POST juga dijawab 404, bukan 405. Cara paling sederhana adalah mendaftarkan URL di dashboard persis seperti route-nya. Opsi ignoreTrailingSlash di level atas konstruktor masih bekerja tetapi memunculkan peringatan deprecation FSTDEP022; di Fastify 5 tempatnya di routerOptions.
Apa yang dilihat pengirim kalau database sedang mati?
Galat yang dilempar dari handler async dijawab 500 oleh Fastify, dan 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam. Pada pengukuran, body 500 bawaan Fastify memuat pesan galat Postgres apa adanya, termasuk nama tabel. Kalau itu tidak diinginkan, pasang setErrorHandler yang mencatat galatnya ke log lalu menjawab 500 dengan body umum.