Panduan · Terbit
Integrasi payment gateway di Spring Boot: @RequestBody byte[] sebelum Jackson menyentuh apa pun, CSRF yang menolak webhook dengan 403, dan @Transactional yang diam-diam tidak berjalan
Panduan ini memasang Kasera Pay di aplikasi Spring Boot 3 dengan Java 17 atau lebih baru, tanpa SDK dan tanpa dependensi tambahan di luar yang sudah dibawa Spring Boot: RestClient untuk memanggil API, Jackson untuk JSON, dan javax.crypto untuk tanda tangan webhook. Referensi endpoint lengkapnya ada di dokumentasi API Kasera Pay.
Kontraknya sama dengan panduan bahasa lain. Yang membuat Spring berbeda adalah tiga hal yang dikerjakan kerangka kerjanya sebelum kode milik sendiri berjalan, dan ketiganya merusak webhook tanpa galat yang jelas: Jackson yang membaca body sebelum controller, Spring Security yang menolak POST tanpa token CSRF, dan proxy transaksi yang tidak dilewati panggilan dari dalam kelas yang sama. Kalau urutan umumnya lebih mudah dibaca dalam bahasa lain, panduan Go mengerjakan hal yang sama dengan pustaka standar saja.
1. Kredensial dan metode yang aktif
# application.properties
kasera.pay.base-url=https://pay.kasera.id
kasera.pay.key=${KASERA_PAY_KEY}
kasera.pay.webhook-secret=${KASERA_PAY_WEBHOOK_SECRET}API key dibawa sebagai bearer token, berawalan kp_test_ selama membangun dan kp_live_ setelah go-live. Signing secret webhook diambil dari dasbor, menu Developer, dan berbeda per mode: secret tes tidak pernah bisa memverifikasi payload live. Metode yang bisa disebut di 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.
2. Klien, record, dan dua pengaturan Jackson yang tidak boleh global
@Configuration
class KaseraPayConfig {
// RestClient.Builder dari Spring Boot sudah memakai ObjectMapper aplikasi.
// Batas waktunya tidak: tanpa baris setReadTimeout, permintaan yang
// menggantung ditunggu tanpa batas.
@Bean
RestClient kaseraPayRestClient(RestClient.Builder builder,
@Value("${kasera.pay.base-url}") String baseUrl,
@Value("${kasera.pay.key}") String key) {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5_000);
factory.setReadTimeout(20_000);
return builder
.baseUrl(baseUrl)
.requestFactory(factory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + key)
.build();
}
}
// @JsonNaming per record, bukan spring.jackson.property-naming-strategy:
// properti global itu mengubah JSON seluruh API milik aplikasi sendiri.
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@JsonInclude(JsonInclude.Include.NON_NULL)
public record CreateRequest(long amount, String description, String externalId,
Customer customer, String returnUrl,
List<String> paymentMethods) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Customer(String name, String email) {}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public record Transaction(String id, String status, long amount, long fee, long net,
String checkoutUrl, OffsetDateTime expiresAt) {}
public class KaseraPayException extends RuntimeException {
public final int status;
public final String code;
public final String requestId;
KaseraPayException(int status, String code, String requestId) {
super("kasera pay " + status + " " + code + " request_id=" + requestId);
this.status = status;
this.code = code;
this.requestId = requestId;
}
// 429 dan 5xx boleh diulang dengan Idempotency-Key yang sama.
// Sisanya akan ditolak lagi dengan body yang sama.
public boolean retryable() {
return status == 429 || status >= 500;
}
}
@Component
public class KaseraPayClient {
private final RestClient http;
private final ObjectMapper mapper;
KaseraPayClient(RestClient kaseraPayRestClient, ObjectMapper mapper) {
this.http = kaseraPayRestClient;
this.mapper = mapper;
}
public Transaction create(CreateRequest body, String idempotencyKey) {
return http.post()
.uri("/v1/transactions")
.contentType(MediaType.APPLICATION_JSON)
.header("Idempotency-Key", idempotencyKey)
.body(body)
.retrieve()
.onStatus(HttpStatusCode::isError, (req, res) -> {
String code = null;
try {
code = mapper.readTree(res.getBody()).path("error").path("code").asText(null);
} catch (IOException notJson) {
// Balasan proxy berupa HTML tetap dilaporkan lewat statusnya.
}
throw new KaseraPayException(res.getStatusCode().value(), code,
res.getHeaders().getFirst("X-Request-Id"));
})
.body(Transaction.class);
}
}Batas waktu ditulis sendiri. SimpleClientHttpRequestFactory tanpa pengaturan menunggu jawaban tanpa batas, dan pada jalur pembayaran itu berarti thread checkout yang tidak pernah kembali sementara pembeli menekan tombol bayar sekali lagi. Lima detik untuk koneksi dan dua puluh detik untuk jawaban adalah titik awal yang wajar.
Penamaan snake_case dipasang per record. Menulis spring.jackson.property-naming-strategy=SNAKE_CASE memang lebih singkat, tetapi properti itu mengubah JSON setiap controller di aplikasi, termasuk API yang sudah dipakai frontend sendiri. @JsonNaming pada record membatasi pengaruhnya pada tipe milik integrasi ini. @JsonInclude(NON_NULL) membuat field yang tidak diisi dihilangkan dari body, bukan dikirim sebagai null.
ObjectMapper diambil dari Spring, bukan dibuat dengan new. Mapper bawaan Spring Boot tidak gagal pada field yang tidak dikenal, sedangkan new ObjectMapper() gagal. Response API bisa bertambah field kapan saja, dan mapper buatan sendiri akan mengubah tambahan itu menjadi galat pada setiap pembuatan tagihan. Nominal adalah long, tidak pernah double: rupiah di API selalu bilangan bulat.
Galat dari API berbentuk error.code, dan keputusan mengulang diambil dari kode itu, bukan dari teks pesannya. Header X-Request-Id ikut disimpan di pengecualian karena nilainya yang disebutkan saat menghubungi dukungan. Daftar kodenya ada di dokumentasi galat.
3. Membuat tagihan dengan key yang lebih tua dari percobaannya
@Controller
class CheckoutController {
private final OrderRepository orders;
private final KaseraPayClient kaseraPay;
CheckoutController(OrderRepository orders, KaseraPayClient kaseraPay) {
this.orders = orders;
this.kaseraPay = kaseraPay;
}
@PostMapping("/pesanan/{number}/bayar")
String bayar(@PathVariable String number) {
Order order = orders.findByNumber(number).orElseThrow();
// Key dibuat sekali dan disimpan bersama pesanannya, di luar blok
// percobaan mana pun. Key baru pada tiap percobaan menghapus proteksinya
// tanpa galat, dan hasilnya dua tagihan untuk satu pesanan.
String key = orders.ensureIdempotencyKey(order.getId());
Transaction tx = kaseraPay.create(new CreateRequest(
order.getTotal(),
"Pesanan " + order.getNumber(),
order.getNumber(),
new Customer(order.getCustomerName(), null),
"https://toko.example/pesanan/" + order.getNumber(),
List.of("qris", "va_bca")), key);
orders.attachPaymentRequest(order.getId(), tx.id());
return "redirect:" + tx.checkoutUrl();
}
}Hanya header Idempotency-Key yang mencegah satu pesanan menjadi dua pembayaran. external_id dan merchant_ref hanya label yang disimpan dan bisa difilter, dan dua create dengan nilai yang sama tetap menjadi dua permintaan. Key yang dibangkitkan di dalam method yang diulang, baik oleh loop sendiri maupun oleh @Retryable dari Spring Retry, berganti pada tiap percobaan dan proteksinya hilang tanpa peringatan. Latar belakangnya ada di tulisan tentang idempotency.
4. Spring Security: kecualikan satu route, bukan seluruh aplikasi
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
http
// Webhook tidak membawa token CSRF dan tidak pernah bisa membawanya.
// Keamanannya datang dari tanda tangan, bukan dari sesi.
.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/kasera-pay"))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/webhooks/kasera-pay").permitAll()
.anyRequest().authenticated());
return http.build();
}Begitu spring-boot-starter-security ada di classpath, setiap POST tanpa token CSRF ditolak 403, dan route yang belum diberi permitAll meminta login. Kiriman dari server Kasera Pay tidak punya sesi dan tidak membawa token CSRF, jadi webhook ditolak sebelum mencapai controller. Yang terlihat bukan galat di aplikasi, melainkan deretan percobaan gagal di log pengiriman webhook. Kalau aplikasi sudah punya SecurityFilterChain, dua baris di atas digabungkan ke rantai yang ada.
5. Route webhook yang menerima byte
@RestController
class KaseraWebhookController {
private final WebhookVerifier verifier;
private final PaymentEventHandler handler;
private final ObjectMapper mapper;
KaseraWebhookController(WebhookVerifier verifier, PaymentEventHandler handler,
ObjectMapper mapper) {
this.verifier = verifier;
this.handler = handler;
this.mapper = mapper;
}
// byte[], bukan KaseraEvent dan bukan String. Parameter bertipe objek
// membuat Jackson menghabiskan body sebelum method ini berjalan, dan byte
// yang ditandatangani tidak tersisa di mana pun.
@PostMapping("/webhooks/kasera-pay")
ResponseEntity<Void> receive(
@RequestBody byte[] body,
@RequestHeader(name = "Kasera-Signature-V1", required = false) String signature)
throws IOException {
if (signature == null || !verifier.verify(body, signature)) {
return ResponseEntity.badRequest().build();
}
KaseraEvent event = mapper.readValue(body, KaseraEvent.class);
handler.handle(event); // pengecualian di sini menjadi 500, dan 500 diulang
return ResponseEntity.ok().build();
}
}
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
record KaseraEvent(String id, String type, Data data) {
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
record Data(String paymentRequestId, String externalId, long amount, String status) {}
}Kebiasaan yang benar di hampir setiap controller Spring, yaitu menulis @RequestBody KaseraEvent event, adalah satu-satunya bentuk yang pasti salah di sini. Jackson membaca stream body sampai habis sebelum method berjalan, dan HMAC dihitung atas byte, bukan atas isi yang setara. Dengan byte[], byte yang diterima diserahkan apa adanya, diverifikasi lebih dulu, lalu di-parse dari array yang sama. Filter pencatat request yang membaca getInputStream() tanpa membungkus request-nya menimbulkan masalah yang serupa: controller menerima body kosong.
6. Verifikasi tanda tangan
@Component
class WebhookVerifier {
private static final long TOLERANCE_SECONDS = 300;
private final byte[] secret;
WebhookVerifier(@Value("${kasera.pay.webhook-secret}") String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
boolean verify(byte[] body, String header) {
String[] parts = header.split(",");
if (parts.length < 2 || !parts[0].startsWith("t=")) return false;
String t = parts[0].substring(2);
long ts;
try {
ts = Long.parseLong(t);
} catch (NumberFormatException e) {
return false;
}
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCE_SECONDS) return false;
byte[] expected;
try {
// Mac dibuat per panggilan. Bean ini singleton dan dipakai banyak
// thread sekaligus, sedangkan Mac tidak thread-safe.
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
expected = mac.doFinal(body);
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
// Selama 24 jam setelah rotasi secret ada dua entri v1.
for (int i = 1; i < parts.length; i++) {
if (!parts[i].startsWith("v1=")) continue;
byte[] got;
try {
got = HexFormat.of().parseHex(parts[i].substring(3));
} catch (IllegalArgumentException e) {
continue;
}
if (MessageDigest.isEqual(got, expected)) return true;
}
return false;
}
}Yang ditandatangani adalah t, satu titik, dan body mentah, dengan HMAC-SHA256. Timestamp yang melenceng lebih dari lima menit ditolak, dan itulah yang mencegah kiriman yang disadap diputar ulang belakangan. Dua detail khas Java: Mac dibuat per panggilan karena bean Spring adalah singleton yang dipakai banyak thread sekaligus, dan perbandingannya memakai MessageDigest.isEqual yang waktunya tidak bergantung pada letak byte pertama yang berbeda, bukan Arrays.equals. Rotasi secretnya dibahas di rotasi signing secret tanpa kehilangan satu pun event.
7. Dedupe dan pemenuhan pesanan dalam satu transaksi
@Service
public class PaymentEventHandler {
private final JdbcTemplate jdbc;
private final OrderService orders;
PaymentEventHandler(JdbcTemplate jdbc, OrderService orders) {
this.jdbc = jdbc;
this.orders = orders;
}
// Satu transaksi: catatan event dan perubahan pesanan jadi bersama atau
// batal bersama. Dipanggil dari controller, jadi lewat proxy Spring.
@Transactional
public void handle(KaseraEvent event) {
int inserted = jdbc.update(
"INSERT INTO kasera_events (id, type) VALUES (?, ?) ON CONFLICT (id) DO NOTHING",
event.id(), event.type());
if (inserted == 0) return; // sudah pernah diproses
if ("payment.paid".equals(event.type())) {
orders.markPaid(event.data().externalId(), event.data().paymentRequestId(),
event.data().amount());
}
}
}Pengiriman bersifat at-least-once, jadi event yang sama bisa datang lagi dengan id yang sama. Unique index pada kasera_events.id yang menjadi penjaganya; pada PostgreSQL, ON CONFLICT DO NOTHING membuat kiriman kedua yang tiba bersamaan menunggu yang pertama selesai lalu tidak menulis apa pun. Pada MySQL padanannya INSERT IGNORE.
Catatan event dan perubahan pesanan harus jadi atau batal bersama. Kalau event tercatat sementara markPaid gagal, pengiriman ulang dianggap duplikat dan pesanan tidak pernah terpenuhi. @Transactional menjamin keduanya hanya kalau dipanggil lewat proxy Spring, karena itu method-nya ada di bean terpisah dan bukan di controller. Jangan memindahkan markPaid ke @Async sebelum balasan 200: kegagalan setelah balasan terkirim tidak akan pernah diulang. Di dalam markPaid, bandingkan juga nominal event dengan total pesanan sebelum menandainya lunas.
Sebelum go-live
URL webhook wajib https dan mengarah ke alamat publik. Empat hal yang layak diuji di mode tes dengan MockMvc atau langsung ke aplikasi yang berjalan: kiriman sah diterima dengan 200, body yang diubah satu karakter ditolak, event yang sama dikirim dua kali hanya memenuhi pesanan sekali, dan database yang dimatikan di tengah menghasilkan 500, bukan 200. Daftar pemeriksaan lengkapnya ada di checklist sebelum go-live. Cara gateway lain membuktikan keaslian callback, dan apa yang berubah saat kode verifikasi dipindahkan, dibandingkan di tulisan tentang verifikasi callback payment gateway.
Pertanyaan yang sering muncul
Apakah ada SDK Java atau starter Spring Boot resmi untuk Kasera Pay?
Tidak ada, dan panduan ini tidak membutuhkannya. RestClient, Jackson, dan javax.crypto yang sudah ikut bersama Spring Boot 3 dan Java 17 cukup untuk seluruh integrasinya. Kalau tipe permintaan dan response ingin dibangkitkan dari kontrak API, spesifikasi OpenAPI Kasera Pay bisa diunduh tanpa kunci, dengan catatan bahwa verifikasi webhook dan kunci idempotensi tetap ditulis tangan.
Kenapa webhook harus diterima sebagai @RequestBody byte[] dan bukan sebagai objek?
Karena tanda tangan Kasera-Signature-V1 dihitung atas byte body yang dikirim. Parameter bertipe objek membuat Jackson membaca stream body sampai habis dan menyerahkan hasil parse-nya, dan menyusun ulang JSON dari objek itu menghasilkan byte yang berbeda: urutan kunci mengikuti record, spasi hilang, dan field yang tidak didefinisikan lenyap. HMAC yang dihitung ulang tidak akan pernah cocok. Dengan byte[], ByteArrayHttpMessageConverter menyerahkan byte apa adanya, dan parsing dikerjakan sesudah verifikasi dari array yang sama.
Kenapa webhook ditolak 403 padahal route-nya sudah benar?
Hampir selalu karena spring-boot-starter-security ada di classpath. Perlindungan CSRF aktif secara bawaan untuk setiap POST, dan permintaan dari server Kasera Pay tidak membawa token CSRF sehingga ditolak sebelum mencapai controller. Kecualikan route webhook saja dari CSRF dan beri permitAll, jangan matikan CSRF untuk seluruh aplikasi. Setiap balasan selain 2xx dihitung gagal dan diulang sampai 7 kali dalam kisaran 33 jam, jadi konfigurasi yang salah ini terlihat sebagai deretan kegagalan di log pengiriman, bukan sebagai galat di aplikasi.
Apakah aman membungkus pembuatan tagihan dengan @Retryable?
Aman selama Idempotency-Key dibuat di luar method yang diulang dan disimpan bersama pesanannya. Setiap pengulangan lalu membawa key yang sama, dan permintaan yang ternyata sudah masuk pada percobaan sebelumnya dikembalikan, bukan dibuat dua kali. Batasi pengulangannya pada KaseraPayException yang retryable(), yaitu 429 dan 5xx, karena 422 dan 409 akan ditolak lagi dengan body yang sama.
Kenapa @Transactional pada handler webhook kadang tidak berjalan?
Karena transaksi Spring dipasang lewat proxy, dan panggilan dari method lain di kelas yang sama tidak melewati proxy itu. Kalau controller memanggil method privat di dalam dirinya sendiri yang diberi @Transactional, anotasinya diabaikan tanpa peringatan. Akibatnya pada webhook adalah catatan event yang sudah tersimpan sementara perubahan pesanan gagal, lalu pengiriman ulang dianggap duplikat dan pesanan tidak pernah terpenuhi. Letakkan method bertransaksi di bean terpisah dan panggil dari controller.