Panduan · Terbit
Integrasi payment gateway di Elysia: parse: "text" untuk tanda tangan webhook, skema t.Object yang membuang field sebelum diverifikasi, dan onError yang membocorkan pesan Postgres
Integrasi Kasera Pay di Elysia butuh empat keputusan yang khas Elysia. Route webhook memakai { parse: "text" } dan tidak memakai skema body, karena tanda tangan dihitung atas byte persis dan skema t.Object membuang field sebelum handler melihatnya. Setiap status() di handler diawali return. Plugin webhook dipasang sebelum hook autentikasi, karena hook Elysia berlaku untuk route yang didaftarkan sesudahnya. Dan onError ditulis sendiri, karena bawaannya mengirim pesan galat asli di body 500.
Semua perilaku di bawah diukur untuk panduan ini pada Elysia 1.4.30 di Bun 1.4.2, memakai SDK kasera-pay 0.1.0, Bun SQL, dan Postgres 16, dengan payload yang disusun dan ditandatangani seperti pengirim webhook Kasera Pay. Kontrak lengkapnya ada di dokumentasi webhook, dan klien yang dipakai adalah SDK JavaScript resmi.
1. Paket, kredensial, dan tabel dedupe
bun add elysia kasera-payAPI key berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook diambil dari dashboard, menu Developer, dan berbeda per mode. Bun membaca .env sendiri, jadi tidak perlu paket dotenv.
# .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: parse: “text”, bukan JSON.stringify(body)
Elysia mem-parse body JSON secara otomatis begitu handler menyentuh body. Tiga payload yang sama-sama sah dikirim ke empat cara membaca body: payload biasa, payload dengan nama pelanggan CV Maju & Jaya, dan payload dengan external_id INV/2026/10/001.
| Cara membaca | Biasa | Tanda & | Garis miring |
|---|---|---|---|
JSON.stringify(body) | 200 | 400 | 200 |
request.text(), body tidak disentuh | 200 | 200 | 200 |
request.text() setelah body dibaca | 500 | 500 | 500 |
{ parse: "text" } | 200 | 200 | 200 |
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.
request.text() hanya aman selama tidak ada yang membaca body lebih dulu. Elysia membaca kode handler untuk memutuskan apakah body perlu diparse, jadi satu destrukturisasi body di handler, atau satu hook onBeforeHandle global yang mencatat body, membuat route itu dijawab 500 Body already used. Opsi { parse: "text" } menutup kedua jalan itu: body selalu berupa string mentah, juga untuk hook yang membacanya, dan tetap begitu walaupun header pengiriman adalah application/json.
3. Skema t.Object di route webhook membuang field
Kebiasaan yang baik di route lain menjadi jebakan di sini. Saat route webhook diberi body: t.Object(...) yang hanya mendeklarasikan id, type, dan sebagian data, Elysia membuang semua field yang tidak dideklarasikan sebelum handler berjalan. Pada pengukuran, JSON.stringify(body) di route itu tinggal berisi empat field, tanpa livemode, created_at, customer, dan status, sehingga tanda tangan gagal 400 bahkan untuk payload biasa.
Skema yang mewajibkan data juga menolak tombol uji di dashboard. Pengiriman test.ping tidak membawa data, dan route berskema itu menjawabnya 422 dengan detail validasi. Validasi bentuk payload dilakukan setelah tanda tangan lolos, pada objek yang dikembalikan constructWebhookEvent, bukan oleh skema route.
4. Route webhook dan dua cara gagal menolak
// webhook.ts
import { Elysia } from "elysia";
import type { SQL } from "bun";
import { constructWebhookEvent, SignatureError } from "kasera-pay";
export const webhook = (sql: SQL) =>
new Elysia({ prefix: "/webhooks" }).post(
"/kasera-pay",
async ({ body, headers, status }) => {
// Dengan parse: "text", body adalah string berisi byte persis yang dikirim.
let event;
try {
event = await constructWebhookEvent(
body,
headers["kasera-signature-v1"] ?? "",
process.env.KASERA_PAY_WEBHOOK_SECRET!,
);
} catch (err) {
// "return" wajib. status() tanpa return tidak mengirim apa pun.
if (err instanceof SignatureError) return status(400, "invalid signature");
throw err;
}
await sql.begin(async (tx) => {
// Primary key yang menjadi penjaga dedupe. Pengiriman kembar yang
// tiba bersamaan menunggu di baris ini, lalu tidak mendapat baris.
const fresh = await tx`
INSERT INTO webhook_events (id, type) VALUES (${event.id}, ${event.type})
ON CONFLICT (id) DO NOTHING RETURNING id`;
if (fresh.length === 1 && event.type === "payment.paid") {
const paid = await tx`
UPDATE orders SET status = 'dibayar', payment_request_id = ${event.data.payment_request_id}
WHERE number = ${event.data.external_id} RETURNING number`;
// Pesanan tidak ditemukan: lempar galat agar id event ikut di-rollback.
if (paid.length !== 1) throw new Error("pesanan untuk event ini tidak ditemukan");
}
});
return "ok";
},
// Tanpa skema body. Lihat bagian 3.
{ parse: "text" },
);Yang terukur pada kode ini. Ketiga payload di bagian 2 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 dengan secret lain ditolak 400, dan pesanannya tetap menunggu. test.ping dari dashboard dijawab 200.
Verifikasi buatan sendiri tanpa SDK biasanya gagal di salah satu dari dua bentuk ini:
// JANGAN: dua bentuk yang tampak benar.
.post("/webhooks/kasera-pay", async ({ request, headers, set, status }) => {
const raw = await request.text();
if (!(await verifikasi(raw, headers["kasera-signature-v1"]))) {
status(400, "invalid signature"); // A: dibuat, lalu dibuang
// set.status = 400; // B: terkirim 400, tetapi tidak berhenti
}
await tandaiLunas(JSON.parse(raw)); // tetap jalan di kedua bentuk
return "ok";
})Pada pengukuran, event payment.paid bertanda tangan palsu ke bentuk A dijawab 200 ok dan penanda lunasnya dijalankan. Bentuk B lebih licin: pengirim menerima 400, jadi log terlihat benar, tetapi penanda lunasnya tetap dijalankan. Di route bagian 4, event baru ada setelah constructWebhookEvent berhasil. Saat return di blok catch dihapus untuk pengujian, event palsu dijawab 500 karena galatnya dilempar ulang, dan pesanannya tidak berubah.
5. Menyusun aplikasinya: urutan hook dan onError
// server.ts
import { Elysia } from "elysia";
import { SQL } from "bun";
import { KaseraPay, KaseraPayError } from "kasera-pay";
import { webhook } from "./webhook";
const sql = new SQL(process.env.DATABASE_URL!);
const kasera = new KaseraPay(process.env.KASERA_PAY_KEY!);
new Elysia()
// Galat selain 404, validasi, dan parse dijawab 500 tanpa pesan aslinya.
.onError(({ code, error, status }) => {
if (code === "NOT_FOUND" || code === "VALIDATION" || code === "PARSE") return;
console.error(error);
return status(500, "internal error");
})
// Webhook dipasang sebelum hook autentikasi apa pun.
.use(webhook(sql))
.guard({ beforeHandle: cekSesi }, (toko) =>
toko.post("/toko/pesanan/:number/bayar", async ({ params, redirect, status }) => {
const order = await findOrder(sql, 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(sql, 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 redirect(tx.checkout_url, 303);
} catch (err) {
if (err instanceof KaseraPayError) return status(502, { error: err.code });
throw err;
}
}),
)
.listen(3000);Hook di Elysia berlaku untuk route yang didaftarkan sesudahnya. Pada pengukuran, onBeforeHandle autentikasi yang dipasang sebelum webhook membuat setiap pengiriman dijawab 401, sedangkan hook yang sama setelah webhook hanya menjaga route toko. Plugin autentikasi dengan { as: "global" } atau { as: "scoped" } juga menjawab webhook 401 kalau di-use lebih dulu. Sebaliknya, plugin dengan hook tanpa as tidak menjaga route induk sama sekali, jadi route toko terbuka tanpa peringatan. guard() seperti di atas membuat batasnya terlihat di kode.
Tanpa onError sendiri, galat yang dilempar dijawab 500 dengan pesan aslinya di body, misalnya relation "webhook_events" does not exist, juga dengan NODE_ENV=production. Body itu tersimpan di log pengiriman webhook di dashboard. Jangan memfilter berdasarkan code seperti "UNKNOWN": galat Bun SQL datang dengan code ERR_POSTGRES_SERVER_ERROR dan lolos dari filter itu. Yang terukur aman adalah bentuk di atas, yang meloloskan hanya 404, validasi, dan parse.
redirect(url) tanpa argumen kedua menjawab 302, juga setelah POST. Tulis redirect(tx.checkout_url, 303) agar browser membuka halaman checkout dengan GET. Dengan API key yang salah, createTransaction melempar KaseraPayError dengan code unauthorized, dan route di atas menjawab 502.
6. Dedupe di bawah pengiriman kembar bersamaan
Event yang sama dikirim 30 kali bersamaan ke server Elysia yang sedang berjalan. Ketiga puluhnya dijawab 200, tabel webhook_events memuat satu baris, dan trigger penghitung di tabel pesanan mencatat tepat satu pembaruan. Sebagai pembanding, versi “cek dulu, baru tulis” dengan SELECT lalu INSERT terpisah di tabel tanpa primary key mencatat 30 baris dan 30 pemenuhan pesanan untuk 30 pengiriman yang sama.
Catatan event dan pembaruan pesanan berada dalam satu sql.begin(). Event untuk external_id yang belum ada dijawab 500 dan id event-nya ikut di-rollback, jadi pengiriman ulang setelah pesanannya tersimpan dijawab 200 dan memenuhi pesanannya. Saat tabel event dibuat hilang di tengah pengujian, pengiriman dijawab 500 internal error, dan pengiriman ulang setelah tabelnya kembali diproses normal. 500 berarti pengiriman diulang dengan exponential backoff sampai tujuh kali dalam kurang lebih 33 jam.
7. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, dan pengirimnya tidak mengikuti redirect. Kepulangan pembeli ke return_url bukan bukti bayar; penanda lunas hanya payment.paid bertanda tangan atau pembacaan GET /v1/transactions/{id} dari server. Kalau stok dilepas saat payment.expired atau payment.failed, siapkan jalur untuk menariknya kembali karena payment.paid masih bisa menyusul. Bentuk yang sama untuk framework yang juga berjalan di Bun ada di panduan Hono, dan urutan pemeriksaan lainnya di checklist sebelum go-live.
Pertanyaan yang sering muncul
Sudah ada middleware logger yang membaca body di onBeforeHandle. Apakah webhook masih bisa diverifikasi?
Bisa, asalkan route webhook memakai parse: "text". Pada pengukuran, hook global yang membaca body membuat request.text() di handler dijawab 500 Body already used, karena body sudah dibaca dan diparse sebagai JSON. Dengan parse: "text" di route webhook, hook yang sama menerima string, handler menerima string yang sama, dan tanda tangan lolos.
Apakah kode ini berjalan di Node dengan adapter Elysia?
Panduan ini hanya diukur di Bun 1.4.2, dan memakai Bun SQL yang tidak ada di Node. Di Node, Bun SQL diganti klien Postgres seperti pg dengan transaksi yang sama. Perilaku Elysia di bagian 2 sampai 5 belum diukur di adapter Node, jadi uji ulang ketiga payload di bagian 2 sebelum go-live.
Kenapa URL dengan garis miring di akhir diterima, tetapi huruf besar 404?
Secara bawaan Elysia tidak ketat soal garis miring di akhir: /webhooks/kasera-pay/ dijawab sama dengan route tanpa garis miring. Huruf besar dianggap path yang berbeda dan dijawab 404, dan GET ke route yang hanya punya POST juga 404, bukan 405. Daftarkan URL di dashboard persis seperti route-nya.
Handler lupa mengembalikan apa pun. Apa yang diterima pengirim?
200 dengan body kosong. Ini berbeda dari Hono, yang menjawab 404 untuk handler tanpa return. Bagi webhook, 200 berarti pengiriman dianggap selesai dan tidak diulang, jadi handler yang berhenti di tengah tanpa return dan tanpa galat akan menelan event itu.
Di mana pesan galat yang dikirim balik ke Kasera Pay terlihat?
Body jawaban endpoint, sampai 2 KB, disimpan di log pengiriman webhook dan tampil di detail log di dashboard, menu Developer. Karena itu onError di bagian 5 menjawab internal error dan mencetak galat lengkapnya ke log server sendiri.