Panduan · Terbit
Integrasi payment gateway di Echo: c.Bind yang mengosongkan body sebelum tanda tangan diperiksa, middleware CSRF global yang menolak webhook dengan 400, dan garis miring yang berakhir 404
Integrasi Kasera Pay di Echo gagal di tiga tempat yang tidak ada hubungannya dengan pembayaran. Middleware CSRF yang dipasang global menjawab setiap pengiriman webhook dengan 400 sebelum handler-nya berjalan. Handler yang memanggil c.Bind sebelum memeriksa tanda tangan menolak setiap pengiriman yang sah, juga dengan 400. Lalu URL webhook yang berbeda satu garis miring dari route-nya dijawab 404, dan middleware yang biasanya dipasang untuk itu hanya menolong dalam satu susunan. Semuanya diukur di bawah pada Echo v5.4.0 dan Go 1.25, dengan perbandingan pada v4.16.0.
Sisi klien, yaitu pemanggilan POST /v1/transactions, galat bertipe, dan http.Client bertimeout, tidak berubah karena Echo. Paket kaserapay yang ditulis utuh di panduan integrasi payment gateway di Go dipakai apa adanya. Layanan yang memakai Gin punya jebakan yang berbeda, dan pembahasannya ada di panduan integrasi di Gin.
1. Susunan route: CSRF dilewati, webhook di luar group
// main.go (Echo v5)
func main() {
s := newServer() // memegang DB, secret, dan klien kaserapay
e := echo.New()
e.Use(middleware.Recover())
// CSRF global boleh tetap ada untuk form milik aplikasi, asalkan route
// webhook dilewati. Tanpa Skipper ini setiap pengiriman dijawab 400.
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
Skipper: func(c *echo.Context) bool {
return c.Path() == "/webhooks/kasera"
},
}))
// Webhook didaftarkan pada e, DI LUAR group bermiddleware login.
// BodyLimit menjawab 413 untuk body di atas 1 MB, juga yang chunked.
e.POST("/webhooks/kasera", s.kaseraWebhook, middleware.BodyLimit(1<<20))
app := e.Group("", s.requireLogin)
app.POST("/pesanan/:id/bayar", s.bayar)
srv := &http.Server{
Addr: ":8080",
Handler: e,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}CSRF global menolak webhook dengan 400. Pengirim webhook adalah server, bukan peramban. Pengirim itu tidak membawa token CSRF, cookie, maupun header Sec-Fetch-Site. Pada percobaan, e.Use(middleware.CSRF()) menjawab pengiriman tanpa token dengan 400 di v5 maupun v4. Kodenya sama dengan kode untuk tanda tangan yang salah, sehingga penyebabnya mudah disangka secret yang keliru. Skipper yang mencocokkan c.Path() menyelesaikannya, dan middleware CSRF tetap melindungi form milik aplikasi.
Group bermiddleware login juga bukan tempatnya. Route webhook di dalam group yang memakai middleware.KeyAuth dijawab 401 dengan pesan missing key pada percobaan. Keamanan route ini datang dari tanda tangan HMAC, bukan dari middleware.
Server ditulis sendiri. e.Start pada v5 hanya memasang ReadTimeout 30 detik, dan pada v4 tidak memasang timeout apa pun. Dokumentasi Echo sendiri menyebut http.Server dengan Handler: e sebagai jalan yang setara, jadi keempat timeout bisa diatur sesuai kebutuhan route yang terbuka ke internet.
2. Membuat tagihan dari handler Echo
// Tombol "Bayar" di halaman pesanan, dikirim sebagai form POST.
func (s *Server) bayar(c *echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
order, err := s.orders.Find(ctx, c.Param("id"))
if err != nil {
return echo.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.String(http.StatusUnprocessableEntity, "Pesanan tidak bisa diproses")
case err != nil:
return c.String(http.StatusServiceUnavailable, "Coba lagi sebentar")
}
if err := s.orders.SavePaymentRequest(ctx, order.ID, tx.ID); err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, tx.CheckoutURL)
}*echo.Context pada v5 tidak memenuhi interface context.Context, jadi context untuk database dan untuk klien API selalu diambil dari c.Request().Context(). Kesalahan itu tertangkap compiler, tidak seperti di Gin.
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. Tombol bayar dijawab 303 See Other ke checkout_url, sehingga tombol kembali di peramban tidak mengirim ulang form-nya.
3. Route webhook: byte mentah lebih dulu
func (s *Server) kaseraWebhook(c *echo.Context) error {
// 1. Byte mentah lebih dulu. JANGAN c.Bind di sini: Bind menghabiskan
// body, dan io.ReadAll sesudahnya mengembalikan nol byte tanpa galat.
raw, err := io.ReadAll(c.Request().Body)
if err != nil {
return err // dari BodyLimit: dijawab 413
}
// 2. Verifikasi atas byte yang sama persis dengan yang ditandatangani.
if !verify(raw, c.Request().Header.Get("Kasera-Signature-V1"), s.webhookSecret) {
return echo.ErrBadRequest
}
// 3. Baru sekarang JSON dibaca, dari slice yang sama.
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
return echo.ErrBadRequest
}
ctx := c.Request().Context()
// 4. Catatan event dan perubahan pesanan dalam satu transaksi. Galat
// biasa yang dikembalikan handler dijawab 500 oleh Echo, 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.NoContent(http.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.NoContent(http.StatusOK)
}c.Bind(&event) di baris pertama adalah kebiasaan yang benar di handler Echo lain dan kesalahan di route ini. Pada percobaan, Bind berhasil mengisi struct, lalu io.ReadAll(c.Request().Body) sesudahnya mengembalikan nol byte dengan err bernilai nil, di v5 maupun v4. Tidak ada galat yang mengarah ke penyebabnya. Menyusun ulang struct dengan json.Marshal juga tidak menolong, karena spasi dan urutan key pada payload asli tidak kembali, sedangkan HMAC dihitung atas byte.
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.
Kode jawaban menentukan pengiriman ulang. Galat biasa yang dikembalikan handler dijawab 500 oleh HTTPErrorHandler bawaan, dengan pesan umum yang tidak membocorkan isi galatnya, dan 500 membuat event dikirim ulang. Itu sebabnya galat database cukup dikembalikan apa adanya, sedangkan tanda tangan yang salah dijawab echo.ErrBadRequest.
Handler di atas diuji pada Postgres 16 dengan race detector menyala. Pengiriman sah dijawab 200, secret yang salah dan timestamp berumur sepuluh menit dijawab 400, body 2 MB dijawab 413 baik dengan Content-Length maupun chunked, dan empat puluh pengiriman event yang sama secara bersamaan menghasilkan empat puluh jawaban 200 dan satu pesanan lunas. MarkPaid yang dipaksa gagal menghasilkan 500 tanpa meninggalkan catatan event, pengiriman ulangnya menandai pesanan lunas, dan duplikat sesudahnya dijawab 200 tanpa mengubah apa pun. Sebagai bukti bahwa ujinya bisa gagal, c.Bind yang disisipkan sebelum io.ReadAll mengubah pengiriman sah menjadi 400. 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.
4. Garis miring di akhir URL
Echo tidak mengalihkan apa pun secara bawaan. Kalau yang terdaftar /webhooks/kasera, request ke /webhooks/kasera/ dijawab 404, dan /webhooks/Kasera juga 404. Dengan CSRF global yang terpasang, jawabannya malah 400, karena route yang tidak ditemukan tidak cocok dengan Skipper.
Middleware garis miring hanya menolong dalam satu susunan, dan hasil ukurnya begini:
| Susunan | URL tanpa garis miring | URL dengan garis miring |
|---|---|---|
| Tanpa middleware | 200 | 404 |
e.Pre(middleware.RemoveTrailingSlash()) | 200 | 200, path ditulis ulang tanpa redirect |
Sama, dengan RedirectCode: 301 | 200 | Gagal: pengirim menolak redirect |
e.Use(middleware.RemoveTrailingSlash()) | 200 | 404, karena routing sudah terjadi |
e.Pre(middleware.AddTrailingSlash()) | 404 | 404 |
Pengirim webhook Kasera Pay tidak mengikuti redirect, karena alamat tujuan redirect adalah URL kedua yang tidak pernah divalidasi. Baris ketiga itulah yang membuat setelan “rapikan URL dengan 301” berbahaya untuk webhook. Perbaikan yang paling sederhana tetap di dashboard: 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 400, 401, atau 404 langsung tampil sebagai gagal. Urutan pemeriksaan berikutnya ada di tulisan tentang webhook yang tidak sampai.
5. Sebelum go-live
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. Database dimatikan di tengah, dan jawabannya harus 500, bukan 200. Lalu go test -race atas handler webhook, karena satu handler Echo melayani banyak request sekaligus.
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. Tarif yang berlaku untuk akun tampil di dashboard.
Pertanyaan yang sering muncul
Apakah panduan ini berlaku untuk Echo v4?
Berlaku, dengan tiga perubahan penulisan. Handler v4 menerima echo.Context sebagai interface, bukan *echo.Context. BodyLimit v4 menerima string seperti "1M", bukan angka byte. Pesan galat CSRF v4 lebih jelas, yaitu "missing csrf token in request header", tetapi kodenya sama: 400. Perilaku yang menentukan diukur sama pada v4.16.0: c.Bind menghabiskan body tanpa galat, dan URL dengan garis miring tambahan dijawab 404. Satu perbedaan nyata ada di server: e.Start pada v4 memakai http.Server tanpa satu pun timeout, sedangkan v5 memasang ReadTimeout 30 detik saja.
Kenapa semua webhook ditolak 400 padahal secret-nya benar?
Ada dua penyebab yang jawabannya sama-sama 400, dan keduanya perlu dibedakan sebelum secret dicurigai. Pertama, middleware CSRF global: pengirim webhook tidak membawa token CSRF, jadi Echo menolaknya sebelum handler berjalan. Kedua, body yang sudah habis dibaca oleh c.Bind atau oleh middleware pencatat request, sehingga HMAC dihitung atas body kosong. Cara membedakannya: pasang log di baris pertama handler. Kalau log itu tidak pernah muncul, penyebabnya middleware.
Apakah middleware.BodyDump aman dipakai bersama route webhook?
Aman untuk verifikasi. Pada percobaan, BodyDump membaca body lalu mengembalikannya, dan io.ReadAll di handler sesudahnya mendapat 38 byte yang identik dengan yang dikirim, termasuk spasinya. Yang perlu dijaga adalah isinya: payload webhook memuat data pembayaran, jadi log yang menyimpannya ikut menjadi tempat data itu tersimpan.
Haruskah handler mengembalikan galat atau menulis jawabannya sendiri?
Untuk webhook, kembalikan galat. HTTPErrorHandler bawaan menjawab galat biasa dengan 500 dan pesan umum "Internal Server Error", tanpa membocorkan isi galatnya, dan 500 membuat Kasera Pay mengirim ulang event itu. Untuk tanda tangan yang salah, kembalikan echo.ErrBadRequest, karena mengirim ulang byte yang sama tidak akan pernah lolos verifikasi.
Apakah ada SDK Go resmi atau middleware Echo 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 Echo tidak mengubah apa pun pada sisi itu.