springkotlinstripe-payments

Best way to handle webhook ordering for susbcriptions events in Stripe


I'm listening for Stripe events concerning subscription created and updated. These events sometimes don't come in order, and as such, my database entry gets overwritten by the last message sent. For example, when a user subscribes to a membership in my website, two subscriptions events will be sent to my webhook from stripe, one has a status Incomplete and the other has a status Active. If the Incomplete event comes in last, my database entry will be all wrong. In consequence, his subscription will never be Active and he won't be able to do what he paid for in the website.

Now to remedy this, I decided to get the last subscription (latestSubscription) object from stripe and check the status from there and then update the status in my database entry

fun processEvent(event: Event, previousAttributes: JsonNode) {
    val stripeEventObject: StripeObject =
        event.dataObjectDeserializer.deserializeUnsafe() ?: return
    logger.info("[*] Processing Stripe event: ${event.id} -> ${event.type}")
    when (event.type) {
        "customer.subscription.created", "customer.subscription.updated" -> {
            val userSubscription = retrieveUserSubscription(stripeEventObject)
            userSubscription?.let {
                (stripeEventObject as? Subscription)?.let {
                    handleSubscriptionCreatedOrUpdated(it, userSubscription)
                }
            }
        }

        else -> {
            logger.info("Unhandled event type: {}", event.type)
        }
    }
}

Here's the method that I updated with latestSubscription

private fun handleSubscriptionCreatedOrUpdated(subscription: Subscription, userSubscription: UserSubscription) {
        // Fetch the latest subscription from Stripe API to ensure accuracy
        // This is important because webhook events may arrive out of order
        val latestSubscription = try {
            Subscription.retrieve(subscription.id)
        } catch (e: StripeException) {
            logger.error("Failed to retrieve latest subscription from Stripe", e)
            SentryUtils.addBreadcrumb("Failed to fetch latest subscription", "stripe")
            SentryUtils.setTag("subscription_id", subscription.id)
            SentryUtils.captureException(e)
            // Fall back to the subscription from the webhook event
            subscription
        }
        
        try {
            val paymentMethodId = latestSubscription.defaultPaymentMethod
            val paymentMethod = PaymentMethod.retrieve(paymentMethodId)

            // Update the customer's default payment method
            val customerParams: MutableMap<String, Any> = HashMap()
            customerParams["invoice_settings"] = mapOf("default_payment_method" to paymentMethod.id)
            Customer.retrieve(userSubscription.stripeCustomerId).update(customerParams)

            userSubscription.stripePaymentMethodId = paymentMethod.id
            userSubscription.cardLastFourDigits = paymentMethod.card?.last4
            userSubscription.cardType = paymentMethod.card?.brand
        } catch (e: StripeException) {
            logger.error("Setting default payment method failed", e)
            SentryUtils.addBreadcrumb("Stripe payment method update failed", "stripe")
            SentryUtils.setTag("stripe_operation", "update_payment_method")
            SentryUtils.setTag("subscription_id", latestSubscription.id)
            SentryUtils.setTag("customer_id", userSubscription.stripeCustomerId)
            SentryUtils.captureException(e)
        }
        if (userSubscription.stripeSubscriptionId == null ||
            latestSubscription.id != userSubscription.stripeSubscriptionId
        ) {
            userSubscription.startDate = latestSubscription.startDate
        }
        userSubscription.stripeSubscriptionId = latestSubscription.id

        val subscriptionProductId = latestSubscription.items.data.firstOrNull()?.price?.product
        userSubscription.stripeSubscriptionProductId = subscriptionProductId

        if (subscriptionProductId == stripeEnterpriseSubscriptionProductId) {
            val currentSeatCount = latestSubscription.items.data.firstOrNull()?.quantity ?: 0L
            // val currentTier = stripeService.getEnterpriseTierForSeats(currentSeatCount)
            // val highestTierSeatLimit = stripeService.getHighestEnterpriseTierSeatLimit() ?: Long.MAX_VALUE
            userSubscription.maxEnterpriseQuantity = currentSeatCount
        }

        when {
            latestSubscription.cancelAtPeriodEnd -> {
                userSubscription.status = SubscriptionStatus.valueOf(latestSubscription.status.uppercase())
                userSubscription.endDate = latestSubscription.cancelAt
            }

            latestSubscription.pauseCollection != null -> {
                userSubscription.status = SubscriptionStatus.PAUSED
                userSubscription.endDate = Instant.now().epochSecond
            }

            else -> {
                userSubscription.status = SubscriptionStatus.valueOf(latestSubscription.status.uppercase())
                userSubscription.endDate = null
            }
        }
        // Update user role, userSubscription has a user object we can retrieve and provision a new role
        if (userSubscription.stripeSubscriptionProductId == stripeBasicSubscriptionProductId) {
            userService.updateUserRole(userSubscription.user, Role.USER)
            // Re-enroll to courses (that weren't purchased) once his subscription is active again
            enrollmentService.reactivateSubscriptionEnrollments(userSubscription.user)
        } else if (userSubscription.stripeSubscriptionProductId == stripeEnterpriseSubscriptionProductId) {
            userService.updateUserRole(userSubscription.user, Role.ENTERPRISE)
            // Create an enterprise (if it doesn't already exist for the owner to manage)
            val owner = userSubscription.user
            if (!enterpriseService.isEnterpriseOwner(owner)) {
                enterpriseService.createEnterprise(owner)
            } else {
                // Enterprise already exists, mark it as active, and re provision the enterprise members
                val enterprise = enterpriseService.getEnterpriseByOwner(owner)
                enterpriseService.updateEnterprise(
                    enterprise.copy(
                        status = EnterpriseStatus.ACTIVE,
                    )
                )
                enterpriseService.provisionEnterpriseMembers(owner, userSubscription.maxEnterpriseQuantity ?: 0)
            }
        }
        userSubscriptionService.saveUserSubscription(userSubscription)
    }

After running a few tests, this seems to work as expected. Now for my questions, is this the implementation solid or am I missing important use cases? I want to get advice from the pros before this goes to prod


Solution

  • Working with stripe webhok can always be a pain. And while your approach works, there's a more cleaner way to do this.

    The key point as you've mentioned is to not rely on the order + payloads of Stripe webhook events for subscriptions, as they can arrive out of order or be delayed.

    Instead, on each relevant webhook event (or after a successful checkout redirect), call a sync function that fetches the latest subscription state directly from the stripe API and updates your internal store like syncStripeDataToKV.

    This approach ensures your state is always accurate, avoids race conditions, and handles event ordering issues nicely. You can check out the recommended implementation here by t3dotgg (he was invited by the Stripe team for this also): https://github.com/t3dotgg/stripe-recommendations.