<- Read more

Stripe One-Time Payments with Spring Boot and Kotlin

A banner containing the Stripe logo, Spring Boot logo, and Kotlin logo

Stripe One-Time Payments with Spring Boot and Kotlin

By this time, probably everybody and their grandma has already heard of Stripe or even tried to use it in their own project. But if, by some miracle, you have not, or you just need a quick tutorial on implementing Stripe one-time payments with Spring Boot and Kotlin, then you have come to the right place.

In this article, I will cover my implementation of the Stripe API for one-time payments. I will use code examples from my AI Santa greeting app, which I built some time ago. If you are interested in the project itself, you can read more about it here.

Without further ado, let's get started!

Setting up Stripe configuration

First, update your configuration files with the values from Stripe. In this case, I will add the config to application.yaml.

stripe:
  secret: ${STRIPE_SECRET}
  webhook-secret: ${STRIPE_WEBHOOK_SECRET}
  greeting-375-price-id: ${STRIPE_GREETING_375_PRICE_ID}
  greeting-750-price-id: ${STRIPE_GREETING_750_PRICE_ID}
  greeting-1125-price-id: ${STRIPE_GREETING_1125_PRICE_ID}
  greeting-1500-price-id: ${STRIPE_GREETING_1500_PRICE_ID}
  success-url: ${STRIPE_SUCCESS_URL}
  cancel-url: ${STRIPE_CANCEL_URL}

Never put secrets directly into your application.yaml file. Always use environment variables.

Next, create a @ConfigurationProperties class to retrieve the config values cleanly.

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("stripe")
data class StripeProperties(
    val secret: String,
    val webhookSecret: String,
    val greeting375PriceId: String,
    val greeting750PriceId: String,
    val greeting1125PriceId: String,
    val greeting1500PriceId: String,
    val successUrl: String,
    val cancelUrl: String,
)

In my case, I have several different Stripe price IDs because the price of the AI Santa greeting depends on the length of the message. If your product has only one price, you only need one price ID.

Configuring the Stripe client

To send requests to the Stripe API, we need to create a Stripe client bean that can be injected into our services. In my project, I have a separate @Configuration class called BeanConfig, where I register all of my beans.

import com.stripe.StripeClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.stitas.dovkal.properties.StripeProperties

@Configuration
class BeanConfig {

    @Bean
    fun stripeClient(stripeProperties: StripeProperties): StripeClient {
        return StripeClient(stripeProperties.secret)
    }
}

This keeps the Stripe setup in one place and makes the client easy to inject wherever it is needed.

Checkout session DTO

This implementation is pretty simple, so the only DTO we need is used to return the created Stripe Checkout session data.

data class CheckoutSession(
    val stripeCheckoutId: String,
    val stripeCheckoutUrl: String,
)

The stripeCheckoutId is important because we will later use it to match Stripe webhook events with the correct order in our database.

The stripeCheckoutUrl is the URL where we redirect the user to complete the payment.

Implementing the Stripe API client

To communicate with Stripe, we use the methods of the Stripe client bean we created earlier.

For this, I created a StripeApiClient class.

import com.stripe.StripeClient
import com.stripe.exception.StripeException
import com.stripe.param.checkout.SessionCreateParams
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.stitas.dovkal.exceptions.StripeIntegrationException
import org.stitas.dovkal.integrations.stripe.dto.CheckoutSession

@Service
class StripeApiClient(
    private val stripeClient: StripeClient,
) {

    fun createCheckoutSession(priceId: String, email: String, successUrl: String, cancelUrl: String): CheckoutSession {
        val params = SessionCreateParams.builder()
            .setMode(SessionCreateParams.Mode.PAYMENT)
            .setCustomerEmail(email)
            .setSuccessUrl(successUrl)
            .setCancelUrl(cancelUrl)
            .addLineItem(
                SessionCreateParams.LineItem.builder()
                    .setPrice(priceId)
                    .setQuantity(1L)
                    .build()
            )
            .build()

        val session = try {
            stripeClient.v1().checkout().sessions().create(params)
        } catch (e: StripeException) {
            log.error("Failed to create stripe checkout session for email={}", email, e)
            throw StripeIntegrationException("Failed to create stripe checkout session", e)
        }

        return CheckoutSession(session.id, session.url)
    }

    companion object {
        private val log = LoggerFactory.getLogger(StripeApiClient::class.java)
    }
}

Here, we build the request using the SessionCreateParams builder and create a Stripe Checkout session. Stripe gives us the session.id and session.url in return.

Important: you need to save the session.id in your database. For example, I save it in my Order entity. Later, when a Stripe webhook comes in, we need the session.id to map the incoming event to the correct order.

The session.url is the payment link that we send to the UI. The frontend then redirects the user to that URL so they can pay for the product.

I also use a custom StripeIntegrationException that I built for my app, but I will not go deep into that here. For simplicity, you can use something like IllegalStateException or handle the error in a different way.

Later, I use this method in a StripeService class, following the Controller -> Service -> Repository pattern. In this case, the API client replaces the repository part. I only add additional business logic there, so I will not share that part here.

Creating a Stripe webhook endpoint

Creating the checkout session is only half of the implementation. After the user pays, Stripe needs to notify our application that the payment was completed.

This is done with Stripe webhooks.

Creating the endpoint

Stripe sends a POST request with a raw string request body and a signature header called Stripe-Signature. We use that signature to verify that the request really came from Stripe.

@PostMapping("/hook")
fun stripeWebhook(
    @RequestBody payload: String,
    @RequestHeader("Stripe-Signature") stripeSignature: String,
): ResponseEntity<Void> {
    stripeService.handleWebhook(payload, stripeSignature)
    return ResponseEntity.ok().build()
}

The important part here is that we receive the payload as a raw String. This is needed because Stripe webhook signature verification depends on the exact raw request body.

Handling Stripe webhook events

After receiving the event, we need to handle it. For this, I created the following method.

fun handleWebhook(payload: String, stripeSignature: String) {
    val event = constructEvent(payload, stripeSignature)

    when (event.type) {
        "checkout.session.completed" -> {
            val session = extractSessionFromEvent(event)
            handleCheckoutSessionCompleted(session)
        }
        "checkout.session.expired" -> {
            val session = extractSessionFromEvent(event)
            handleCheckoutSessionExpired(session)
        }
        else -> return // Do nothing for unhandled webhooks
    }
}

In my case, I only care about two events:

  1. checkout.session.completed
  2. checkout.session.expired

For other unhandled events, I simply return. This way, the controller returns status 200, and Stripe treats the event as successfully received.

If your controller returns a bad status code, Stripe will try to resend the event.

Constructing and verifying the Stripe event

Before handling the webhook event, we need to verify its signature. This is where the STRIPE_WEBHOOK_SECRET from the configuration is used.

private fun constructEvent(payload: String, stripeSignature: String): Event {
    return try {
        Webhook.constructEvent(payload, stripeSignature, stripeProperties.webhookSecret)
    } catch (e: SignatureVerificationException) {
        log.error("Stripe signature verification failed", e)
        throw StripeIntegrationException("Stripe signature verification failed", e, HttpStatus.BAD_REQUEST)
    } catch (e: Exception) {
        log.error("Failure while getting event from Stripe webhook", e)
        throw StripeIntegrationException("Failure while getting event from Stripe webhook", e, HttpStatus.BAD_REQUEST)
    }
}

If the signature is invalid, we reject the request. This protects the endpoint from random requests pretending to be Stripe.

Extracting the checkout session from the event

I also created a helper method to extract the Stripe Checkout session from the received event.

private fun extractSessionFromEvent(event: Event): Session {
    val stripeObject = event.dataObjectDeserializer.`object`.orElseThrow {
        log.error("Could not deserialize Stripe event object. Check Stripe API version")
        StripeIntegrationException(message = "Could not deserialize Stripe event object", httpStatus = HttpStatus.BAD_REQUEST)
    }

    return stripeObject as? Session ?: run {
        log.error("Could not cast deserialized stripe object to Session")
        throw StripeIntegrationException(message = "Could not cast deserialized stripe object to Session", httpStatus = HttpStatus.BAD_REQUEST)
    }
}

Here, we deserialize the event using built-in Stripe methods and cast it to a Session object.

If the object cannot be deserialized, it may mean that your Stripe API version and the version expected by your application do not match.

Handling checkout.session.completed

This is the main event we want to handle. Stripe sends it when the user successfully completes the checkout.

After receiving this event, we can start processing the order, which is exactly what I do in my app.

fun handleCheckoutSessionCompleted(session: Session) {
    val order = orderService.findByStripeCheckoutId(session.id) ?: run {
        log.error("Failed to handle checkout session completed. Order with stripeCheckoutId={} not found", session.id)
        throw StripeIntegrationException(message = "Order not found by Stripe checkout id", httpStatus = HttpStatus.BAD_REQUEST)
    }

    heyGenService.sendVideoGenerationRequest(order)
    emailService.sendOrderReceivedEmail(order.email, order.id)
}

Here, I map the event to my Order entity in the database using the session ID we saved when creating the checkout session.

After that, the rest is business logic. In my case, I start generating the AI Santa video and send an order confirmation email.

Handling checkout.session.expired

The expired event tells us that the user started the checkout session but did not complete it before it became invalid.

I created this method to handle that case.

fun handleCheckoutSessionExpired(session: Session) {
    val order = orderService.findByStripeCheckoutId(session.id) ?: run {
        log.warn("Failed to handle checkout session expired. Order with stripeCheckoutId={} not found", session.id)
        throw StripeIntegrationException(message = "Order not found by Stripe checkout id", httpStatus = HttpStatus.BAD_REQUEST)
    }

    orderService.changeOrderStatus(order, OrderStatus.EXPIRED)
    orderService.save(order)
}

The process is similar to checkout.session.completed. We map the received event to our order in the database using the session ID, and then execute the needed business logic.

In this case, I change the order status to EXPIRED and save it.

Testing Stripe one-time payments

To test this implementation, you usually use Stripe test mode. It allows you to create checkout sessions and pay for your products using test cards, so you do not spend real money.

For local webhook testing, I recommend using the Stripe CLI. It lets you forward Stripe webhook events to your local application and test the full payment flow more easily.

For example, your local webhook endpoint might look like this:

http://localhost:8080/api/v1/payment/hook

With Stripe CLI, you can forward events to it and test the same webhook logic you will use in production.

Even after testing in test mode, I recommend making one real purchase in production just to verify that everything works end to end. The transaction can be refunded afterwards, so do not worry too much. You are not really losing your money.

Final notes

That's it, folks!

This is the basic flow I use for Stripe one-time payments with Spring Boot and Kotlin:

  1. Create a Stripe Checkout session.
  2. Save the session.id in the database.
  3. Redirect the user to session.url.
  4. Receive Stripe webhook events.
  5. Verify the webhook signature.
  6. Map the event back to your order.
  7. Process the order based on the event type.

If you found this article useful, consider subscribing below. I'll be sharing more practical Spring Boot, Kotlin, and API integration tutorials in the future.

Want to read more?

Get the next build note, mistake, and lesson straight to your inbox.