golangtutorial

Handle Stripe Subscription in Golang

Implementing a smooth, reliable subscription experience for your users is absolutely critical in today's world. While Stripe's powerful subscription management features can feel like a daunting beast to integrate, with the right approach and Go's elegant simplicity, you can tame it into an efficient, manageable workflow.

This guide will walk you through the essential steps to build a basic Stripe subscription flow in Golang. We'll cover everything from directing users to Stripe's hosted checkout (your VIP entrance!) to deftly handling critical webhook events for subscription activation and deactivation. By the end, you'll have a solid understanding of how to leverage Stripe's API with Go, enhancing your application's billing workflow and keeping your users (and accountants) happy!

Chapter 1: The VIP Entrance - Creating a Checkout Session

Our first task is to send users to Stripe's hosted checkout page. This offloads all the tricky payment form handling, security, and PCI compliance to Stripe – a huge win for us! To create this session, we need a few key pieces of information:

  • Customer ID: Stripe's unique identifier for a customer. Always use an existing one if available to avoid creating duplicate customer records for the same user. Think of it as Stripe's social security number for your users.
  • Email: The user's email address. Crucial for matching users in your system to their Stripe records.
  • Line Items: What they're buying! For a simple subscription, this is usually just one Price ID (representing your subscription plan) and a Quantity of 1.
  • Success URL: Where to send the user after they successfully complete payment. This could be your app's dashboard or a "Welcome Aboard!" page.
  • Cancel URL: Where to send them if they back out of the checkout process. Maybe your pricing page or a "Don't go!" message.

Here's how you might implement a handler to redirect users to Stripe Checkout, assuming you're passing a price ID in the URL query parameters (e.g., /subscribe?price=price_123ABC):

package main

import (
	"log"        // For logging errors
	"net/http"   // Go's standard HTTP library
	"fmt"        // For formatting strings, especially error messages

	"github.com/stripe/stripe-go/v78"              // The main Stripe Go library
	"github.com/stripe/stripe-go/v78/checkout/session" // For creating checkout sessions
)

// Dummy User struct for demonstration. Replace with your actual user model.
// Assumes you have a way to fetch the current user, possibly from a request context.
type User struct {
	Email      string
	CustomerID stripe.NullString // Use stripe.NullString to handle nullable CustomerID
}

// getUser is a placeholder. In a real app, this would get the user from
// a session cookie, JWT, etc., and return your actual user struct.
func getUser(r *http.Request) *User {
	// For demonstration, let's pretend a user is logged in
	// and has an email, but maybe not a Stripe Customer ID yet.
	return &User{
		Email: "test@example.com",
		// CustomerID: stripe.NullString{String: "cus_ABCDEF12345", Valid: true}, // Uncomment to simulate existing customer
	}
}

// CreateCheckoutSession handles the request to initiate a Stripe checkout.
func CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
	u := getUser(r) // Get the current user from your application's context/session
	priceID := r.URL.Query().Get("price") // Expecting price ID from query param
	quantity := int64(1)
	mode := string(stripe.CheckoutSessionModeSubscription) // Must be "subscription" for subscriptions

	// Define where Stripe should send the user after checkout.
	// You might have different success URLs based on user's logged-in state.
	successURL := "https://example.com/app/dashboard?session_id={CHECKOUT_SESSION_ID}"
	cancelURL := "https://example.com/pricing" // Send them back to your pricing page on cancel

	// This is the actual subscription plan (price) and quantity.
	lineItems := []*stripe.CheckoutSessionLineItemParams{
		{
			Price:    &priceID, // The Price ID from your Stripe dashboard
			Quantity: &quantity,
		},
	}

	// Determine customer information for Stripe.
	// If we have a logged-in user with a Stripe Customer ID, use that.
	// Otherwise, if they're logged in with just an email, pass the email.
	// If no user (u == nil), Stripe will prompt for email in checkout.
	var customerEmail *string
	var customerID *string
	if u != nil {
		if u.CustomerID.Valid {
			customerID = &u.CustomerID.String // Use existing Stripe Customer ID
		} else {
			customerEmail = &u.Email // Provide user's email for Stripe to create/match
		}
	}

	// Build the parameters for our new Checkout Session
	params := &stripe.CheckoutSessionParams{
		CustomerEmail: customerEmail, // Either email (for new customer)
		Customer:      customerID,    // Or existing customer ID
		LineItems:     lineItems,
		Mode:          &mode,
		SuccessURL:    &successURL,
		CancelURL:     &cancelURL,
		// If you want to automatically collect tax, add this:
		// AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{Enabled: stripe.Bool(true)},
	}

	// Create the actual Stripe Checkout Session!
	s, err := session.New(params)
	if err != nil {
		log.Printf("session.New: %v", err) // Log the detailed error
		http.Error(w, "Failed to create checkout session. Please try again.", http.StatusInternalServerError)
		return
	}

	// Redirect the user's browser to Stripe's hosted checkout page!
	http.Redirect(w, r, s.URL, http.StatusSeeOther)
}

Now that users can be whisked away to Stripe to subscribe, the next crucial step is to listen for Stripe's "gossip" – i.e., webhook events – to know when a subscription has been paid for or cancelled.

Chapter 2: Stripe's Gossip Line - Creating the Webhook Handler

Stripe communicates back to your application using webhooks. Think of them as push notifications from Stripe saying, "Hey, something important just happened!" We need a dedicated HTTP endpoint to receive these notifications and act upon them.

The most critical part of handling webhooks is verifying the signature of the incoming request. This ensures that the event actually came from Stripe and hasn't been tampered with. Stripe's Go library makes this straightforward with webhook.ConstructEvent.

For our basic flow, we'll focus on two main events:

  • checkout.session.completed: A user just successfully paid for a subscription! Time to grant them access.
  • customer.subscription.deleted: A subscription has ended (either cancelled by the user, failed payment, etc.). Time to revoke access.
package main // or your relevant package for handlers

import (
	"encoding/json" // For unmarshaling JSON data
	"io"            // For reading the request body
	"log"           // For logging
	"net/http"      // Go's standard HTTP library
	"os"            // For reading environment variables
	"fmt"           // For formatting errors

	"github.com/stripe/stripe-go/v78"                  // Main Stripe library
	"github.com/stripe/stripe-go/v78/checkout/session" // For checkout session objects
	"github.com/stripe/stripe-go/v78/subscription"     // For subscription objects
	"github.com/stripe/stripe-go/v78/webhook"          // For webhook verification
)

// Placeholder functions for handling events.
// In a real app, these would interact with your database.
func checkoutSessionCompleted(rawData []byte) error {
    log.Println("Handling checkout.session.completed event...")
    // Simulate parsing and expanding, then fulfilling access
    return nil // Simulate success
}

func customerSubscriptionDeleted(rawData []byte) error {
    log.Println("Handling customer.subscription.deleted event...")
    // Simulate parsing and expanding, then denying access
    return nil // Simulate success
}


// StripeWebhook is the handler for Stripe webhook events.
func StripeWebhook() http.HandlerFunc {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Set a maximum body size to prevent malicious large requests.
		const maxBodyBytes = int64(65536) // 64KB
		r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)

		// Read the raw request body. This is crucial for signature verification.
		body, err := io.ReadAll(r.Body)
		if err != nil {
			log.Printf("Error reading webhook body: %v\n", err)
			w.WriteHeader(http.StatusServiceUnavailable) // Often used for temporary issues
			return
		}

		// Get your Stripe webhook secret from environment variables.
		// IMPORTANT: This secret is unique per webhook endpoint you configure in Stripe.
		// NEVER hardcode it.
		webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET")
		if webhookSecret == "" {
			log.Printf("STRIPE_WEBHOOK_SECRET environment variable not set.\n")
			http.Error(w, "Server configuration error", http.StatusInternalServerError)
			return
		}

		// Construct and verify the event. This is the crucial security step!
		event, err := webhook.ConstructEvent(
			body,
			r.Header.Get("Stripe-Signature"), // Get the signature from the request header
			webhookSecret,
		)
		if err != nil {
			log.Printf("Error verifying Stripe webhook signature: %v\n", err)
			w.WriteHeader(http.StatusBadRequest) // Bad request if signature is invalid
			return
		}

		// Now, let's figure out what kind of "gossip" Stripe sent us!
		switch event.Type {
		case "checkout.session.completed":
			// Hooray! A user successfully completed a checkout session.
			// Time to fulfill their subscription on our end.
			log.Println("Received checkout.session.completed event.")
			if err := checkoutSessionCompleted(event.Data.Raw); err != nil {
				log.Printf("Error handling checkout.session.completed: %v\n", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
		case "customer.subscription.deleted":
			// Oh no! A customer's subscription has been deleted (cancelled, failed payment, etc.).
			// Time to deny them access to our service.
			log.Println("Received customer.subscription.deleted event.")
			if err := customerSubscriptionDeleted(event.Data.Raw); err != nil {
				log.Printf("Error handling customer.subscription.deleted: %v\n", err)
				w.WriteHeader(http.StatusInternalServerError)
				return
			}
		// You might want to handle other events like:
		// case "customer.subscription.updated": // If a user changes plans
		// case "invoice.payment_succeeded": // For general billing info
		// case "invoice.payment_failed": // To notify users about failed payments
		default:
			log.Printf("Unhandled event type: %s\n", event.Type)
		}

		// Always respond with a 200 OK to Stripe to acknowledge receipt of the webhook.
		w.WriteHeader(http.StatusOK)
	})
}

Don't forget! You'll need to configure your webhook endpoint in your Stripe Dashboard (Developers -> Webhooks). Also, ensure STRIPE_WEBHOOK_SECRET is set in your environment variables. For local development, stripe listen is your best friend!

Chapter 3: The 'Hooray, Money!' Event - Handling checkout.session.completed

When Stripe tells us checkout.session.completed, it's our cue to grant the user access to the subscribed service. This usually involves updating a flag in your database.

The key here is that the initial checkout.session.completed event might not contain all the customer details directly. We often need to "expand" the event data to get the full Customer object, which contains the email and Stripe Customer ID.

package main // or your relevant package for handlers

import (
	"encoding/json" // For parsing JSON
	"fmt"           // For formatting errors
	"log"           // For logging

	"github.com/stripe/stripe-go/v78"              // Main Stripe library
	"github.com/stripe/stripe-go/v78/checkout/session" // For checkout session objects
)

// Dummy function to simulate database interaction.
// In a real application, you would update your 'User' table.
func updateUserSubscriptionStatus(email, customerID string, isActive bool) {
    log.Printf("DATABASE ACTION: User %s (Stripe ID: %s) subscription set to active: %t\n", email, customerID, isActive)
    // Here you would:
    // 1. Find the user by email in your database.
    // 2. If the user exists, update their 'is_subscribed' flag to isActive and store their customerID.
    // 3. If the user does NOT exist (e.g., they signed up via Stripe checkout),
    //    create a new user record in your database with the provided email and customerID,
    //    and set their 'is_subscribed' flag to isActive.
}


// checkoutSessionCompleted handles the "checkout.session.completed" webhook event.
func checkoutSessionCompleted(rawData []byte) error {
	var cs stripe.CheckoutSession
	err := json.Unmarshal(rawData, &cs) // Parse the raw event data into a CheckoutSession object
	if err != nil {
		return fmt.Errorf("error parsing checkout session webhook JSON: %w", err)
	}

	// It's crucial to retrieve the CheckoutSession and Expand the 'customer' object.
	// The initial webhook event often only contains the customer ID, not the full details.
	params := &stripe.CheckoutSessionParams{}
	params.AddExpand("customer") // Request Stripe to include the full Customer object

	csExpanded, err := session.Get(cs.ID, params) // Retrieve the session with expanded customer data
	if err != nil {
		return fmt.Errorf("error getting expanded checkout session %s: %w", cs.ID, err)
	}

	// Now we can safely access the customer's email and Stripe Customer ID
	email := csExpanded.Customer.Email
	customerID := csExpanded.Customer.ID

	if email == "" || customerID == "" {
		return fmt.Errorf("expanded checkout session %s missing customer email or ID", cs.ID)
	}

	// --- Fulfilling the Subscription: What happens in your app ---
	// This is where your application-specific logic goes!
	// You need to handle two primary scenarios:

	// Scenario 1: A user corresponding to this email already exists in your database.
	// 	- Update their subscription status flag to TRUE (e.g., `is_subscribed = true`).
	// 	- Store their `customerID` from Stripe on their user record if it's new.

	// Scenario 2: No user corresponding to this email exists in your database.
	// 	- Create a brand new user record in your database.
	// 	- Store their `email` and `customerID`.
	// 	- Set their `is_subscribed` flag to TRUE.
	// 	- (Optional) Send them a welcome email and instructions to set a password.

	log.Printf("Fulfilling subscription for email: %s, Stripe Customer ID: %s\n", email, customerID)
	updateUserSubscriptionStatus(email, customerID, true) // Dummy call to your DB logic

	return nil // Success!
}

Chapter 4: The 'Oh No, They Left!' Event - Handling customer.subscription.deleted

The flip side of successful subscriptions is when users decide to cancel, or their payments fail, leading to subscription deletion. When Stripe sends a customer.subscription.deleted event, it's our cue to revoke their access to our service.

Similar to the checkout session, we'll parse the raw data into a stripe.Subscription object and expand it to get the associated customer details.

package main // or your relevant package for handlers

import (
	"encoding/json" // For parsing JSON
	"fmt"           // For formatting errors
	"log"           // For logging

	"github.com/stripe/stripe-go/v78"              // Main Stripe library
	"github.com/stripe/stripe-go/v78/subscription" // For subscription objects
)

// Dummy function to simulate database interaction.
// In a real application, you would update your 'User' table.
// This is the same function from the previous example, just re-declared for clarity.
func updateUserSubscriptionStatus(email, customerID string, isActive bool) {
    log.Printf("DATABASE ACTION: User %s (Stripe ID: %s) subscription set to active: %t\n", email, customerID, isActive)
    // Here you would:
    // 1. Find the user by email or customerID in your database.
    // 2. Update their 'is_subscribed' flag to isActive (false in this case).
}


// customerSubscriptionDeleted handles the "customer.subscription.deleted" webhook event.
func customerSubscriptionDeleted(rawData []byte) error {
	var sub stripe.Subscription
	err := json.Unmarshal(rawData, &sub) // Parse the raw event data into a Subscription object
	if err != nil {
		return fmt.Errorf("error parsing subscription deleted webhook JSON: %w", err)
	}

	// Expand the 'customer' object to get their email and ID.
	params := &stripe.SubscriptionParams{}
	params.AddExpand("customer") // Request Stripe to include the full Customer object

	subExpanded, err := subscription.Get(sub.ID, params) // Retrieve the subscription with expanded customer data
	if err != nil {
		return fmt.Errorf("error getting expanded subscription %s: %w", sub.ID, err)
	}

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

	if email == "" || customerID == "" {
		return fmt.Errorf("expanded subscription %s missing customer email or ID", sub.ID)
	}

	// --- Denying Access: What happens in your app ---
	// In most simple cases, this means setting a flag in your database to FALSE.
	// For instance, `is_subscribed = false` for the user associated with this email/customerID.
	log.Printf("Denying access for user with email: %s, Stripe Customer ID: %s (Subscription ID: %s deleted)\n", email, customerID, sub.ID)
	updateUserSubscriptionStatus(email, customerID, false) // Dummy call to your DB logic

	return nil // Success!
}

Wrapping Up Our Stripe Adventure!

And with that, you've built the fundamental pieces of a Stripe subscription flow in Golang! From initiating a checkout session to gracefully handling the "money received" and "subscription gone" events, you're now equipped to manage basic user subscriptions.

Remember, this is just the beginning. Real-world applications might involve:

  • Handling customer.subscription.updated events (e.g., plan changes, payment method updates).
  • Dealing with invoice.payment_failed to send dunning emails.
  • Implementing customer portals for users to manage their own subscriptions.
  • More sophisticated error handling and logging.

But for now, give yourself a pat on the back – you've tamed a piece of the payment beast! Go forth and build amazing subscription services with confidence.