Handle Stripe Subscriptions in Golang

Handling Stripe subscriptions effectively is crucial when we want to implement a seamless payment experience for our users. Integrating Stripe’s powerful subscription management features can be a daunting task, but with the right approach and tools, it becomes manageable and efficient. This article provides a comprehensive guide for developers on how to handle Stripe subscriptions using Golang. We will explore the essential steps required to create a checkout session, allowing users to subscribe to our plan, and delve into managing critical Stripe webhook events such as checkout session completion, subscription deletions, and updates. By the end of this guide, we’ll have a solid understanding of how to leverage Stripe’s API with Golang to manage subscriptions effectively and enhance our application’s billing workflow.

Creating a checkout session

In order to create a simple checkout session utilizing Stripe hosted checkout, we need to create the checkout session based on a few key pieces of information:

We should always use an existing customer ID if one exists in order to prevent Stripe from creating another user for the same email address. Having multiple customer IDs for a single email address could lead to some unwanted consequences.

Here is a way to implement redirecting users to the Stripe hosted checkout page, assuming the request URL contains a price ID in its query parameters:

import (
    "net/http"

    "github.com/stripe/stripe-go/v78"
	"github.com/stripe/stripe-go/v78/checkout/session"
)

func CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
    u := getUser(r) // get the current user, or nil, from request context
    price := r.URL.Query().Get("price")
    quantity := 1
    mode := "subscription"

    // redirect users to an application page upon completing
    // the checkout session depending on whether the user is
    // already signed in or not, we need to provide
    // a different successURL
    successURL := "https://example.com/app"

    // redirect users back to a pricing page upon cancelling
    // the checkout session
    cancelURL := "https://example.com/pricing"

    lineItems := []*stripe.CheckoutSessionLineItemParams{
        {
            Price:    &price,
            Quantity: &quantity,
        }
    }

    // here we define the user's email address and customer ID
    // as a pointer to a string, we then determine if our user
    // exists (i.e. u != nil), if so, we can place the user's
    // customer ID as the Customer in the following stripe
    // CheckoutSessionParams, otherwise setting CustomerEmail
    // based on the user's email
    //
    // in case there is no signed in user, both values are left
    // as nil, and the // user must manually input their email
    // address within Stripe's checkout flow here we assume u
    // is a User struct that contains at least the fields
    // Email string, and CustomerID sql.NullString
    var email, customerID *string
    if u != nil {
        if u.CustomerID.Valid {
            customerID = &u.CustomerID.String
        } else {
            email = &u.Email
        }
    }

    checkoutSessionParams := &stripe.CheckoutSessionParams{
        CustomerEmail: email,
        Customer:      customerID,
        LineItems:     lineItems,
        Mode:          &mode,
        SuccessURL:    &successURL,
        CancelURL:     &cancelURL,
    }

    s, err := session.New(params)
    if err != nil {
        // log the error for investigation
        // render an error page or redirect user to an error page
        return
    }

    // redirect user to the checkout page
    http.Redirect(w, r, s.URL, http.StatusSeeOther)
}

Now that users are able to go through Stripe checkout to subscribe to our plan, we must create a webhook handler that is able to fulfill subscriptions when users successfully complete the checkout. For that, we will create a new function that returns a http.HandlerFunc

Creating the webhook handler

In order to receive webhook events, we will create a handler function that receives requests from Stripe, and parses the body of the requests into webhook event structs that contain a field called Type that we can use to determine what type of event we received, and handle them accordingly. In our simple case, we only need to listen to two events: checkout.session.completed and customer.subscription.deleted. These allow us to either allow or deny a user access to our service.

import (
    "net/http"

    "github.com/stripe/stripe-go/v78"
	"github.com/stripe/stripe-go/v78/checkout/session"
    "github.com/stripe/stripe-go/v78/customer"
	"github.com/stripe/stripe-go/v78/subscription"
	"github.com/stripe/stripe-go/v78/webhook"
)

func StripeWebhook() http.HandlerFunc {
    return http.HandlerFunc(w http.ResponseWriter, r *http.Request) {
        const maxBodyBytes := int64(65536)
        r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)

        body, err := io.ReadAll(r.Body)
        if err != nil {
            log.Printf("%+v\n", err)
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }

        event, err := webhook.ConstructEvent(
            body,
            r.Header.Get("Stripe-Signature"),
            os.Getenv("STRIPE_WEBHOOK_SECRET"),
        )
        if err != nil {
            log.Printf("%+v\n", err)
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        switch event.Type {
		case "checkout.session.completed":
			// here we know that the user successfully completed
            // a checkout session, therefore we need to fulfill
            // the customer's subscription on our end, i.e. allow
            // the user access to our service in the simplest case
            // we can set a boolean flag to true in our database
            if err := checkoutSessionCompleted(event.Data.Raw); err != nil {
                log.Printf("%+v\n", err)
                w.WriteHeader(http.StatusInternalServerError)
                return
            }
        case "customer.subscription.deleted":
            // Stripe will send a 'deleted' event when a customer's
            // subscription has expired i.e. the moment we should
            // deny the user access to our service again, in the
            // simplest case we can simply set a boolean flag to FALSE
            // in our database
            if err := customerSubscriptionDeleted(event.Data.Raw); err != nil {
                log.Printf("%+v\n", err)
                w.WriteHeader(http.StatusInternalServerError)
            }
            return
    }
}

Be sure to place STRIPE_WEBHOOK_SECRET in your environment variables for this to work. You can retrieve the secret from your Stripe account’s developer settings.

Handling checkout.session.completed

Now that our webhook handler is done, we still need to actually handle the two events we set out to handle. Let’s start by implementing checkoutSessionCompleted. This function receives the event’s raw data as JSON. We need to first convert it into a stripe.CheckoutSession and expand the event data to contain customer data in order to allow the correct user access to our service.

func checkoutSessionCompleted(rawData []byte) {
    var cs stripe.CheckoutSession
	err := json.Unmarshal(rawData, &cs)
	if err != nil {
		return fmt.Errorf("err parsing webhook json: %+v", err)
	}

	params := &stripe.CheckoutSessionParams{}
	params.AddExpand("customer")
	csExpanded, err := session.Get(cs.ID, params)
	if err != nil {
		return err
	}

    email := csExpanded.Customer.Email
    customerID := csExpanded.Customer.ID

    // fulfill granting user access
}

What happens next, comes down to the finer details of how our application is built. In any case we must handle two possible scenarios:

  1. A user corresponding to the email found in the webhook event already exists in our database
  2. No user corresponding to the email found in the webhook event exists in our database

In the first case, we simply update a flag in our database to TRUE, granting the user access to our service.

In the second case, we must create a user with the information found in the webhook event, most notably the user’s email address and customer ID.

Handling customer.subscription.deleted

Next, we should handle the unfortunate event that a user decides to cancel their subscription to our plan. Similarly to handling checkout session completed, we create a function that parses the raw event data into a stripe.Subscripion and expand it to contain user data:

func customerSubscriptionDeleted(rawData []byte) {
    var sub stripe.Subscription
	err := json.Unmarshal(rawData, &sub)
	if err != nil {
		return fmt.Errorf("err parsing webhook json: %+v", err)
	}

	params := stripe.SubscriptionParams{}
	params.AddExpand("customer")
	subExpanded, err := subscription.Get(sub.ID, &params)
	if err != nil {
		return err
	}

    email := subExpanded.Customer.Email

    // fulfill denying user access to our service
}

In our simple case, handling the customer.subscription.deleted event is a matter of simply parsing the user’s email address from the webhook event and setting the user’s access flag to FALSE in our database.

With that, our Stripe subscription flow for the most simple case is complete.

Sign up or log in to start commenting