Billing is the feature most tutorials gloss over and most production bugs come from. Here is how KaryoSpace wires Stripe into a Go SaaS: webhook handler with signature verification, plan enforcement middleware, and a billing admin page that gives operators full visibility.
Stripe communicates billing events to your server via webhooks. The handler must verify the signature before processing anything. Stripe includes a Stripe-Signature header that contains an HMAC-SHA256 of the raw request body. If you parse the body before verifying, you have already lost the ability to verify (the parsed representation differs from the raw bytes).
func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
const maxBodySize = 65536
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodySize))
if err != nil {
http.Error(w, "read error", 400)
return
}
// Verify signature BEFORE touching the body content
sig := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(body, sig, os.Getenv("STRIPE_WEBHOOK_SECRET"))
if err != nil {
http.Error(w, "invalid signature", 400)
return
}
// Now safe to dispatch
switch event.Type {
case "checkout.session.completed":
handleCheckoutComplete(event)
case "customer.subscription.updated":
handleSubscriptionUpdate(event)
case "customer.subscription.deleted":
handleSubscriptionDeleted(event)
case "invoice.payment_failed":
handlePaymentFailed(event)
}
w.WriteHeader(200)
}
The io.LimitReader cap on the body is not optional. Without it, a malicious payload could exhaust memory. Stripe payloads are small in practice, but never trust that.
Stripe may deliver the same webhook event more than once. Your handler must be idempotent: processing the same event twice should produce the same outcome as processing it once. The simplest approach is to store processed event IDs and skip duplicates.
func handleCheckoutComplete(event stripe.Event) {
// Check idempotency
exists, _ := db.Collection("billing_processed_events").CountDocuments(ctx,
bson.M{"stripe_event_id": event.ID})
if exists > 0 { return } // already handled
var session stripe.CheckoutSession
json.Unmarshal(event.Data.Raw, &session)
// Update org billing record
_, err := db.Collection("billing_subscriptions").UpdateOne(ctx,
bson.M{"org_id": session.Metadata["org_id"]},
bson.M{"$set": bson.M{
"plan": "business",
"stripe_customer_id": session.Customer.ID,
"stripe_sub_id": session.Subscription.ID,
"status": "active",
"updated_at": time.Now(),
}},
options.Update().SetUpsert(true))
if err == nil {
// Record as processed
db.Collection("billing_processed_events").InsertOne(ctx,
bson.M{"stripe_event_id": event.ID, "processed_at": time.Now()})
}
}
The current plan for an org is stored in billing_subscriptions. A middleware checks the plan on every request to plan-gated features. The middleware is lightweight: one MongoDB read per request, cached in the session for 5 minutes to avoid hammering the database.
type BillingPlan string
const (
PlanFree BillingPlan = "free"
PlanBusiness BillingPlan = "business"
PlanEnterprise BillingPlan = "enterprise"
)
func requirePlan(plan BillingPlan, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgPlan := getOrgPlan(r.Context(), getOrgID(r))
if !planCovers(orgPlan, plan) {
http.Error(w, "upgrade required", 402)
return
}
next(w, r)
}
}
func planCovers(have, need BillingPlan) bool {
order := map[BillingPlan]int{
PlanFree: 0, PlanBusiness: 1, PlanEnterprise: 2,
}
return order[have] >= order[need]
}
The free tier allows up to 5 users and 1 email integration. These limits are enforced at the create-user and connect-integration endpoints, not in middleware. Enforcement at the point of action (rather than at display time) prevents race conditions where two concurrent requests both pass the limit check.
func canAddUser(orgID string) error {
plan := getOrgPlan(ctx, orgID)
if plan != PlanFree { return nil }
count, _ := db.Collection("employees").CountDocuments(ctx,
bson.M{"org_id": orgID, "active": true})
if count >= 5 {
return errors.New("free tier limit: 5 users maximum")
}
return nil
}
The /admin/billing page shows operators the current plan, subscription status, Stripe customer ID, next billing date, and payment history. It also has a "Manage in Stripe" button that redirects to the Stripe customer portal, where users can update their payment method, download invoices, or cancel.
// GET /api/admin/billing/portal — returns a Stripe customer portal URL
func billingPortalHandler(w http.ResponseWriter, r *http.Request) {
orgID := getOrgID(r)
sub, _ := getBillingSubscription(ctx, orgID)
params := &stripe.BillingPortalSessionParams{
Customer: stripe.String(sub.StripeCustomerID),
ReturnURL: stripe.String("https://karyospace.com/admin/billing"),
}
session, err := portalsession.New(params)
if err != nil {
http.Error(w, "portal unavailable", 500)
return
}
http.Redirect(w, r, session.URL, http.StatusSeeOther)
}
Stripe provides a test mode with test clock objects that let you advance time to trigger subscription renewals and payment failures without waiting. KaryoSpace uses the Stripe CLI to replay webhook events against the local dev server. The 25 billing tests cover the full event lifecycle: checkout complete, subscription renewal, payment failure, subscription cancel, and the idempotency guard.
# Replay a webhook in dev
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
One test specifically verifies the idempotency guard by sending the same event ID twice and asserting the billing record has only one entry. Without this test, the guard is invisible until Stripe starts retrying in production.
Questions about KaryoSpace or this article?