Panduan · Terbit
Integrasi payment gateway di Koa: ctx.request.rawBody untuk tanda tangan webhook, ctx.status = 400 yang tidak menghentikan handler, dan next() tanpa await yang menjawab 404 sambil tetap memproses
Integrasi Kasera Pay di Koa bergantung pada tiga kebiasaan yang khas Koa. Tanda tangan webhook diverifikasi atas ctx.request.rawBody, bukan atas ctx.request.body yang disusun ulang. Penolakan ditulis dengan ctx.throw(400), karena ctx.status = 400 hanya mengisi properti dan handler tetap berjalan. Dan setiap middleware memanggil await next(), karena next() tanpa await membuat Koa menjawab 404 sebelum route sempat selesai, sementara route itu tetap memproses event di belakang.
Semua perilaku di bawah diukur untuk panduan ini pada Koa 3.2.1, @koa/router 15.7.0, @koa/bodyparser 6.1.0, SDK kasera-pay 0.1.0, Node 22, dan Postgres 16, lewat listener HTTP sungguhan. Kontrak lengkapnya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Paket, kredensial, dan tabel dedupe
npm install koa @koa/router @koa/bodyparser 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: ctx.request.rawBody, bukan ctx.request.body
Jalan pintas yang paling sering dicoba adalah memverifikasi JSON.stringify(ctx.request.body). Pada pengukuran, jalan pintas itu lolos untuk payload biasa dan gagal 400 untuk payload yang sama dengan nama pelanggan CV Maju & Jaya, baik dengan @koa/bodyparser maupun dengan koa-bodyparser versi lama. 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.
Kedua parser menyimpan byte aslinya di ctx.request.rawBody, dan dengan itu payload berisi \u0026 lolos 200. Jebakannya ada di sebelahnya: membaca stream sekali lagi dengan raw-body setelah bodyParser() terpasang di app.use() gagal dengan stream is not readable, dan pengiriman dijawab 500 pada payload apa pun. Stream permintaan hanya bisa dibaca sekali, jadi ambil dari parser yang sudah membacanya. Satu syarat lagi: rawBody hanya terisi untuk content type yang di-parse. Pengiriman webhook membawa application/json, termasuk dengan charset=utf-8, dan keduanya lolos; permintaan text/plain tidak punya rawBody dan ditolak 400.
3. Route webhook
// webhook.js
import Router from "@koa/router";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
export function webhookRouter(pool) {
const router = new Router({ prefix: "/webhooks" });
router.post("/kasera-pay", async (ctx) => {
let event;
try {
// rawBody: byte persis yang dikirim, disimpan oleh @koa/bodyparser.
// Jangan JSON.stringify(ctx.request.body).
event = await constructWebhookEvent(
ctx.request.rawBody ?? "",
ctx.get("kasera-signature-v1"),
process.env.KASERA_PAY_WEBHOOK_SECRET,
);
} catch (err) {
// ctx.throw menghentikan handler. ctx.status = 400 tidak.
if (err instanceof SignatureError) ctx.throw(400, "invalid signature");
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; // Koa menjawab 500, dan pengiriman diulang.
} finally {
client.release();
}
ctx.body = "ok";
});
return router;
}Yang terukur pada kode ini. Payload dengan \u0026 diterima 200 dan pesanannya menjadi dibayar. 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 dengan body invalid signature, dan pesanannya tidak berubah.
4. ctx.status = 400 tidak menghentikan apa pun
Di Koa, ctx.status dan ctx.body hanyalah properti. Respons baru dikirim setelah seluruh rantai middleware selesai, dan yang terkirim adalah nilai terakhir kedua properti itu. Bentuk yang lazim ditulis orang yang memverifikasi tanpa SDK:
// JANGAN: verifikasi buatan sendiri yang hanya mengisi ctx.status.
router.post("/kasera-pay", async (ctx) => {
const ok = await verifyWebhook(ctx.request.rawBody, ctx.get("kasera-signature-v1"), secret);
if (!ok) {
ctx.status = 400;
ctx.body = "invalid signature"; // handler tetap lanjut
}
await tandaiLunas(ctx.request.body); // tetap jalan
ctx.body = "ok"; // pengirim menerima 400 dengan body "ok"
});Pada pengukuran, event payment.paid bertanda tangan palsu yang dikirim ke handler seperti ini dijawab 400 dengan body ok, dan penanda lunasnya tetap dijalankan. Status 400 bertahan karena Koa mencatat bahwa status sudah diisi secara eksplisit, tetapi body-nya tertimpa dan pesanannya sudah berubah. Lebih buruk lagi kalau baris ctx.status = 400 ikut terlupa: handler yang hanya mengisi ctx.body = "invalid signature" lalu lanjut dijawab 200 ok, dan event palsunya diproses tanpa jejak apa pun.
ctx.throw(400, "invalid signature") melempar galat, sehingga baris di bawahnya tidak pernah berjalan. Pada pengukuran, bentuk ini menjawab 400 dengan body invalid signature dan pesanannya tidak berubah. Route di bagian 3 memakai bentuk itu. Saat ctx.throw di blok catch-nya diganti dengan ctx.status = 400 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.
5. next() tanpa await: 404 untuk pengirim, event tetap diproses
Middleware Koa adalah fungsi async yang saling menunggu. Middleware yang memanggil next() tanpa await atau return selesai lebih dulu daripada route di bawahnya:
// JANGAN: next() tanpa await atau return.
app.use(async (ctx, next) => {
ctx.set("X-Request-Id", crypto.randomUUID());
next(); // Koa menganggap middleware ini selesai di sini
});Pada pengukuran, pengiriman webhook yang sah lewat middleware seperti ini dijawab 404 Not Found, karena saat Koa menulis respons belum ada route yang mengisi ctx.body. Route-nya tetap berjalan di belakang dan memproses event itu. Pengirim yang menerima 404 mengulang pengiriman, jadi satu pembayaran menghasilkan rangkaian pengiriman ulang yang semuanya diproses dan semuanya tampak gagal di log pengiriman. Tabel dedupe di bagian 1 mencegah pesanan dipenuhi dua kali, tetapi log pengiriman tetap merah sampai jatahnya habis. Periksa setiap middleware buatan sendiri: await next(), selalu.
6. Menyusun aplikasinya: router terpisah untuk webhook
// server.js
import Koa from "koa";
import Router from "@koa/router";
import { bodyParser } from "@koa/bodyparser";
import { KaseraPay, KaseraPayError } from "kasera-pay";
import pg from "pg";
import { webhookRouter } 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 Koa();
// Sekali, di atas semua router. rawBody hanya ada kalau parser inilah
// yang membaca stream-nya; jangan pasang pembaca body lain di depannya.
app.use(bodyParser());
// Webhook dipasang tanpa middleware autentikasi apa pun.
const webhooks = webhookRouter(pool);
app.use(webhooks.routes()).use(webhooks.allowedMethods());
// Autentikasi hanya di router toko. app.use(cekSesi) di atas akan ikut
// menjaga /webhooks, dan Kasera Pay tidak mengirim token apa pun.
const toko = new Router({ prefix: "/toko" });
toko.use(cekSesi);
toko.post("/pesanan/:number/bayar", async (ctx) => {
const order = await findOrder(pool, ctx.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));
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 },
);
ctx.status = 303;
ctx.redirect(tx.checkout_url);
} catch (err) {
if (err instanceof KaseraPayError) {
ctx.status = 502;
ctx.body = { error: err.code };
return;
}
throw err;
}
});
app.use(toko.routes()).use(toko.allowedMethods());
app.listen(3000);Pada pengukuran, middleware autentikasi yang dipasang di app.use() sebelum router membuat setiap pengiriman webhook dijawab 401. Router terpisah dengan toko.use(cekSesi) menjaga /toko (401 tanpa token) dan membiarkan /webhooks (200). Kalau semua route berada di satu router, router.use() bergantung pada urutan: dipasang sebelum route webhook, webhook ikut dijawab 401; dipasang sesudahnya, webhook lolos. Aturan yang bergantung pada urutan baris mudah rusak saat file disunting orang lain, jadi pisahkan routernya.
ctx.redirect(url) menjawab 302 secara bawaan. Setelah POST, 303 adalah kode yang memaksa browser membuka halaman checkout dengan GET; pada pengukuran, ctx.status = 303 sebelum atau sesudah ctx.redirect() sama-sama menghasilkan 303. Permintaan yang dikirim SDK membawa header Idempotency-Key dari opsi idempotencyKey, dan kalau API menolak, KaseraPayError membawa status dan code seperti validation_failed. Bentuk yang sama untuk Express ada di panduan Node.js dan Express.
7. Dedupe di bawah pengiriman kembar bersamaan
Event baru yang sama dikirim 30 kali bersamaan ke listener Koa sungguhan. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris untuk event itu, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Saat pemeriksaan rowCount === 1 dihapus untuk pengujian, pengiriman yang sama menghasilkan 30 pembaruan. Catatan event dan pembaruan pesanan berada dalam satu transaksi: saat tabel pesanan dibuat hilang di tengah pengujian, pengiriman dijawab 500 dan tidak ada baris event yang tersimpan, lalu 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.
8. 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
Proyek lama memakai koa-bodyparser, bukan @koa/bodyparser. Apakah ctx.request.rawBody tetap ada?
Ya. Pada pengukuran, koa-bodyparser 4.4.1 dan @koa/bodyparser 6.1.0 sama-sama mengisi ctx.request.rawBody dengan byte asli, dan keduanya meloloskan payload berisi \u0026. Keduanya juga sama-sama gagal kalau yang diverifikasi adalah JSON.stringify(ctx.request.body). Syaratnya sama: parser itulah yang membaca stream, dan content type permintaannya termasuk yang ia parse. Pengiriman webhook selalu membawa Content-Type application/json, yang termasuk bawaan kedua parser.
Bisakah body webhook dibaca dengan raw-body atau stream ctx.req sendiri?
Bisa, asal tidak ada parser lain yang sudah membacanya. Tanpa bodyparser, getRawBody(ctx.req) mengembalikan byte asli dan lolos verifikasi. Dengan bodyParser() terpasang di app.use() sebelumnya, stream-nya sudah habis: pada pengukuran getRawBody gagal dengan stream is not readable dan pengiriman dijawab 500. Kalau parser global sudah ada, ambil ctx.request.rawBody darinya, jangan membaca stream untuk kedua kalinya.
Kenapa URL webhook dengan garis miring di akhir atau huruf besar tetap diterima?
@koa/router mencocokkan path secara longgar secara bawaan. Pada pengukuran, /webhooks/kasera-pay/ dan /Webhooks/kasera-pay sama-sama dijawab 200; yang 404 hanya garis miring ganda. new Router({ strict: true }) menolak garis miring di akhir, dan { sensitive: true } menolak huruf besar. GET ke route yang hanya punya POST dijawab 404 kalau allowedMethods() tidak dipasang, dan 405 dengan header Allow: POST kalau dipasang.
Apa yang dilihat pengirim kalau database sedang mati?
Galat yang dilempar dari handler dijawab 500 oleh Koa dengan body Internal Server Error, tanpa pesan Postgres, sementara galat lengkapnya dipancarkan ke event error milik aplikasi dan tercetak di log. 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam.
Apakah perlu SDK, atau verifikasi bisa ditulis sendiri?
Bisa ditulis sendiri dengan crypto bawaan Node: HMAC-SHA256 atas stempel waktu, titik, dan body mentah, dibandingkan dengan perbandingan waktu-konstan, dengan toleransi lima menit. SDK kasera-pay melakukan semuanya dalam satu fungsi dan melempar SignatureError, sehingga pola ctx.throw di panduan ini menjadi alami. Untuk tumpukan JavaScript, SDK adalah jalan yang lebih pendek.