Panduan · Terbit
Integrasi payment gateway di Fiber: pesan galat database yang ikut terkirim sebagai body 500, group bermiddleware login yang ikut menjaga route webhook, dan c.BodyRaw yang isinya sudah milik request lain
Integrasi Kasera Pay di Fiber gagal di tempat yang berbeda dari Gin dan Echo. Body webhook tidak hilang setelah di-bind, dan URL yang berbeda satu garis miring tetap sampai ke handler. Yang menjebak adalah tiga hal lain. ErrorHandler bawaan mengirim teks galat apa adanya sebagai body 500, termasuk nama tabel database dan pesan panic. Middleware berlaku menurut urutan pendaftaran dan awalan path, sehingga route webhook yang kelihatannya berada di luar group tetap dijawab 401 atau 403. Lalu slice dari c.BodyRaw() yang disimpan melewati handler berisi body request lain. Semuanya diukur di bawah pada Fiber v3.6.0 dan Go 1.26, dengan perbandingan pada v2.52.15.
Sisi klien, yaitu pemanggilan POST /v1/transactions, galat bertipe, dan http.Client bertimeout, tidak berubah karena Fiber. Paket kaserapay yang ditulis utuh di panduan integrasi payment gateway di Go dipakai apa adanya, berikut fungsi verify dan struct Event-nya. Layanan yang memakai router berbasis net/http punya jebakan yang berbeda, dan pembahasannya ada di panduan integrasi di Echo.
1. Susunan aplikasi: ErrorHandler sendiri, webhook didaftarkan lebih dulu
// main.go (Fiber v3)
func main() {
s := newServer() // memegang DB, secret, dan klien kaserapay
app := fiber.New(fiber.Config{
BodyLimit: 1 << 20, // 413 untuk body di atas 1 MB
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
ErrorHandler: errorHandler,
})
app.Use(recover.New())
// Webhook didaftarkan SEBELUM middleware global apa pun. Fiber
// menjalankan stack menurut urutan pendaftaran, dan handler ini tidak
// memanggil c.Next(), jadi CSRF di bawahnya tidak pernah menyentuhnya.
app.Post("/webhooks/kasera", s.kaseraWebhook)
app.Use(csrf.New())
// Group yang dijaga login diberi prefiks sendiri, bukan "" atau "/".
toko := app.Group("/toko", s.requireLogin)
toko.Post("/pesanan/:id/bayar", s.bayar)
log.Fatal(app.Listen(":8080"))
}
// Galat bertipe *fiber.Error dijawab dengan kodenya sendiri. Galat lain,
// termasuk panic yang ditangkap recover, dicatat lalu dijawab 500 polos.
func errorHandler(c fiber.Ctx, err error) error {
var fe *fiber.Error
if errors.As(err, &fe) {
return c.Status(fe.Code).SendString(fe.Message)
}
log.Printf("%s %s: %v", c.Method(), c.Path(), err)
return c.SendStatus(fiber.StatusInternalServerError)
}ErrorHandler bawaan membocorkan isi galat. Handler yang mengembalikan galat database biasa dijawab 500 dengan body ERROR: relation “kasera_events” does not exist (SQLSTATE 42P01) pada percobaan. Panic yang ditangkap recover.New() juga dijawab 500 dengan pesan panic-nya sebagai body. Route webhook terbuka ke internet, jadi siapa pun yang mengirim POST ke alamat itu bisa membaca pesan tersebut. Dashboard juga menyimpan body jawaban endpoint di log pengiriman, di bawah Respons endpoint. errorHandler di atas meneruskan kode dari *fiber.Error, seperti 400 dan 403, lalu menjawab galat lain dengan Internal Server Error polos. Galatnya tetap lengkap di log server.
Urutan pendaftaran adalah urutan eksekusi. Pengirim webhook adalah server, bukan peramban, jadi tidak membawa token CSRF. Route yang didaftarkan sesudah app.Use(csrf.New()) dijawab 403 pada percobaan, di v3 maupun v2. Route yang didaftarkan sebelumnya dijawab 200, sementara form milik aplikasi yang didaftarkan sesudahnya tetap dijaga dengan 403 kalau tokennya tidak ada.
Group adalah awalan, bukan wadah. app.Group(“”, requireLogin) dan app.Group(“/”, requireLogin) memasang middleware login untuk setiap path, termasuk route yang didaftarkan pada app sesudah group itu. Pada percobaan dengan keyauth, webhook di luar group seperti itu dijawab 401. Dengan prefiks /toko, webhook dijawab 200 dan route di dalam group tetap 401 tanpa kunci. Keamanan route webhook datang dari tanda tangan HMAC, bukan dari middleware.
Next pada CSRF lebih rapuh daripada urutan. Melewati webhook dengan Next: func(c fiber.Ctx) bool { return c.Path() == “/webhooks/kasera” } menjawab URL yang persis sama dengan 200, tetapi /webhooks/kasera/ dan /webhooks/Kasera dengan 403. Routing Fiber secara bawaan tidak membedakan garis miring di akhir maupun huruf besar, jadi kedua URL itu sampai ke handler webhook, sementara perbandingan string di Next tidak ikut longgar.
Batas body dan timeout ditulis sendiri. Bawaan Fiber adalah BodyLimit 4 MB dan ReadTimeout, WriteTimeout, serta IdleTimeout tanpa batas. Dengan BodyLimit: 1 << 20, body 2 MB dijawab 413 sebelum handler berjalan, baik dengan Content-Length maupun chunked. Klien yang masih mengunggah saat jawaban itu dikirim bisa melihatnya sebagai koneksi yang terputus, dan itu tetap penolakan.
2. Membuat tagihan dari handler Fiber
// Tombol "Bayar" di halaman pesanan, dikirim sebagai form POST.
func (s *Server) bayar(c fiber.Ctx) error {
// c.Context() tidak pernah dibatalkan, jadi batas waktunya dipasang di sini.
ctx, cancel := context.WithTimeout(c.Context(), 15*time.Second)
defer cancel()
order, err := s.orders.Find(ctx, c.Params("id"))
if err != nil {
return fiber.ErrNotFound
}
// Nominal diambil dari database, tidak pernah dari form. Key
// idempotensi dibuat sekali lalu disimpan bersama pesanannya.
key, err := s.orders.EnsureIdempotencyKey(ctx, order.ID)
if err != nil {
return err
}
tx, err := s.pay.CreateTransaction(ctx, kaserapay.CreateRequest{
Amount: order.Total,
Description: "Pesanan " + order.Number,
ExternalID: order.Number,
ReturnURL: s.siteURL + "/pesanan/" + order.Number,
}, key)
var apiErr *kaserapay.APIError
switch {
case errors.As(err, &apiErr) && !apiErr.Retryable():
return c.Status(fiber.StatusUnprocessableEntity).SendString("Pesanan tidak bisa diproses")
case err != nil:
return c.Status(fiber.StatusServiceUnavailable).SendString("Coba lagi sebentar")
}
if err := s.orders.SavePaymentRequest(ctx, order.ID, tx.ID); err != nil {
return err
}
return c.Redirect().Status(fiber.StatusSeeOther).To(tx.CheckoutURL)
}c.Context() tidak pernah dibatalkan. Pada v3, nilainya adalah context.Background() kecuali diisi lewat SetContext. Pada percobaan, klien yang menyerah setelah 300 milidetik tidak membatalkan apa pun di server: Done() bernilai nil dan tidak ada deadline. fiber.Ctx sendiri memenuhi interface context.Context, jadi meneruskan c ke pemanggilan database lolos compiler, tetapi dokumentasinya menyebut context itu tidak pernah bisa dibatalkan dan dipakai ulang setelah handler selesai. Itu sebabnya setiap handler memasang context.WithTimeout sendiri.
Key idempotensi lebih tua daripada percobaannya. Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional, dan key yang dibuat baru di setiap percobaan sama saja dengan tidak ada. external_id hanyalah label; dua create dengan external_id yang sama tetap menjadi dua permintaan pembayaran. Penjelasan lengkapnya ada di idempotency untuk pembayaran. Middleware idempotency bawaan Fiber adalah hal yang berbeda: middleware itu menyaring request yang masuk ke server sendiri, bukan request ke Kasera Pay. Tombol bayar dijawab 303 See Other ke checkout_url, diukur dengan header Location yang benar, sehingga tombol kembali di peramban tidak mengirim ulang form-nya.
3. Route webhook: salinan byte mentah
func (s *Server) kaseraWebhook(c fiber.Ctx) error {
// 1. Salinan byte mentah. Slice dari c.BodyRaw() hanya berlaku sampai
// handler selesai; sesudah itu buffer-nya dipakai request lain.
raw := bytes.Clone(c.BodyRaw())
// 2. Verifikasi atas byte yang sama persis dengan yang ditandatangani.
if !verify(raw, c.Get("Kasera-Signature-V1"), s.webhookSecret) {
return fiber.ErrBadRequest
}
// 3. Baru sekarang JSON dibaca, dari slice yang sama.
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
return fiber.ErrBadRequest
}
ctx, cancel := context.WithTimeout(c.Context(), 8*time.Second)
defer cancel()
// 4. Catatan event dan perubahan pesanan dalam satu transaksi. Galat
// yang dikembalikan menjadi 500 polos lewat errorHandler, dan 500
// berarti Kasera Pay mengirim ulang event ini nanti.
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // tidak berbuat apa-apa setelah Commit berhasil
res, err := tx.ExecContext(ctx,
`INSERT INTO kasera_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING`,
event.ID, event.Type)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return c.SendStatus(fiber.StatusOK) // duplikat, sudah diproses
}
switch event.Type {
case "payment.paid":
err = s.orders.MarkPaid(ctx, tx, event.Data.ExternalID)
case "payment.expired", "payment.failed":
err = s.orders.ReleaseStock(ctx, tx, event.Data.ExternalID)
}
if err != nil {
return err // rollback lewat defer
}
if err := tx.Commit(); err != nil {
return err
}
// Tipe lain, termasuk test.ping dari dashboard, cukup dijawab 200.
return c.SendStatus(fiber.StatusOK)
}bytes.Clone bukan hiasan. Fiber memakai ulang buffer request demi kecepatan. Pada percobaan, slice dari c.BodyRaw() yang disimpan dari request pertama berisi body request kedua setelah request kedua datang, sedangkan salinannya tetap utuh. Selama slice hanya dipakai di dalam handler, keduanya sama. Begitu payload diteruskan ke goroutine, antrean, atau log yang ditulis belakangan, hanya salinan yang aman. Hal yang sama berlaku untuk string dari c.Get dan c.Params, kecuali Immutable: true dipasang di konfigurasi.
Fungsi verify sama persis dengan versi di panduan Go: header Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, HMAC-SHA256 dihitung atas t, satu titik, dan body mentah, toleransinya lima menit, perbandingannya memakai hmac.Equal, dan selama rotasi secret header membawa dua entri v1 yang cukup cocok dengan salah satunya. Format resminya ada di dokumentasi webhook.
Handler di atas diuji pada Postgres 16 dengan race detector menyala, di dalam susunan aplikasi dari bagian 1. Pengiriman sah dijawab 200, juga ke /webhooks/kasera/ dan /webhooks/Kasera. Secret yang salah dan timestamp berumur sepuluh menit dijawab 400, header rotasi dengan dua v1 diterima, dan empat puluh pengiriman event yang sama secara bersamaan menghasilkan empat puluh jawaban 200 dan satu pesanan lunas. Tabel event yang hilang menghasilkan 500 dengan body Internal Server Error saja, dan pengiriman ulang setelah tabelnya kembali dijawab 200. Sebagai bukti bahwa ujinya bisa gagal, tiga perubahan dicoba satu per satu: mengembalikan galat tanpa errorHandler membocorkan teks SQL-nya, mendaftarkan webhook sesudah csrf.New() mengubah pengiriman sah menjadi 403, dan menyimpan slice tanpa bytes.Clone menghasilkan payload milik request lain.
Event payment.expired dan payment.failed hanya datang ke endpoint yang mencentangnya, dan kedaluwarsa bisa disusul payment.paid, jadi stok yang dilepas harus bisa diambil kembali. Pengirim webhook Kasera Pay tidak mengikuti redirect, jadi jangan pasang middleware yang mengalihkan URL webhook ke alamat lain.
4. Sebelum go-live
Salin path route apa adanya ke kolom URL endpoint di menu Developer, lalu tekan Kirim event percobaan. Event test.ping dikirim lewat jalur yang sama dengan event sungguhan, jadi 401, 403, atau 404 langsung tampil sebagai gagal, dan body jawabannya tercatat di log pengiriman. Body itu juga cara tercepat memeriksa bahwa errorHandler sudah terpasang: matikan database sebentar, kirim ulang, dan pastikan yang tercatat hanya Internal Server Error. Urutan pemeriksaan berikutnya ada di tulisan tentang webhook yang tidak sampai.
URL webhook wajib https dan beralamat publik. Kunci kp_test_ dan kp_live_ memiliki endpoint dan signing secret masing-masing, dan secret tes tidak pernah bisa memverifikasi payload live. Tarif default saat ini: QRIS 0,7% + Rp 250 per transaksi berhasil, Virtual Account Rp 5.000, dan pencairan Rp 3.000 per pencairan, tanpa biaya bulanan. Tarif yang berlaku untuk akun tampil di dashboard.
Pertanyaan yang sering muncul
Apakah panduan ini berlaku untuk Fiber v2?
Berlaku, dengan perubahan penulisan. Handler v2 menerima *fiber.Ctx, c.BodyRaw() tetap tersedia, dan context untuk database diambil dari c.UserContext(), karena c.Context() pada v2 mengembalikan *fasthttp.RequestCtx. Redirect ditulis c.Redirect(url, fiber.StatusSeeOther). Perilaku yang menentukan diukur sama pada v2.52.15: galat biasa dan pesan panic terkirim apa adanya sebagai body 500, group berprefiks kosong menjawab webhook dengan 401, CSRF global menjawabnya dengan 403, dan URL bergaris miring atau berhuruf besar tetap sampai ke handler. Fiber v3 membutuhkan Go 1.26 atau lebih baru; v2 masih berjalan di Go yang lebih lama.
Kenapa webhook dijawab 401 padahal route-nya tidak ada di dalam group?
Karena di Fiber, group adalah awalan path, bukan wadah. app.Group("", requireLogin) atau app.Group("/", requireLogin) memasang middleware itu untuk semua path yang diawali awalan tersebut, termasuk route yang didaftarkan pada app sesudahnya. Pada percobaan, route webhook yang didaftarkan setelah group seperti itu dijawab 401 dengan pesan "missing or invalid API Key". Beri group prefiks sendiri, misalnya /toko, atau daftarkan webhook lebih dulu.
Apakah c.Bind().Body() merusak body seperti di Gin dan Echo?
Tidak. Fiber berjalan di atas fasthttp, yang menyimpan seluruh body di buffer, jadi c.BodyRaw() sesudah Bind tetap mengembalikan byte yang identik dengan yang dikirim. Pada percobaan, Bind yang dipanggil sebelum verifikasi tetap menghasilkan 200. Urutan byte-mentah-lebih-dulu tetap dipakai di route ini karena menjaga verifikasi tetap benar kalau suatu saat handler-nya dipindah ke net/http lewat adaptor.
Apa bedanya c.Body() dan c.BodyRaw()?
c.Body() membuka kompresi kalau request membawa header Content-Encoding, sedangkan c.BodyRaw() mengembalikan byte persis seperti yang diterima. Pengiriman webhook Kasera Pay tidak dikompres, jadi keduanya sama untuk saat ini. HMAC dihitung atas byte yang dikirim, jadi c.BodyRaw() adalah pilihan yang tidak bergantung pada asumsi itu.
Apakah ada SDK Go resmi atau middleware Fiber dari Kasera Pay?
Tidak ada. SDK resmi saat ini hanya untuk PHP dan JavaScript. Untuk Go, sisi kliennya cukup paket kecil berbasis net/http yang ditulis utuh di panduan integrasi Go. Paket itu bisa dipakai di dalam handler Fiber tanpa perubahan, karena yang berjalan di atas fasthttp hanya server-nya, bukan klien HTTP keluar.