Panduan · Terbit
Integrasi payment gateway di Go: satu baris decode yang menghapus tanda tangan webhook sebelum sempat diperiksa, dan http.Client bawaan yang tidak punya batas waktu sama sekali
Panduan ini memasang Kasera Pay di layanan Go tanpa SDK dan tanpa satu pun dependensi di luar pustaka standar. Yang dipakai hanya net/http, encoding/json, dan tiga paket kriptografi bawaan. Tidak ada SDK Go resmi Kasera Pay, dan tidak ada yang perlu ditambahkan ke go.mod untuk mengikuti panduan ini. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay, dan ringkasan metode aktif beserta tarifnya di halaman API QRIS untuk developer.
Dua hal di bawah ini yang paling sering menjatuhkan integrasi Go, dan keduanya bukan soal kriptografi. Yang pertama adalah json.NewDecoder(r.Body).Decode pada route webhook: satu baris itu menghabiskan stream body-nya, dan setelah itu byte yang ditandatangani sudah tidak ada lagi untuk diperiksa. Yang kedua adalah http.Client yang dibiarkan pada nilai bawaannya, yang tidak memiliki batas waktu sama sekali.
Kalau layanannya bukan Go, kontraknya sama persis dan yang berbeda hanya perkakasnya: integrasi payment gateway di Node.js dan Express dan integrasi payment gateway di PHP tanpa framework mengerjakan hal yang sama.
Alurnya, sebelum menulis kode
Ada empat hal yang bergerak, dan urutannya menentukan apa yang boleh dipercaya. Server membuat permintaan pembayaran. Pembeli dibawa ke checkout_url. Pembeli membayar di sana. Lalu Kasera Pay mengirim payment.paid bertanda tangan ke route webhook, dan hanya event itulah yang menjadi penentu bahwa uangnya masuk. Kepulangan pembeli ke return_url bukan bukti pembayaran. Kalau webhook bukan pilihan yang tepat untuk layanan ini, pertimbangannya dibandingkan utuh di perbandingan menunggu webhook melawan menanyakan status berkala.
1. Kredensial dan metode yang aktif
API key dibawa sebagai bearer token dan berawalan kp_test_ selama membangun, kp_live_ setelah go-live. Signing secret webhook berbeda per mode dan diambil dari dashboard, menu Developer. Mode live dan mode tes adalah endpoint terpisah dengan secret masing-masing, dan secret tes tidak pernah bisa memverifikasi payload live.
# .env: kp_test_ selama membangun, kp_live_ setelah go-live.
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
KASERA_PAY_BASE_URL=https://pay.kasera.idMetode yang bisa disebut pada payment_methods saat ini adalah qris dan delapan kode Virtual Account: va_bca, va_bri, va_bni, va_mandiri, va_permata, va_cimb, va_danamon, dan va_maybank. QRIS dikenai 0,7% + Rp 250 per transaksi berhasil dan Virtual Account Rp 5.000 tetap, dengan nominal minimum Rp 1.000 pada QRIS dan Rp 10.000 pada Virtual Account.
2. Klien kecil, dan tiga keputusan tipe yang menentukan sisanya
// kaserapay/client.go
package kaserapay
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// http.DefaultClient tidak punya batas waktu sama sekali. Sebuah permintaan
// yang menggantung akan menggantung selamanya, dan pada jalur pembayaran itu
// berarti goroutine yang menahan checkout pembeli tidak pernah selesai.
// Timeout di sini adalah plafon untuk seluruh permintaan, termasuk pembacaan
// body-nya; context per panggilan yang mengatur sisanya.
type Client struct {
BaseURL string
Key string
HTTP *http.Client
}
func New(baseURL, key string) *Client {
return &Client{
BaseURL: baseURL,
Key: key,
HTTP: &http.Client{Timeout: 20 * time.Second},
}
}
// Nominal rupiah selalu int64. Decode ke map[string]any menjadikannya
// float64, yang bukan tipe untuk uang dan tidak pernah perlu di sini:
// amount adalah bilangan bulat rupiah, bukan pecahan.
type CreateRequest struct {
Amount int64 `json:"amount"`
Description string `json:"description,omitempty"`
ExternalID string `json:"external_id,omitempty"`
MerchantRef string `json:"merchant_ref,omitempty"`
Customer *Customer `json:"customer,omitempty"`
ReturnURL string `json:"return_url,omitempty"`
PaymentMethods []string `json:"payment_methods,omitempty"`
}
type Customer struct {
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
}
type Transaction struct {
ID string `json:"id"`
Status string `json:"status"`
Amount int64 `json:"amount"`
Fee int64 `json:"fee"`
Net int64 `json:"net"`
CheckoutURL string `json:"checkout_url"`
ExpiresAt string `json:"expires_at"`
}
// Galat bertipe, supaya pemanggil bisa memutuskan retry lewat errors.As dan
// bukan lewat pencocokan teks pesan.
type APIError struct {
StatusCode int
Code string
}
func (e *APIError) Error() string {
return fmt.Sprintf("kasera pay: %d %s", e.StatusCode, e.Code)
}
// Permanen berarti body yang sama akan ditolak terus, jadi mengulanginya
// hanya membuang waktu. 429 dan 5xx adalah kebalikannya.
func (e *APIError) Retryable() bool {
return e.StatusCode == 429 || e.StatusCode >= 500
}
func (c *Client) CreateTransaction(
ctx context.Context, in CreateRequest, idempotencyKey string,
) (*Transaction, error) {
body, err := json.Marshal(in)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, c.BaseURL+"/v1/transactions", bytes.NewReader(body),
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
req.Header.Set("Content-Type", "application/json")
// Opsional di API, dan satu-satunya hal yang mencegah satu pesanan
// menjadi dua pembayaran. Dibuat sekali, lalu dipakai apa adanya pada
// setiap percobaan ulang.
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
// Dua baris, bukan satu. Close saja tidak cukup: koneksi baru bisa
// dipakai ulang kalau body-nya sudah terbaca sampai habis, dan body
// yang ditinggalkan setengah membuat setiap create membuka koneksi TCP
// dan handshake TLS baru.
defer func() {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}()
raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, err
}
if res.StatusCode >= 300 {
var e struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
json.Unmarshal(raw, &e)
return nil, &APIError{StatusCode: res.StatusCode, Code: e.Error.Code}
}
var tx Transaction
if err := json.Unmarshal(raw, &tx); err != nil {
return nil, err
}
return &tx, nil
}Tiga hal di berkas itu layak diperhatikan lebih lama daripada kelihatannya.
Timeout tidak ada sampai ditulis. http.DefaultClient dan &http.Client{} sama-sama tidak punya batas waktu, jadi permintaan yang menggantung akan menggantung sampai lawan bicaranya memutus koneksi, yang bisa tidak pernah terjadi. Pada jalur pembayaran akibatnya berantai: goroutine yang melayani checkout tidak pernah selesai, pembeli menekan tombol bayar sekali lagi, dan percobaan kedua itu menjadi tagihan kedua kalau key idempotensinya dibuat baru. Field Timeout adalah plafon untuk seluruh permintaan termasuk pembacaan body-nya; http.NewRequestWithContext yang meneruskan pembatalan dari sisi pemanggil.
Nominal adalah int64, tidak pernah float64. Decode ke map[string]any memberikan setiap angka sebagai float64, dan dari situ rupiah mulai dicetak dalam notasi ilmiah dan dibandingkan dengan toleransi yang tidak pernah dibutuhkan. amount, fee, dan net adalah bilangan bulat rupiah menurut API, jadi struct dengan int64 menghilangkan seluruh kelas masalah itu sebelum muncul.
Body response dibaca sampai habis, bukan sekadar ditutup. Koneksi baru dikembalikan ke pool dan dipakai ulang kalau body-nya terbaca sampai EOF. Body yang ditinggalkan setengah lalu di-Close membuat koneksinya dibuang, sehingga tiap pembuatan tagihan membayar handshake TLS baru. Karena itu defer di atas berisi io.Copy(io.Discard, res.Body) sebelum Close.
3. Membuat tagihan, dan satu key yang harus lebih tua daripada percobaannya
// Pembuatan tagihan dari handler HTTP milik penjual sendiri.
func (s *Server) bayar(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
order, err := s.orders.Find(ctx, r.PathValue("id"))
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Key disimpan bersama pesanannya, bukan dibuat di dalam percobaan.
// Key baru pada tiap percobaan menghapus proteksinya tanpa galat apa pun
// yang memberi tahu, dan hasilnya adalah dua tagihan untuk satu pesanan.
if order.IdempotencyKey == "" {
order.IdempotencyKey = uuid.NewString()
if err := s.orders.SaveKey(ctx, order.ID, order.IdempotencyKey); err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
}
tx, err := s.pay.CreateTransaction(ctx, kaserapay.CreateRequest{
Amount: order.Total,
Description: "Pesanan " + order.Number,
ExternalID: order.Number,
Customer: &kaserapay.Customer{Name: order.CustomerName},
ReturnURL: s.SiteURL + "/pesanan/" + order.Number,
PaymentMethods: []string{"qris", "va_bca"},
}, order.IdempotencyKey)
var apiErr *kaserapay.APIError
switch {
case errors.As(err, &apiErr) && !apiErr.Retryable():
// 422 validation_failed, 422 customer_required, 409
// idempotency_conflict: body yang sama akan ditolak terus.
log.Printf("create ditolak permanen: %s", apiErr.Code)
http.Error(w, "tidak bisa diproses", http.StatusBadRequest)
return
case err != nil:
http.Error(w, "coba lagi", http.StatusServiceUnavailable)
return
}
s.orders.SavePaymentRequest(ctx, order.ID, tx.ID)
http.Redirect(w, r, tx.CheckoutURL, http.StatusSeeOther)
}Hanya Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. Header itu opsional, dan tanpa header itu setiap percobaan menjadi permintaan pembayaran tersendiri. Karena itu key dibuat sekali lalu disimpan bersama pesanannya: key yang dibangkitkan di dalam blok percobaan berubah pada tiap percobaan dan proteksinya hilang tanpa galat apa pun yang memberi tahu. external_id dan merchant_ref tidak menggantikannya; keduanya hanya label yang disimpan, dikembalikan, dan bisa difilter, dan tidak pernah menyatakan bahwa dua permintaan adalah satu pembayaran. Latar belakangnya ada di idempotency untuk pembayaran.
Keputusan retry diambil dari kode galatnya, bukan dari teks pesannya, dan itulah alasan APIError ada. 422 validation_failed, 422 customer_required, dan 409 idempotency_conflict adalah penolakan permanen. 429 rate_limited menyala pada 300 request per menit untuk satu API key, dan retry-nya wajib membawa key idempotensi yang sama; bentuk antreannya dibahas di rate limit dan lonjakan permintaan.
4. Route webhook, dan baris yang harus berada di paling atas
// webhook.go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
const toleransi = 5 * time.Minute
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt string `json:"created_at"`
Data struct {
PaymentRequestID string `json:"payment_request_id"`
ExternalID string `json:"external_id"`
Amount int64 `json:"amount"`
PaidAt string `json:"paid_at"`
} `json:"data"`
}
func (s *Server) webhook(w http.ResponseWriter, r *http.Request) {
// BODY MENTAH LEBIH DULU, SEBELUM APA PUN MENYENTUHNYA.
// Memanggil json.NewDecoder(r.Body).Decode(&event) di sini akan
// menghabiskan stream-nya, dan setelah itu byte yang ditandatangani
// tidak ada lagi di mana pun untuk diperiksa.
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "body too large", http.StatusBadRequest)
return
}
if !verify(raw, r.Header.Get("Kasera-Signature-V1"), s.WebhookSecret) {
// Selain 2xx berarti ditolak dan pengiriman diulang. Itu memang
// yang diinginkan kalau yang salah adalah secret yang terpasang.
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event Event
if err := json.Unmarshal(raw, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// Dedupe berdasarkan id event. Pengiriman bersifat at-least-once, jadi
// event yang sama bisa datang lebih dari sekali dengan id yang sama.
// Unique index pada kolomnya yang menjadi penjaga, bukan pemeriksaan
// "sudah ada atau belum" yang dua pengiriman bersamaan akan lewati.
if err := s.events.Insert(r.Context(), event.ID, event.Type); err != nil {
if isUniqueViolation(err) {
w.WriteHeader(http.StatusOK)
return
}
// 500 berarti "coba lagi nanti", dan itu benar: database yang
// sedang tidak bisa dihubungi bukan alasan menyatakan event ini
// selesai.
http.Error(w, "internal", http.StatusInternalServerError)
return
}
if event.Type == "payment.paid" {
if err := s.penuhiPesanan(r.Context(), event.Data.ExternalID); err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
}Urutan tiga baris pertama di dalam handler itu adalah keseluruhan bagian ini. Body sebuah permintaan HTTP di Go adalah io.ReadCloser yang habis begitu dibaca, dan json.NewDecoder(r.Body).Decode(&event) membacanya sampai selesai. Bentuk itu adalah kebiasaan yang benar di hampir setiap handler Go lain, dan justru karena kebiasaan itulah ia muncul di route webhook tanpa dipikirkan ulang.
Akibatnya bukan galat yang terbaca sebagai galat. Setelah decode, yang tersisa adalah struct Go, dan menyusunnya kembali dengan json.Marshal menghasilkan teks yang isinya sama tetapi byte-nya tidak: spasi pada payload asli hilang, urutan key mengikuti urutan field struct, dan field yang tidak ikut didefinisikan hilang sama sekali. HMAC dihitung atas byte, jadi tanda tangan yang dihitung ulang tidak akan pernah cocok, sementara di log payload-nya tetap terlihat benar dan masuk akal. Yang terlihat oleh developer adalah seluruh pengiriman yang sah ditolak dengan alasan tanda tangan tidak valid, padahal secret-nya benar.
http.MaxBytesReader di baris yang sama bukan hiasan. Tanpa batas, io.ReadAll pada sebuah route publik bersedia mengalokasikan sebanyak apa pun yang dikirim ke sana, dan route webhook memang harus terbuka ke internet.
Dedupe bersandar pada unique index di database, bukan pada pemeriksaan “sudah ada atau belum” sebelum penulisan: dua pengiriman event yang sama bisa tiba bersamaan dan keduanya akan membaca “belum ada” sebelum salah satunya sempat menulis. Kedaluwarsa tidak pernah dikirim sebagai webhook, jadi pelepasan stok dijalankan dari expires_at yang disimpan sendiri.
5. Verifikasi tanda tangan
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
func verify(raw []byte, header, secret string) bool {
parts := strings.Split(header, ",")
if len(parts) < 2 || !strings.HasPrefix(parts[0], "t=") {
return false
}
t, err := strconv.ParseInt(strings.TrimPrefix(parts[0], "t="), 10, 64)
if err != nil {
return false
}
if math.Abs(time.Since(time.Unix(t, 0)).Seconds()) > toleransi.Seconds() {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(t, 10)))
mac.Write([]byte("."))
mac.Write(raw)
expected := mac.Sum(nil)
// Beberapa entri v1 muncul selama rotasi signing secret: secret lama
// dan secret baru menandatangani payload yang sama selama 24 jam, dan
// cocok dengan salah satunya sudah cukup.
for _, p := range parts[1:] {
if !strings.HasPrefix(p, "v1=") {
continue
}
got, err := hex.DecodeString(strings.TrimPrefix(p, "v1="))
if err != nil {
continue
}
// hmac.Equal, bukan == dan bukan bytes.Equal. Perbandingan biasa
// berhenti pada byte pertama yang berbeda, dan selisih waktunya
// bisa dipakai menebak tanda tangan satu byte demi satu byte.
if hmac.Equal(got, expected) {
return true
}
}
return false
}Header Kasera-Signature-V1 berisi t=<unix>,v1=<hex>, yang ditandatangani adalah t dan body mentah yang disambung dengan satu titik, dan toleransi waktunya lima menit. Toleransi itulah yang mencegah pengiriman yang disadap di-replay belakangan, jadi membuangnya bukan penyederhanaan melainkan pelemahan. Setelah rotasi signing secret, header membawa dua entri v1 selama 24 jam, satu per secret, sehingga cocok dengan salah satunya sudah cukup dan secret baru bisa dipasang tanpa jendela mati. Id event juga tersedia di header Kasera-Event-Id. Pengiriman masih membawa header Kasera-Signature yang lama, hex polos tanpa timestamp, tetapi header itu sudah deprecated dan tidak melindungi dari replay; integrasi baru memakai yang bertimestamp. Penyimpanan secret dan key-nya sendiri dibahas di menyimpan dan merotasi kunci API.
Perbandingannya memakai hmac.Equal, bukan bytes.Equal dan bukan == atas bentuk hex-nya. Perbandingan biasa berhenti pada byte pertama yang berbeda, sehingga lamanya bergantung pada berapa banyak byte awal yang sudah benar, dan selisih waktu itu bisa diukur dari luar lalu dipakai menebak tanda tangan satu byte demi satu byte. hmac.Equal selalu memakan waktu yang sama untuk panjang yang sama.
6. Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik, jadi localhost tidak bisa didaftarkan langsung dan selama membangun perlu terowongan yang menerbitkannya ke alamat publik. Masa berlaku permintaan pembayaran default 60 menit dengan plafon akun 24 jam.
Empat hal yang layak dicoba di mode tes, dan tiga di antaranya khas Go. Pertama, pengiriman yang sah harus diterima: kalau seluruhnya ditolak, hampir selalu decode yang naik ke atas pembacaan body yang penyebabnya. Kedua, event yang sama dikirim dua kali harus menghasilkan satu pesanan terpenuhi dan satu balasan 200 tanpa kerja tambahan. Ketiga, jalankan seluruh tes verifikasi dengan -race kalau handler-nya menyentuh state bersama, karena satu handler HTTP di Go melayani banyak permintaan secara bersamaan secara bawaan. Keempat, matikan database di tengah dan pastikan yang keluar adalah 500 dan bukan 200, karena hanya yang pertama yang menghasilkan percobaan ulang.
Pertanyaan yang sering muncul
Apakah Kasera Pay punya SDK Go atau modul resmi yang perlu di-go get?
Tidak ada, dan tidak diperlukan. Seluruh integrasi di panduan ini memakai pustaka standar saja: net/http untuk permintaannya, encoding/json untuk body-nya, serta crypto/hmac, crypto/sha256, dan encoding/hex untuk verifikasi tanda tangannya. Tidak ada modul yang perlu ditambahkan ke go.mod di luar driver database yang sudah dipakai layanannya, dan tidak ada rilis modul yang perlu ditunggu setiap kali API bertambah.
Kenapa body webhook harus dibaca dengan io.ReadAll dan bukan langsung di-decode?
Karena r.Body adalah io.ReadCloser yang hanya bisa dibaca satu kali, dan json.NewDecoder(r.Body).Decode menghabiskannya. Setelah baris itu jalan, byte yang ditandatangani sudah tidak ada lagi di mana pun: yang tersisa adalah struct Go hasil decode. Menyusunnya kembali dengan json.Marshal menghasilkan teks yang isinya sama tetapi byte-nya tidak, karena spasi dan urutan key pada payload asli tidak dipertahankan, sehingga HMAC yang dihitung ulang tidak akan pernah cocok. Yang terlihat oleh developer adalah seluruh pengiriman yang sah ditolak dengan alasan tanda tangan tidak valid, padahal secret-nya benar. Baca byte-nya lebih dulu, verifikasi atas byte itu, lalu json.Unmarshal dari slice yang sama.
Kenapa hmac.Equal dan bukan bytes.Equal atau perbandingan string biasa?
Karena bytes.Equal dan == berhenti pada byte pertama yang berbeda, sehingga lamanya perbandingan bergantung pada berapa banyak byte awal yang sudah benar. Selisih waktu itu bisa diukur dari luar dan dipakai menebak tanda tangan satu byte demi satu byte, tanpa pernah tahu secret-nya. hmac.Equal memakai crypto/subtle di dalamnya dan selalu memakan waktu yang sama untuk panjang yang sama. Bandingkan juga dalam bentuk byte hasil hex.DecodeString, bukan dalam bentuk string hex-nya.
Apa yang terjadi kalau http.Client dibiarkan memakai nilai bawaannya?
http.Client bawaan tidak punya batas waktu sama sekali, jadi permintaan yang menggantung akan menggantung selamanya. Pada jalur pembayaran akibatnya berantai: goroutine yang melayani checkout tidak pernah selesai, pembeli menekan tombol bayar sekali lagi, dan kalau Idempotency-Key dibuat baru pada tiap percobaan, percobaan kedua itu menjadi tagihan kedua untuk pesanan yang sama. Pasang Timeout pada client-nya sebagai plafon, dan context.WithTimeout per panggilan supaya pembatalan dari sisi pemanggil ikut diteruskan.
Kenapa response body harus dibaca sampai habis, bukan hanya di-Close?
Karena koneksi HTTP hanya dikembalikan ke pool dan dipakai ulang kalau body-nya sudah terbaca sampai EOF. Body yang ditinggalkan setengah lalu di-Close membuat koneksinya dibuang, sehingga setiap pembuatan tagihan membuka koneksi TCP dan handshake TLS baru. Pada volume rendah hal itu tidak terlihat, dan pada penjualan yang sedang ramai hal itu menambah latensi pada bagian yang paling tidak boleh lambat. Pola yang aman adalah io.Copy(io.Discard, res.Body) lalu res.Body.Close() di dalam satu defer.
Bagaimana membedakan galat yang pantas diulang dari yang tidak?
Dari status dan kode galatnya, bukan dari teks pesannya. 422 validation_failed, 422 customer_required, dan 409 idempotency_conflict adalah penolakan permanen: body yang sama akan ditolak terus, jadi percobaan ulang hanya membuang waktu. 429 rate_limited dan 5xx adalah kebalikannya, dan retry-nya wajib membawa Idempotency-Key yang sama supaya create yang ternyata sudah masuk dikembalikan dan bukan dibuat dua kali. Bungkus keduanya dalam satu tipe galat yang membawa kode itu, lalu putuskan lewat errors.As.
Apakah kepulangan pembeli ke return_url boleh dipakai sebagai tanda lunas?
Tidak. Halaman itu bisa dibuka siapa saja, termasuk pembeli yang menutup halaman pembayaran tanpa membayar. Penanda lunas hanya event payment.paid yang bertanda tangan, atau pembacaan langsung ke GET /v1/transactions/{id} dari server. Halaman kepulangan sebaiknya membaca ulang status dari server, bukan menyimpulkannya dari fakta bahwa halamannya terbuka.