Panduan · Terbit
Integrasi payment gateway di Hono: c.req.text() untuk tanda tangan webhook, c.text() tanpa return yang dibuang diam-diam, dan middleware global yang menolak setiap pengiriman
Integrasi Kasera Pay di Hono butuh tiga keputusan yang khas Hono. Body webhook dibaca dengan c.req.text(), bukan diparse lalu disusun ulang, karena tanda tangan dihitung atas byte persis. Setiap c.text() dan c.json() di handler diawali return, karena di Hono balasan yang tidak dikembalikan tidak terkirim sama sekali. Dan middleware autentikasi dipasang per path, bukan di app.use("*"), karena pengirim webhook tidak membawa token login.
Semua perilaku di bawah diukur untuk panduan ini pada Hono 4.13.13 dengan @hono/node-server 2.1.5 di Node 22, diulang di Bun 1.4.2 dengan hasil yang sama, memakai SDK kasera-pay 0.1.0 dan Postgres 16. Kontrak lengkapnya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Paket, kredensial, dan tabel dedupe
npm install hono @hono/node-server kasera-pay pgAPI 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_...
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. Body mentah: c.req.text(), bukan c.req.json() dan bukan c.req.raw
Jalan pintas yang paling sering dicoba adalah await c.req.json() lalu memverifikasi JSON.stringify hasilnya. 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. Jalan pintas ini lolos di mode tes lalu gagal pada pelanggan pertama yang namanya memuat tanda itu.
Kabar baiknya khas Hono: HonoRequest menyimpan body yang sudah dibaca. Pada pengukuran, c.req.text() yang dipanggil setelah validator("json", ...) atau setelah middleware yang memanggil c.req.json() tetap mengembalikan byte asli, dan payload berisi \u0026 tetap lolos. Jebakannya ada di sebelahnya: c.req.raw.text() membaca Request bawaan yang sudah habis, dan route itu dijawab 500 dengan galat Body is unusable. Selalu baca lewat c.req, bukan c.req.raw.
3. Route webhook
// webhook.js
import { Hono } from "hono";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
export function webhookRoutes(pool) {
const app = new Hono();
app.post("/kasera-pay", async (c) => {
// Byte persis yang dikirim. Jangan c.req.json() lalu JSON.stringify.
const raw = await c.req.text();
let event;
try {
event = await constructWebhookEvent(
raw,
c.req.header("kasera-signature-v1") ?? "",
process.env.KASERA_PAY_WEBHOOK_SECRET,
);
} catch (err) {
// "return" wajib. Di Hono, c.text() tanpa return tidak mengirim apa pun.
if (err instanceof SignatureError) return c.text("invalid signature", 400);
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; // Hono menjawab 500, dan pengiriman diulang.
} finally {
client.release();
}
return c.text("ok", 200);
});
return app;
}Yang terukur pada kode ini. Payload dengan \u0026 diterima 200. 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, dan pesanannya tidak berubah.
4. c.text() tanpa return: balasan yang dibuang diam-diam
Di Express, res.status(400).send() tanpa return tetap mengirim 400 walaupun kode di bawahnya ikut berjalan. Di Hono, c.text() hanya membuat objek Response; yang dikirim adalah apa pun yang akhirnya dikembalikan handler. Bentuk yang lazim ditulis orang yang memverifikasi tanpa SDK:
// JANGAN: verifikasi buatan sendiri tanpa return.
app.post("/webhooks/kasera-pay", async (c) => {
const raw = await c.req.text();
if (!(await verifikasi(raw, c.req.header("kasera-signature-v1")))) {
c.text("invalid signature", 400); // dibuat, lalu dibuang
}
await tandaiLunas(JSON.parse(raw)); // tetap jalan
return c.text("ok"); // inilah yang diterima pengirim: 200
});Pada pengukuran, event payment.paid bertanda tangan palsu yang dikirim ke handler seperti ini dijawab 200 ok, dan penanda lunasnya tetap dijalankan. Pengirim yang curang tidak melihat penolakan apa pun, dan Hono tidak mencetak peringatan apa pun ke log. Ini lebih buruk daripada kesalahan yang sama di Fastify, yang setidaknya masih menjawab 400.
Route di bagian 3 lebih tahan terhadap kelalaian ini karena event baru ada setelah constructWebhookEvent berhasil. Saat return di blok catch-nya dihapus untuk pengujian, event palsu dijawab 500 karena event kosong, dan pesanannya tidak berubah. Itu tetap salah, karena 500 berarti pengiriman palsu diulang sampai tujuh kali, tetapi tidak menandai pesanan lunas. Kebalikannya juga terukur: handler yang tidak mengembalikan apa pun dijawab 404, dan 404 membuat event yang sah diulang sampai habis jatahnya.
5. Menyusun aplikasinya: middleware per path
// server.js
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { bearerAuth } from "hono/bearer-auth";
import { KaseraPay, KaseraPayError } from "kasera-pay";
import pg from "pg";
import { webhookRoutes } from "./webhook.js";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const kasera = new KaseraPay(process.env.KASERA_PAY_KEY);
const app = new Hono();
// Webhook dipasang tanpa middleware autentikasi apa pun.
app.route("/webhooks", webhookRoutes(pool));
// Autentikasi hanya untuk path toko. app.use("*", bearerAuth(...)) akan
// ikut menjaga /webhooks, dan Kasera Pay tidak mengirim token apa pun.
app.use("/toko/*", bearerAuth({ verifyToken: cekSesi }));
app.post("/toko/pesanan/:number/bayar", async (c) => {
const order = await findOrder(pool, c.req.param("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));
try {
const tx = await kasera.createTransaction(
{
amount: order.total, // dari database, tidak pernah dari body permintaan
description: "Pesanan " + order.number,
external_id: order.number,
return_url: process.env.SITE_URL + "/pesanan/" + order.number,
checkout: {},
},
{ idempotencyKey: key },
);
return c.redirect(tx.checkout_url, 303);
} catch (err) {
if (err instanceof KaseraPayError) return c.json({ error: err.code }, 502);
throw err;
}
});
serve({ fetch: app.fetch, port: 3000 });Pada pengukuran, app.use("*", bearerAuth(...)) membuat setiap pengiriman webhook dijawab 401, karena middleware dengan pola * berlaku untuk semua route termasuk yang dipasang lewat app.route(). Batasi ke path yang memang butuh login, seperti /toko/* di atas. csrf() dari hono/csrf lain cerita: ia hanya memeriksa content type yang bisa dikirim formulir HTML, dan pengiriman application/json lolos 200 dengan Origin apa pun.
c.redirect(url) tanpa argumen kedua menjawab 302. Setelah POST, 303 adalah kode yang memaksa browser membuka halaman checkout dengan GET, jadi tulis c.redirect(tx.checkout_url, 303). Kalau pembuatan permintaan gagal, KaseraPayError membawa status dan code dari API. Bentuk yang sama untuk Workers dan D1 ada di panduan Cloudflare Workers.
6. Dedupe di bawah pengiriman kembar bersamaan
Event baru yang sama dikirim 30 kali bersamaan ke listener @hono/node-server sungguhan. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris untuk event itu, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Catatan event dan pembaruan pesanan berada dalam satu transaksi: saat tabelnya dibuat hilang di tengah pengujian, pengiriman dijawab 500 dengan body Internal Server Error tanpa pesan Postgres, dan pengiriman ulang setelah tabelnya kembali dijawab 200 dan memenuhi pesanannya. Kalau id event disimpan di luar transaksi, pengiriman ulang itu akan dianggap duplikat, dan 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. 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 atau payment.failed, atau saat permintaan dibatalkan, siapkan jalur untuk menariknya kembali karena payment.paid masih bisa menyusul. Urutan pemeriksaan lainnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Validator JSON sudah terpasang di route webhook. Apakah c.req.text() masih memberi byte mentah?
Ya, pada Hono 4.13.13. HonoRequest menyimpan body yang sudah dibaca, sehingga c.req.text() setelah validator('json', ...) atau middleware yang memanggil c.req.json() tetap mengembalikan byte asli, dan payload berisi \u0026 tetap lolos verifikasi. Yang gagal adalah c.req.raw.text(): Request bawaannya sudah habis dibaca, dan pada pengukuran route itu dijawab 500 dengan galat Body is unusable.
Apakah csrf() dari hono/csrf memblokir webhook?
Tidak untuk webhook Kasera Pay. Middleware itu hanya memeriksa Origin pada content type yang bisa dikirim formulir HTML, seperti text/plain, dan pengiriman webhook selalu membawa Content-Type application/json. Pada pengukuran, POST application/json tanpa header Origin maupun dengan Origin asing dijawab 200, sedangkan text/plain dijawab 403.
Apakah kode ini berjalan di Bun dan Cloudflare Workers?
Seluruh pengukuran di panduan ini diulang di Bun 1.4.2 dengan hasil yang sama. SDK kasera-pay memverifikasi tanda tangan lewat Web Crypto, jadi tidak bergantung pada modul Node. Di Workers yang berbeda adalah penyimpanannya: pg diganti D1 atau database lain yang bisa dijangkau Worker, dan panduan Cloudflare Workers membahas bagian itu.
Kenapa URL webhook dijawab 404 padahal route-nya ada?
Hono mencocokkan path secara ketat secara bawaan. Pada pengukuran, /webhooks/kasera-pay/ dengan garis miring di akhir dan /Webhooks/kasera-pay dengan huruf besar sama-sama 404, dan GET ke route yang hanya punya POST juga 404, bukan 405. new Hono({ strict: false }) membuat garis miring di akhir diterima, tetapi cara paling sederhana tetap mendaftarkan URL di dashboard persis seperti route-nya.
Apa yang dilihat pengirim kalau database sedang mati?
Galat yang dilempar dari handler dijawab 500 oleh Hono dengan body Internal Server Error, tanpa pesan galat Postgres, sementara galat lengkapnya tercetak di log server. 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam.