Integrasi Pembayaran Stripe & Webhook
Menerima pembayaran bukan seperti menerima POST biasa. Kalau menambah data ke database, request yang gagal bisa diulang tanpa banyak kerugian. Tapi di sini ada uang yang berpindah: ketika pelanggan menekan tombol "Bayar", Anda harus memastikan order mereka tercatat, kartu terpotong dengan benar, dan statusnya cocok di semua sisi — tanpa pernah menagih dua kali. Artikel ini mengupas cara membangun alur checkout dengan Stripe PaymentIntent dan verifikasi webhook, dua mekanisme yang membuat konfirmasi pembayaran bisa diandalkan meski request HTTP terputus di tengah jalan.
Kenapa Integrasi Pembayaran Itu Rumit
Berbeda dengan endpoint CRUD biasa, pembayaran melibatkan pihak ketiga (Stripe) dan jaringan perbankan. Artinya, ada beberapa kemungkinan yang membuat status jadi tidak sinkron:
| Skenario | Yang terjadi | Dampak jika tidak ditangani |
|---|---|---|
| Browser ditutup saat proses bayar | fetch ke Stripe terputus | Pelanggan ditarik tapi order tetap PENDING |
| Timeout di sisi jaringan | Klien tidak pernah melihat respons | Order tampak gagal padahal kartu terpotong |
| Request hilang di tengah jalan | Stripe tidak menerima konfirmasi | Pembayaran gagal padahal pelanggan sudah memasukkan kartu |
| Duplikasi retry oleh klien | Konfirmasi dikirim dua kali | Bisa terjadi penagihan ganda |
Kuncinya satu: status order dan status pembayaran harus selalu sinkron. Kalau kartu terpotong, order harus PAID. Kalau kartu ditolak, order harus tetap PENDING (atau dibatalkan). Keduanya berubah lewat jalur yang berbeda, jadi Anda butuh desain yang jelas tentang siapa yang authoritative atas tiap status.
Pembayaran adalah masalah durability: konfirmasi tidak boleh hilang hanya karena browser ditutup atau timeout. Desain yang benar harus punya jalur konfirmasi asinkron sebagai jaring pengaman.
Aktor dalam Integrasi Pembayaran
Ada tiga pihak yang terlibat, dan masing-masing punya tanggung jawab yang berbeda:
| Aktor | Contoh teknologi | Peran |
|---|---|---|
| Frontend | SPA, React, Next.js | Mengumpulkan data kartu lewat Stripe Elements, menampilkan status ke pelanggan |
| Backend Anda | Spring Boot, Java | Membuat order, membuat PaymentIntent, menerima webhook, menandai order PAID |
| Stripe | Platform pembayaran | Memproses kartu, menerbitkan client_secret, mengirim event webhook |
Diagram di atas menunjukkan dua jalur komunikasi. Jalur sinkron (garis penuh) dipakai frontend untuk mendapatkan client_secret dan meminta Stripe memproses kartu. Jalur webhook (garis putus-putus) adalah jalur asinkron dari Stripe ke backend Anda — di sinilah konfirmasi "resmi" datang.
Alur Checkout dengan PaymentIntent
PaymentIntent adalah objek Stripe yang merepresentasikan "niat membayar" dari pelanggan. Ia menyimpan jumlah, mata uang, status, dan cara pembayaran yang digunakan. Alurnya berjalan dalam empat langkah.
Langkah 1: Backend membuat order
Semua berangkat dari order yang dibuat di database Anda sendiri. Order ini membawa snapshot harga — artinya harga yang tersimpan adalah harga saat checkout, bukan harga terkini dari katalog. Ini penting karena harga produk bisa berubah; pelanggan harus dibayar sesuai yang mereka lihat, bukan yang muncul belakangan.
Order juga punya tracking number unik, misalnya ORD-2026-0001. Ini akan menjadi jembatan antara order Anda dan pembayaran di Stripe.
// Skema sederhana: order + snapshot harga
public class Order {
private String trackingNumber; // ORD-2026-0001
private Long amountCents; // snapshot harga saat checkout
private String currency; // idr
private String status; // PENDING | PAID | FAILED
private String customerEmail;
}
Langkah 2: Backend meminta PaymentIntent ke Stripe
Frontend tidak pernah boleh berkomunikasi langsung dengan Stripe dengan kunci Anda. Yang terjadi adalah frontend memanggil backend Anda, lalu backend yang berbicara ke Stripe.
@PostMapping("/checkout/payment-intent")
public PaymentIntentResponse createPaymentIntent(@RequestBody CheckoutRequest req) {
// 1. Ambil atau buat order berdasarkan trackingNumber
Order order = orderService.createOrder(req);
// 2. Minta PaymentIntent ke Stripe
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(order.getAmountCents()) // snapshot harga dari DB, BUKAN dari klien
.setCurrency(order.getCurrency())
.setReceiptEmail(order.getCustomerEmail())
.putMetadata("orderTrackingNumber", order.getTrackingNumber())
.build();
PaymentIntent paymentIntent = PaymentIntent.create(params);
// 3. client_secret dipakai frontend untuk "mengunci" pembayaran
return new PaymentIntentResponse(paymentIntent.getClientSecret());
}
Beberapa hal penting dari kode di atas:
setAmountmengambil dari database, bukan dari request klien. Percaya jumlah dari klien adalah celah: seseorang bisa mengubah angka sebelum diproses.- Mata uang
idrtidak punya desimal, jadi150000artinya Rp150.000. Untuk mata uang sepertiusd, jumlah dalam cents (15000= $150,00). Selalu pakai satuan terkecil. clientSecretadalah kredensial satu kali pakai yang aman dikirim ke frontend — ini bukan kunci rahasia Anda. Denganclient_secret, frontend bisa menyelesaikan pembayaran tanpa pernah melihatsk_test_...Anda.
Langkah 3: Frontend merender Stripe Elements
Frontend memakai client_secret untuk menginisialisasi Stripe Elements — widget input kartu yang sudah tersertifikasi PCI. Data kartu tidak pernah menyentuh server Anda sama sekali; kartu dikirim langsung oleh browser ke Stripe.
const stripe = Stripe('pk_test_...'); // publishable key, aman di frontend
const elements = stripe.elements();
const card = elements.create('card');
card.mount('#card-element');
Sekaligus backend menyimpan paymentIntentId di tabel order, sehingga nanti Anda bisa mencocokkan event webhook dengan order yang tepat.
Langkah 4: Konfirmasi pembayaran
Saat pelanggan menekan tombol bayar, frontend memanggil stripe.confirmCardPayment(clientSecret, { payment_method }). Di sinilah kartu benar-benar diproses. Kalau berhasil, status PaymentIntent di Stripe berubah menjadi succeeded dan frontend bisa menampilkan halaman sukses.
const { paymentIntent, error } = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: cardElement },
});
if (paymentIntent.status === 'succeeded') {
// tampilkan halaman sukses ke pelanggan
}
Tapi hati-hati: status sukses di frontend bukan bukti final. Browser bisa saja ditutup tepat setelah ini, dan Anda belum menandai order sebagai PAID.
Metadata: "Lem" yang Mempertemukan Order & Pembayaran
Di dunia Anda ada order; di dunia Stripe ada PaymentIntent. Keduanya tidak tahu apa-apa tentang satu sama lain tanpa jembatan. Jembatan itu adalah metadata.
Metadata adalah pasangan key-value yang melekat pada PaymentIntent dan ikut terkirim ulang di setiap event webhook. Dengan menyimpan orderTrackingNumber di metadata, Anda selalu bisa menjawab pertanyaan: "PaymentIntent ini untuk order siapa?"
.putMetadata("orderTrackingNumber", order.getTrackingNumber())
Nanti, ketika event webhook tiba, Anda tinggal membaca metadata ini untuk menemukan order yang bersangkutan — bukan menebak dari email atau jumlah pembayaran.
Webhook: Konfirmasi Asinkron yang Bisa Diandalkan
Kenapa Webhook Diperlukan
Jalur sinkron dari Langkah 4 tidak bisa dijadikan satu-satunya sumber kebenaran. Alasannya:
- Klien bisa mati sebelum membaca respons — pembayaran sukses tapi order tetap
PENDING. - Respons tidak bisa dipercaya sepenuhnya — apa pun bisa terjadi antara Stripe dan browser.
- Event tertentu tidak punya jalur sinkron sama sekali — misalnya refund atau chargeback bulan berikutnya.
Karena itu Stripe mengirim event secara asinkron ke endpoint di backend Anda setiap kali ada hal penting: payment_intent.succeeded, payment_intent.payment_failed, charge.refunded, dan lain-lain. Event inilah yang menjadi sumber kebenaran untuk menandai order.
Jangan anggap konfirmasi dari frontend sebagai kebenaran. Perlakukan webhook sebagai otoritas final yang memutuskan status pembayaran.
Endpoint Webhook Harus Publik
Endpoint webhook dipanggil oleh server Stripe, bukan oleh browser Anda. Karena itu endpoint ini tidak boleh memakai Authorization: Bearer biasa — Stripe tidak tahu token milik pengguna Anda. Ia harus bisa diakses publik (tanpa login), tetapi keamanannya dijaga lewat verifikasi tanda tangan.
| Jalur | Auth | Dilindungi oleh |
|---|---|---|
/checkout/payment-intent | Bearer token user | Login session Anda |
/webhooks/stripe | Tanpa bearer | Verifikasi tanda tangan HMAC |
Jika ada endpoint lain yang mengecek login, pastikan webhook dikecualikan dari filter tersebut.
Verifikasi Tanda Tangan HMAC
Karena endpoint publik bisa dipanggil siapa saja, Anda harus memastikan event benar-benar berasal dari Stripe. Stripe menandatangani setiap payload dengan tanda tangan HMAC memakai webhook secret (whsec_...) milik Anda.
Setiap request membawa header Stripe-Signature yang berisi tiga bagian:
| Bagian | Isi |
|---|---|
t | Timestamp saat payload ditandatangani |
v1 | Versi skema tanda tangan |
| Signature | HMAC-SHA256 dari timestamp.payload, dienkode base64 |
Berikut cara memverifikasinya dengan stripe-java:
@PostMapping("/webhooks/stripe")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload, // RAW body, bukan JSON yang sudah diparsing!
@RequestHeader("Stripe-Signature") String sigHeader) {
String webhookSecret = env.getProperty("stripe.webhook-secret");
try {
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
switch (event.getType()) {
case "payment_intent.succeeded" -> handlePaymentSuccess(event);
case "payment_intent.payment_failed" -> handlePaymentFailure(event);
default -> log.info("Event yang diabaikan: {}", event.getType());
}
// Balas 200 agar Stripe tahu event sudah diproses
return ResponseEntity.ok("received");
} catch (SignatureVerificationException e) {
// Tanda tangan tidak cocok -> bukan dari Stripe -> tolak
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("invalid signature");
}
}
Perhatikan dua hal yang sering jadi jebakan:
@RequestBody Stringharus menerima raw body. Kalau Anda menerima sebagai objek yang sudah di-deserialize lalu mengirim JSON ulang, urutan field bisa berubah dan tanda tangan tidak akan cocok. Verifikasi harus terhadap payload mentah persis seperti yang dikirim Stripe.Webhook.constructEventmelakukan semua pekerjaan: memeriksa timestamp (tidak terlalu tua), menghitung HMAC, dan mencocokkannya. Kalau gagal, ia melemparSignatureVerificationException. Event yang lolos verifikasi baru boleh diproses.
Dengan verifikasi ini, Anda menolak event palsu yang dipanggilkan attacker untuk menandai order PAID tanpa membayar.
Webhook Sebagai Fallback
Kembali ke diagram di atas: jalur webhook adalah jaring pengaman jalur sinkron. Jika browser mati tepat setelah kartu terpotong, tidak ada yang memberitahu frontend — tapi payment_intent.succeeded tetap mendarat di backend Anda beberapa detik kemudian. Karena itu order yang sempat PENDING akan tertutup menjadi PAID, dan pelanggan tetap mendapat barangnya meski layarnya gelap.
Idempotency
Apa Itu Idempotency
Idempotency berarti: memproses event yang sama berkali-kali memberi hasil akhir yang sama. Stripe menjamin pengiriman ("at-least-once"), bukan pengiriman sekali saja. Artinya event yang sama bisa tiba dua, tiga, bahkan lebih kali jika:
- Stripe tidak menerima respons
200dalam batas waktu, lalu melakukan retry. - Koneksi Anda putus tepat setelah memproses event tapi sebelum balas
200. - Stripe mengirim ulang event lama saat endpoint Anda aktif kembali setelah downtime.
Jika handler Anda tidak dirancang idempotent, event ganda bisa berarti order ditandai dua kali (mungkin tidak fatal), tetapi bayangkan event payment_intent.succeeded memicu aksi non-idempotent seperti menambahkan saldo atau mengirim barang — sekali lagi, sekali lagi, sekali lagi.
Operasi set, Bukan increment
Kunci sederhana: tandai order PAID dengan operasi set, bukan increment.
// BENAR — idempotent: memanggil 100 kali, hasilnya tetap PAID
order.setStatus("PAID");
order.setPaidAt(now);
// SALAH — non-idempotent: setiap panggilan menambah saldo lagi
user.setBalance(user.getBalance() + amount);
setStatus("PAID") yang dipanggil dua kali tetap menghasilkan PAID. Tapi balance += amount yang dipanggil dua kali akan menambah saldo ganda. Operasi bisnis yang menambah/mengurangi angka (top-up, transfer) harus diproteksi dengan cara lain — misalnya mencatat event yang sudah diproses.
Menyimpan Event ID yang Sudah Diproses
Cara paling umum adalah menyimpan event.getId() yang sudah berhasil diproses, lalu menolak duplikatnya. Pastikan penyimpanan marker dan perubahan status terjadi dalam satu transaksi database agar tidak ada celah di antaranya.
private void handlePaymentSuccess(Event event) {
String eventId = event.getId(); // evt_1M...
String orderTrackingNumber = event.getDataObjectAs(
PaymentIntent.class).getMetadata().get("orderTrackingNumber");
// Cek duplikat dulu
if (processedEventRepository.existsById(eventId)) {
log.info("Event {} sudah diproses sebelumnya. Diabaikan.", eventId);
return;
}
// Update status + catat eventId dalam SATU transaksi
transactionTemplate.execute(status -> {
orderRepository.findByTrackingNumber(orderTrackingNumber)
.ifPresent(order -> {
order.setStatus("PAID");
orderRepository.save(order);
});
processedEventRepository.save(new ProcessedEvent(eventId));
return null;
});
}
Dengan pola ini, meskipun event yang sama tiba dua kali secara paralel, hanya satu yang mengubah status — yang kedua menemukan eventId sudah ada dan langsung dilewati.
Keamanan & Best Practice
Webhook Secret Tidak Boleh ke Frontend
whsec_... dan sk_test_... adalah kredensial rahasia. Simpan di environment variable server, jangan pernah di bundle JavaScript atau di application.properties yang ikut di-commit ke git. Frontend cukup diberi client_secret (sekali pakai) dan pk_test_... (publishable key) yang memang didesain aman di publik.
Jangan Lakukan Aksi Berat di Handler dengan Blokir
Handler webhook harus cepat: Stripe menunggu respons dan akan retry jika Anda lambat. Jangan kirim email, generate PDF, atau panggil API eksternal lambat secara sinkron di dalam handler. Pilihan yang benar: enqueue ke message queue atau schedule job, lalu balas 200 dulu. Nanti worker yang melakukan pekerjaan berat.
| Tindakan | Sebaiknya dilakukan di |
|---|---|
| Update status order | Langsung di handler (cepat, idempotent) |
| Kirim email konfirmasi | Worker / queue |
| Generate invoice PDF | Worker / queue |
| Trigger pengiriman barang | Worker / queue |
Test Mode vs Live Mode
Stripe punya dua mode yang terpisah total: sk_test_... untuk pengembangan dan sk_live_... untuk produksi. Di test mode, gunakan kartu uji seperti 4242 4242 4242 4242 untuk sukses, dan kartu khusus untuk men-simulasi penolakan.
Untuk menguji webhook secara lokal, gunakan CLI Stripe:
stripe listen --forward-to localhost:8080/webhooks/stripe
Perintah ini menampilkan whsec_... untuk environment lokal, lalu meneruskan semua event webhook dari dashboard ke server Anda. Alternatif lain: pasang tunnel (ngrok) dan daftarkan URL publiknya ke dashboard Stripe.
Log Semua Event & Pantau Order yang Tidak Pernah PAID
Setiap event — valid atau invalid — layak dicatat. Log ini menjadi jejak audit saat Anda harus menyelidiki kenapa order pelanggan tidak pernah PAID. Lebih jauh, jalankan sweeper job yang berkala mencari order PENDING yang umurnya melewati batas, lalu bandingkan dengan status pembayarannya di Stripe. Inilah cara menutup lubang yang tidak bisa ditutup webhook sendirian.
Trade-off & Kesalahan Umum
| Kesalahan | Kenapa berbahaya | Solusi |
|---|---|---|
| Hanya mengandalkan konfirmasi sinkron dari frontend | Browser bisa mati; order PENDING selamanya | Pakai webhook sebagai sumber kebenaran |
| Verifikasi tanda tangan salah/terlewat | Attacker bisa memalsukan event succeeded | Selalu Webhook.constructEvent terhadap raw body |
| Mengirim seluruh body (bukan raw) ke verifikasi | JSON yang di-parse ulang tidak cocok dengan HMAC | Terima String mentah, verifikasi sebelum deserialize |
| Tidak menangani retry / duplikat event | Aksi dijalankan berkali-kali | Simpan eventId, operasi set bukan increment |
Menerima amount dari klien | Pelanggan bisa mengubah nominal | Ambil dari snapshot harga di database Anda |
| Webhook secret di frontend | Rahasia bocor; siapa pun bisa verifikasi | Simpan di server, gunakan client_secret untuk frontend |
| Aksi berat sinkron di handler | Stripe timeout dan retry terus-menerus | Balas 200 cepat, kerjakan berat di queue |
| Tidak membedakan test/live mode | Uang asli bocor saat development | Pisahkan kunci, pakai kartu 4242 |
Trade-off yang perlu disadari: desain yang benar (webhook + idempotency + monitoring) memang lebih banyak pekerjaan daripada sekadar menunggu respons confirmCardPayment. Tetapi di sistem yang memindahkan uang, keandalan dan keamanan bukan fitur — keduanya adalah syarat minimum.
Ringkasan
Integrasi pembayaran menuntut Anda berpikir tentang sumber kebenaran, bukan sekadar alur UI. Alur sinkron (frontend ↔ Stripe) memberi pengalaman cepat, tapi konfirmasinya bisa hilang; alur asinkron (Stripe → webhook Anda) adalah jaring pengaman yang tidak boleh Anda abaikan. Tiga prinsip yang harus selalu dijaga: verifikasi setiap event dengan tanda tangan HMAC sebelum memercayainya, buat handler idempotent agar retry tidak menggandakan efek samping, dan ambil semua nilai finansial dari backend, bukan dari klien. Lengkapi dengan logging dan monitoring, dan Anda punya sistem pembayaran yang aman dari kebetulan — aman juga dari penipuan.
Lanjut membaca
- Stripe: Menerima pembayaran — panduan lengkap alur checkout dari Stripe.
- Stripe: PaymentIntents — detail objek yang memproses pembayaran.
- Stripe: Webhooks — event, retry, dan pengiriman asinkron.
- Stripe: Webhook signatures — verifikasi tanda tangan HMAC secara detail.
- Transaksi Database — kenapa update status + simpan
eventIdharus dalam satu transaksi. - WebSocket & Real-time — alternatif mengirim status pembayaran ke frontend secara langsung.