Panduan · Terbit
Integrasi payment gateway di Gin: ShouldBindJSON yang mengosongkan body sebelum tanda tangan diperiksa, garis miring di URL webhook yang dijawab 307, dan gin.Context yang tidak pernah dibatalkan
Integrasi Kasera Pay di Gin gagal di tiga tempat yang tidak ada hubungannya dengan pembayaran. Route webhook yang memanggil ShouldBindJSON sebelum memeriksa tanda tangan menolak setiap pengiriman yang sah. URL webhook yang berbeda satu garis miring dari route-nya dijawab 307, dan pengirim webhook Kasera Pay tidak mengikuti redirect. Lalu gin.Context yang diteruskan ke database sebagai context tidak pernah dibatalkan. Ketiganya diukur di bawah pada Gin v1.12.0 dan Go 1.25, dan perbaikannya masing-masing hanya beberapa baris.
Sisi klien, yaitu pemanggilan POST /v1/transactions, galat bertipe, dan http.Client bertimeout, tidak berubah karena Gin. Paket kaserapay yang ditulis utuh di panduan integrasi payment gateway di Go dipakai apa adanya di sini, dan panduan ini hanya membahas lapisan Gin di atasnya. Tidak ada SDK Go resmi; SDK resmi saat ini untuk PHP dan JavaScript.
1. Susunan route: webhook di luar group, server bertimeout
// main.go
func main() {
gin.SetMode(gin.ReleaseMode)
s := newServer() // memegang DB, secret, dan klien kaserapay
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
// Webhook didaftarkan langsung pada engine, DI LUAR group yang membawa
// middleware login, sesi, atau CSRF. Pengirimnya tidak punya cookie.
r.POST("/webhooks/kasera", s.kaseraWebhook)
app := r.Group("/", s.requireLogin)
app.POST("/pesanan/:id/bayar", s.bayar)
// r.Run() membuat http.Server tanpa satu pun timeout. Tulis sendiri.
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}Webhook tidak boleh berada di dalam group bermiddleware login. Pengirim webhook tidak membawa cookie sesi, token CSRF, atau header Authorization milik aplikasi. Route yang didaftarkan sebagai api.POST(“/webhooks/kasera”, ...) pada group yang memakai middleware autentikasi dijawab 401 sebelum handler-nya sempat berjalan, dan itu yang terukur pada percobaan. Keamanan route ini datang dari tanda tangan HMAC, bukan dari middleware.
r.Run() tidak memasang timeout apa pun. Sumber Gin v1.12 membuat http.Server yang hanya berisi alamat dan handler. Klien yang lambat atau sengaja menggantung bisa menahan koneksi tanpa batas, dan route webhook memang harus terbuka ke internet. Membuat http.Server sendiri dengan empat timeout di atas adalah perbaikannya.
2. Membuat tagihan dari handler Gin
// Tombol "Bayar" di halaman pesanan, dikirim sebagai form POST.
func (s *Server) bayar(c *gin.Context) {
// c.Request.Context(), BUKAN c. Dengan setelan bawaan Gin,
// c.Done() bernilai nil dan c.Deadline() kosong, jadi query yang
// diberi c tidak ikut berhenti saat pembeli menutup tab.
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
order, err := s.orders.Find(ctx, c.Param("id"))
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
// 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 {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
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():
c.String(http.StatusUnprocessableEntity, "Pesanan tidak bisa diproses")
return
case err != nil:
c.String(http.StatusServiceUnavailable, "Coba lagi sebentar")
return
}
s.orders.SavePaymentRequest(ctx, order.ID, tx.ID)
c.Redirect(http.StatusSeeOther, tx.CheckoutURL)
}Context yang benar adalah milik request, bukan c. gin.Context memang memenuhi interface context.Context, jadi meneruskan c ke query database lolos kompilasi. Namun dengan setelan bawaan (ContextWithFallback bernilai false), c.Done() mengembalikan nil dan c.Deadline() kosong. Pada percobaan dengan request yang diberi batas satu detik, c.Request.Context() membawa batas itu sedangkan c tidak. Akibatnya, query yang diberi c terus berjalan setelah pembeli menutup tab atau setelah batas waktu lewat.
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 yang disimpan dan bisa difilter; dua create dengan external_id yang sama tetap menjadi dua permintaan pembayaran. Penjelasan lengkapnya ada di idempotency untuk pembayaran.
Tombol bayar dijawab 303 See Other ke checkout_url, sehingga peramban membuka halaman pembayaran dengan GET dan tombol kembali tidak mengirim ulang form-nya. Field lain yang bisa dikirim saat create tercantum di referensi endpoint create transaksi.
3. Route webhook: byte mentah lebih dulu
func (s *Server) kaseraWebhook(c *gin.Context) {
// 1. Batasi, lalu baca byte mentahnya. GetRawData hanyalah io.ReadAll
// tanpa batas, jadi plafonnya dipasang lebih dulu.
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
raw, err := c.GetRawData()
if err != nil {
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
return
}
// 2. Verifikasi atas byte yang sama persis dengan yang ditandatangani.
if !verify(raw, c.GetHeader("Kasera-Signature-V1"), s.webhookSecret) {
c.AbortWithStatus(http.StatusBadRequest)
return
}
// 3. Baru sekarang JSON dibaca, dari slice yang sama.
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
ctx := c.Request.Context()
// Unique index pada kolom id event adalah penjaganya. Duplikat
// dijawab 200 karena event itu memang sudah diproses.
if err := s.events.Insert(ctx, event.ID, event.Type); err != nil {
if isUniqueViolation(err) {
c.Status(http.StatusOK)
return
}
c.AbortWithStatus(http.StatusInternalServerError) // dikirim ulang nanti
return
}
switch event.Type {
case "payment.paid":
if err := s.orders.MarkPaid(ctx, event.Data.ExternalID); err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
case "payment.expired", "payment.failed":
s.orders.ReleaseStock(ctx, event.Data.ExternalID)
}
// Tipe lain, termasuk test.ping dari dashboard, cukup dijawab 200.
c.Status(http.StatusOK)
}Kebiasaan yang benar di hampir setiap handler Gin lain, yaitu c.ShouldBindJSON(&event) di baris pertama, adalah kesalahan di route ini. Bind membaca c.Request.Body sampai habis. Pada percobaan dengan Gin v1.12.0, bind berhasil mengisi struct, lalu GetRawData() sesudahnya mengembalikan nol byte dengan err bernilai nil. Tidak ada galat yang mengarah ke penyebabnya. Yang terlihat hanya verifikasi yang gagal untuk setiap pengiriman, padahal secret-nya benar. Menyusun ulang struct dengan json.Marshal juga tidak menolong, karena spasi dan urutan key pada payload asli tidak kembali, sedangkan HMAC dihitung atas byte.
Hal yang sama terjadi kalau sebuah middleware mencatat isi request dengan io.ReadAll lalu tidak mengembalikan body-nya. Middleware semacam itu sering dipasang global lewat r.Use, jadi periksa daftar middleware sebelum mencurigai secret-nya.
Kalau struct hasil bind tetap diinginkan, c.ShouldBindBodyWithJSON adalah jalan kedua. Method itu menyimpan byte aslinya di context dengan kunci gin.BodyBytesKey, dan pada percobaan byte tersebut identik dengan yang dikirim, termasuk dua spasi yang sengaja disisipkan. Verifikasi lalu dijalankan atas c.Get(gin.BodyBytesKey), sebelum struct-nya dipakai untuk apa pun.
Fungsi verify sama persis dengan versi pustaka standar 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 dengan race detector menyala. Pengiriman sah dijawab 200, secret yang salah dan timestamp berumur sepuluh menit dijawab 400, body 2 MB dijawab 413, empat puluh pengiriman event yang sama secara bersamaan menghasilkan empat puluh jawaban 200 dan satu pesanan terpenuhi, dan database yang mati menghasilkan 500 sehingga event-nya dikirim ulang. Event payment.expired dan payment.failed hanya datang ke endpoint yang mencentangnya. Kedaluwarsa juga bisa disusul payment.paid, jadi stok yang dilepas harus bisa diambil kembali.
4. Garis miring di akhir URL, dan kenapa pengirimannya gagal
Dengan setelan bawaan, Gin menjawab request ke /webhooks/kasera/ dengan 307 ke /webhooks/kasera kalau hanya route tanpa garis miring yang terdaftar. Untuk peramban hal itu tidak terasa. Untuk webhook hal itu fatal, karena pengirim webhook Kasera Pay menolak mengikuti redirect: URL tujuan redirect adalah alamat kedua yang tidak pernah divalidasi. Pada percobaan dengan klien yang menolak redirect dengan cara yang sama, pengirimannya berakhir sebagai galat dan handler tidak pernah berjalan. Huruf besar juga berpengaruh: /webhooks/Kasera dijawab 404.
Perbaikannya ada di dashboard, bukan di Gin. Salin path route apa adanya ke kolom URL endpoint di menu Developer, lalu tekan Kirim event percobaan. Event test.ping dikirim lewat jalur pengiriman yang sama dengan event sungguhan, jadi redirect, 401, atau 404 langsung tampil sebagai gagal. Kalau pengiriman tetap gagal, urutan pemeriksaannya ada di tulisan tentang webhook yang tidak sampai.
5. Sebelum go-live
Lima hal yang layak dicoba di mode tes, berurutan. Satu pembayaran tes dari awal sampai payment.paid. Satu pengiriman sah dicatat lengkap dengan header dan body-nya, lalu dikirim ulang dengan curl dalam lima menit, dan harus dijawab 200 tanpa memenuhi pesanan dua kali. URL endpoint ditambah garis miring dengan sengaja, dan Kirim event percobaan harus gagal, sebagai bukti bahwa kegagalan semacam itu terlihat. Database dimatikan di tengah, dan jawabannya harus 500, bukan 200. Terakhir, go test -race atas handler webhook, karena satu handler Gin melayani banyak request sekaligus.
URL webhook wajib https dan beralamat publik, jadi selama membangun di laptop diperlukan terowongan. Kunci kp_test_ dan kp_live_ memiliki endpoint dan signing secret masing-masing, dan secret tes tidak pernah bisa memverifikasi payload live. Tarif yang berlaku saat ini: QRIS 0,7% + Rp 250 per transaksi berhasil, Virtual Account Rp 5.000, dan pencairan Rp 3.000 per pencairan.
Pertanyaan yang sering muncul
Apakah ada SDK Go resmi atau middleware Gin 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 Gin tidak mengubah apa pun pada sisi itu. Yang khas Gin hanya tiga tempat: cara membaca body webhook, posisi route-nya di antara group dan middleware, dan context yang diteruskan ke database.
Kenapa semua webhook ditolak dengan tanda tangan tidak valid padahal secret-nya benar?
Hampir selalu karena body sudah dibaca sebelum verifikasi. ShouldBindJSON, BindJSON, atau middleware yang mencatat isi request menghabiskan c.Request.Body, dan GetRawData sesudahnya mengembalikan slice kosong tanpa galat. HMAC atas body kosong tidak akan pernah cocok. Pada Gin v1.12 hal ini terukur langsung: ShouldBindJSON berhasil mengisi struct, lalu GetRawData mengembalikan nol byte dengan err bernilai nil.
Bolehkah ShouldBindBodyWithJSON dipakai untuk webhook?
Boleh. Method itu membaca body sekali, menyimpan byte aslinya di context dengan kunci gin.BodyBytesKey, lalu mengisi struct. Byte yang tersimpan identik dengan yang dikirim, termasuk spasinya, jadi verifikasi bisa memakai c.Get(gin.BodyBytesKey). Urutannya tetap harus dijaga: struct hasil bind tidak boleh dipakai untuk apa pun sebelum tanda tangan dinyatakan cocok.
Haruskah RedirectTrailingSlash dimatikan?
Tidak perlu, dan mematikannya tidak memperbaiki apa pun untuk webhook: URL yang salah tetap gagal, hanya kodenya berubah dari 307 menjadi 404. Yang memperbaikinya adalah URL di dashboard yang sama persis dengan route-nya, termasuk ada atau tidaknya garis miring di akhir, serta huruf besar dan kecilnya. Setelan itu berguna untuk halaman yang dibuka peramban, jadi biarkan menyala.
Apakah handler webhook boleh menjawab 200 lebih dulu lalu memproses di goroutine?
Tidak disarankan. Jawaban 200 berarti event itu tidak akan dikirim ulang, jadi kalau goroutine-nya gagal setelah itu, pesanan yang sudah dibayar tidak pernah ditandai lunas. Simpan event dan ubah status pesanan lebih dulu, lalu jawab 200. Pekerjaan lambat seperti mengirim email boleh menyusul dari antrean milik sendiri, dan kalau goroutine memang dipakai, Gin mewajibkan c.Copy() karena gin.Context dipakai ulang untuk request berikutnya.