Integrasi Pembayaran Stripe & Webhook

13 min readIntermediate
StripePaymentWebhookBackend

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:

SkenarioYang terjadiDampak jika tidak ditangani
Browser ditutup saat proses bayarfetch ke Stripe terputusPelanggan ditarik tapi order tetap PENDING
Timeout di sisi jaringanKlien tidak pernah melihat responsOrder tampak gagal padahal kartu terpotong
Request hilang di tengah jalanStripe tidak menerima konfirmasiPembayaran gagal padahal pelanggan sudah memasukkan kartu
Duplikasi retry oleh klienKonfirmasi dikirim dua kaliBisa 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:

AktorContoh teknologiPeran
FrontendSPA, React, Next.jsMengumpulkan data kartu lewat Stripe Elements, menampilkan status ke pelanggan
Backend AndaSpring Boot, JavaMembuat order, membuat PaymentIntent, menerima webhook, menandai order PAID
StripePlatform pembayaranMemproses kartu, menerbitkan client_secret, mengirim event webhook

Alur Stripe checkout & 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:

  • setAmount mengambil dari database, bukan dari request klien. Percaya jumlah dari klien adalah celah: seseorang bisa mengubah angka sebelum diproses.
  • Mata uang idr tidak punya desimal, jadi 150000 artinya Rp150.000. Untuk mata uang seperti usd, jumlah dalam cents (15000 = $150,00). Selalu pakai satuan terkecil.
  • clientSecret adalah kredensial satu kali pakai yang aman dikirim ke frontend — ini bukan kunci rahasia Anda. Dengan client_secret, frontend bisa menyelesaikan pembayaran tanpa pernah melihat sk_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:

  1. Klien bisa mati sebelum membaca respons — pembayaran sukses tapi order tetap PENDING.
  2. Respons tidak bisa dipercaya sepenuhnya — apa pun bisa terjadi antara Stripe dan browser.
  3. 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.

JalurAuthDilindungi oleh
/checkout/payment-intentBearer token userLogin session Anda
/webhooks/stripeTanpa bearerVerifikasi 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:

BagianIsi
tTimestamp saat payload ditandatangani
v1Versi skema tanda tangan
SignatureHMAC-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 String harus 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.constructEvent melakukan semua pekerjaan: memeriksa timestamp (tidak terlalu tua), menghitung HMAC, dan mencocokkannya. Kalau gagal, ia melempar SignatureVerificationException. 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 200 dalam 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.

TindakanSebaiknya dilakukan di
Update status orderLangsung di handler (cepat, idempotent)
Kirim email konfirmasiWorker / queue
Generate invoice PDFWorker / queue
Trigger pengiriman barangWorker / 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

KesalahanKenapa berbahayaSolusi
Hanya mengandalkan konfirmasi sinkron dari frontendBrowser bisa mati; order PENDING selamanyaPakai webhook sebagai sumber kebenaran
Verifikasi tanda tangan salah/terlewatAttacker bisa memalsukan event succeededSelalu Webhook.constructEvent terhadap raw body
Mengirim seluruh body (bukan raw) ke verifikasiJSON yang di-parse ulang tidak cocok dengan HMACTerima String mentah, verifikasi sebelum deserialize
Tidak menangani retry / duplikat eventAksi dijalankan berkali-kaliSimpan eventId, operasi set bukan increment
Menerima amount dari klienPelanggan bisa mengubah nominalAmbil dari snapshot harga di database Anda
Webhook secret di frontendRahasia bocor; siapa pun bisa verifikasiSimpan di server, gunakan client_secret untuk frontend
Aksi berat sinkron di handlerStripe timeout dan retry terus-menerusBalas 200 cepat, kerjakan berat di queue
Tidak membedakan test/live modeUang asli bocor saat developmentPisahkan 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

← Back to technical articles