Panduan · Terbit
Integrasi payment gateway di chi: RedirectSlashes yang membuat pengiriman webhook ditolak, r.Use yang panic kalau ditulis setelah route, dan middleware Timeout serta RequestSize yang hanya memberi isyarat
chi adalah router di atas net/http, jadi integrasi Kasera Pay di chi hampir sama dengan Go tanpa framework: body dibaca sendiri, handler adalah http.HandlerFunc, dan tidak ada binding yang menghabiskan body diam-diam. Yang berbeda ada di lapisan router. Route yang dicocokkan persis menjawab 404 untuk URL yang berbeda satu garis miring. RedirectSlashes, obat yang paling sering dipasang untuk itu, menjawab 301, dan pengirim webhook Kasera Pay tidak mengikuti redirect. Lalu Timeout dan RequestSize terdengar seperti pagar, padahal keduanya hanya memberi isyarat. Semua perilaku di bawah diukur pada chi v5.4.0 dan Go 1.24, dengan hasil yang identik pada v5.3.2.
Sisi klien, yaitu pemanggilan POST /v1/transactions dengan header Idempotency-Key, galat bertipe, dan http.Client bertimeout, tidak berubah karena chi. Paket kaserapay, fungsi verify, struct Event, dan handler bayar dari panduan integrasi payment gateway di Go dipakai apa adanya. Layanan yang memakai Gin atau Echo menghadapi binding yang mengosongkan body, dan pembahasannya ada di panduan integrasi di Echo.
1. Susunan router: tanpa RedirectSlashes, auth di dalam group
// router.go (chi v5)
func newRouter(s *Server, requireSession func(http.Handler) http.Handler) chi.Router {
r := chi.NewRouter()
// Semua r.Use ditulis sebelum route pertama. Sesudahnya, chi panic.
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Bukan RedirectSlashes: pengirim webhook tidak mengikuti 301.
r.Use(middleware.StripSlashes)
// Webhook di luar group mana pun. Keamanannya dari tanda tangan HMAC.
r.Post("/webhooks/kasera", s.kaseraWebhook)
// Auth hanya berlaku di dalam group ini.
r.Group(func(r chi.Router) {
r.Use(requireSession)
r.Post("/toko/pesanan/{id}/bayar", s.bayar)
})
return r
}
func main() {
s := newServer() // memegang DB, secret, dan klien kaserapay
// Bukan http.ListenAndServe(":8080", r): server bawaannya tanpa timeout.
srv := &http.Server{
Addr: ":8080",
Handler: newRouter(s, requireSession),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}Route chi dicocokkan persis. Dengan r.Post(“/webhooks/kasera”, h) saja, /webhooks/kasera/ dan /webhooks/Kasera dijawab 404 404 page not found pada percobaan, dan GET dijawab 405 dengan header Allow: POST. URL endpoint yang disalin dengan garis miring di akhir sudah cukup untuk membuat setiap pengiriman gagal.
RedirectSlashes mengubah 404 menjadi kegagalan lain. Middleware itu menjawab POST ke /webhooks/kasera/ dengan 301 dan Location: /webhooks/kasera. Klien HTTP yang menolak redirect, seperti pengirim webhook Kasera Pay, berhenti di situ, dan pada percobaan pengirimannya gagal dengan galat redirect refused. Klien bawaan Go yang mengikuti 301 pun tidak tertolong, karena POST itu berubah menjadi GET dan dijawab 405. StripSlashes memotong garis miringnya tanpa redirect, dan URL bergaris miring dijawab 200. CleanPath juga merapikan //webhooks//kasera menjadi 200. Huruf besar tetap 404 dengan keduanya, jadi URL di dashboard tetap disalin persis.
Middleware auth dipasang di group, bukan di root. r.Use(requireSession) di tingkat atas menjaga semua route, termasuk webhook. Pada percobaan, setiap pengiriman yang sah dijawab 401, empat puluh pengiriman bersamaan menghasilkan empat puluh 401, dan tidak satu pun pesanan berubah menjadi lunas. Di dalam r.Group atau lewat r.With(requireSession).Post(...), webhook dijawab 200 dan route toko tetap 401 tanpa sesi. Pengirim webhook adalah server, bukan peramban: tidak membawa cookie, sesi, atau token CSRF. Keamanannya datang dari tanda tangan HMAC.
r.Use setelah route adalah panic, bukan bug yang tersembunyi. chi menolak susunan itu saat start dengan pesan chi: all middlewares must be defined before routes on a mux. Ini lebih ramah daripada router yang diam-diam melewatkan middleware untuk route yang terdaftar lebih dulu, tetapi artinya middleware untuk sebagian route harus ditulis lewat r.Group, r.Route, atau r.With.
Recoverer tidak membocorkan isi panic. Handler yang panic dengan pesan berisi alamat database dijawab 500 dengan body kosong, sedangkan pesan dan stack trace-nya ditulis ke stderr. Tanpa Recoverer, net/http memutus koneksinya dan pengirim melihat EOF. Yang membocorkan adalah kode sendiri: http.Error(w, err.Error(), 500) menulis teks galat database sebagai body, dan Kasera Pay menyimpan body jawaban endpoint di log pengiriman, di bawah Respons endpoint. Itu sebabnya handler di bawah menulis galat lengkap ke log dan menjawab dengan Internal Server Error polos.
Server bawaan tanpa timeout. http.ListenAndServe(“:8080”, r) membuat server dengan ReadHeaderTimeout, ReadTimeout, WriteTimeout, dan IdleTimeout bernilai nol, artinya tanpa batas. Satu klien lambat bisa memegang koneksi selama yang ia mau. Tulis http.Server sendiri seperti di atas.
2. Route webhook: body mentah, batas yang statusnya ditulis sendiri
func (s *Server) kaseraWebhook(w http.ResponseWriter, r *http.Request) {
// 1. Body mentah lebih dulu, dibatasi 1 MiB. Status 413 dipilih di
// sini; MaxBytesReader hanya mengembalikan galat.
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
// 2. Verifikasi atas byte yang sama persis dengan yang ditandatangani.
if !verify(raw, r.Header.Get("Kasera-Signature-V1"), s.webhookSecret) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// 3. Baru sekarang JSON dibaca, dari slice yang sama.
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
if err := s.record(ctx, event); err != nil {
// Galat lengkap ke log sendiri. Body 500 tetap polos, karena body
// ini tersimpan di log pengiriman Kasera Pay.
log.Printf("kasera webhook %s: %v", event.ID, err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
// Tipe lain, termasuk test.ping dari dashboard, cukup dijawab 200.
w.WriteHeader(http.StatusOK)
}
func (s *Server) record(ctx context.Context, event Event) error {
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 nil // 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
}
return tx.Commit()
}RequestSize tidak pernah menjawab 413. middleware.RequestSize(1<<20) hanya membungkus body dengan http.MaxBytesReader. Handler yang tidak membaca body tetap menjawab 200, dan handler yang membaca menerima galat *http.MaxBytesError yang harus ia ubah sendiri menjadi status. Pada percobaan dengan body 2 MiB, io.ReadAll berhenti di 1.048.576 byte dengan galat http: request body too large, dan handler di atas menjawabnya 413. Karena itu batasnya ditulis langsung di handler, di tempat statusnya ditentukan.
r.Context() baru ikut batal setelah body dibaca. Pada percobaan, klien yang menyerah setelah 300 milidetik membatalkan context handler yang sudah membaca body, dengan context canceled. Handler yang belum membaca body tetap berjalan dengan context yang tidak batal sampai lima detik kemudian. Route webhook membaca body di langkah pertama, jadi aman. Handler lain yang memanggil database sebelum membaca body sebaiknya tetap memasang context.WithTimeout sendiri.
middleware.Timeout hanya membatalkan context. Dengan Timeout(2*time.Second), handler yang memperhatikan context berhenti setelah dua detik dan dijawab 504 dengan body kosong. Handler yang mengabaikan context dan tidur tiga detik tetap menjawab 200 setelah tiga detik. Pemanggilan database dan HTTP yang menerima ctx berhenti tepat waktu; kode yang tidak menerima ctx tidak.
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 ini diuji pada Postgres 16 dengan race detector menyala, di dalam router dari bagian 1. Pengiriman sah dijawab 200, 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. Kegagalan yang dipaksakan saat menandai lunas menghasilkan 500 dengan body Internal Server Error saja dan tidak meninggalkan catatan event, sehingga pengiriman ulang dijawab 200 dan pesanannya lunas sekali. Sebagai bukti ujinya bisa gagal, pemeriksaan n == 0 dimatikan: empat puluh pengiriman yang sama tetap dijawab 200, tetapi pesanannya diproses empat puluh kali.
Event payment.expired dan payment.failed hanya datang ke endpoint yang mencentangnya, dan kedaluwarsa maupun kegagalan bisa disusul payment.paid, jadi stok yang dilepas harus bisa diambil kembali. Pesanan ditandai lunas dari event itu, sedangkan status transaksinya sendiri tertulis succeeded, bukan paid.
3. 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 301, 401, 404, atau 415 langsung tampil sebagai gagal, dan body jawabannya tercatat di log pengiriman. Matikan database sebentar, kirim ulang, dan pastikan yang tercatat hanya Internal Server Error. Kalau masih gagal, 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 handler bayar dari panduan Go perlu diubah untuk chi?
Tidak. Handler chi adalah http.HandlerFunc biasa, jadi handler bayar dari panduan integrasi Go dipakai apa adanya. Parameter {id} dari pola route chi bisa dibaca dengan chi.URLParam(r, "id") maupun r.PathValue("id"); pada percobaan dengan chi v5.4.0 keduanya mengembalikan nilai yang sama.
Kenapa aplikasi panic saat start dengan pesan all middlewares must be defined before routes on a mux?
Karena ada r.Use yang ditulis setelah route pertama didaftarkan pada mux yang sama. chi menolak susunan itu saat start, bukan saat request datang, jadi kesalahannya langsung terlihat. Pindahkan r.Use ke atas, atau kalau middleware itu memang hanya untuk sebagian route, pasang di dalam r.Group atau lewat r.With.
Apakah middleware.AllowContentType aman dipasang di depan webhook?
Aman kalau daftar yang diizinkan memuat application/json. Pengirim webhook Kasera Pay mengirim Content-Type: application/json, dan pada percobaan application/json maupun application/json; charset=utf-8 dijawab 200. Body dengan text/plain atau tanpa Content-Type dijawab 415 dengan body kosong. Middleware ini tidak menambah keamanan untuk webhook, karena yang memutuskan sah atau tidaknya pengiriman adalah tanda tangan.
Apakah ada SDK Go resmi atau middleware chi 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, dan paket itu dipakai di layanan chi tanpa perubahan.