Back to Journal
SaaS Engineering

Complete Guide to Subscription Billing Systems with Go

A comprehensive guide to implementing Subscription Billing Systems using Go, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 16 min read

Go's strong typing, built-in concurrency, and performance characteristics make it an excellent choice for subscription billing systems that need to handle high-volume webhook processing, real-time usage metering, and concurrent invoice generation. This guide covers building a production-ready billing system in Go with Stripe integration, database persistence, and proper error handling.

Project Structure

1billing/
2├── cmd/
3│ └── server/main.go
4├── internal/
5│ ├── billing/
6│ │ ├── plan.go
7│ │ ├── subscription.go
8│ │ ├── invoice.go
9│ │ └── usage.go
10│ ├── stripe/
11│ │ ├── client.go
12│ │ └── webhook.go
13│ ├── store/
14│ │ └── postgres.go
15│ └── handler/
16│ └── billing.go
17├── go.mod
18└── go.sum
19 

Core Types

go
1package billing
2 
3import (
4 "time"
5)
6 
7type SubscriptionStatus string
8 
9const (
10 StatusActive SubscriptionStatus = "active"
11 StatusTrialing SubscriptionStatus = "trialing"
12 StatusPastDue SubscriptionStatus = "past_due"
13 StatusCancelled SubscriptionStatus = "cancelled"
14 StatusPaused SubscriptionStatus = "paused"
15)
16 
17type BillingInterval string
18 
19const (
20 IntervalMonth BillingInterval = "month"
21 IntervalYear BillingInterval = "year"
22)
23 
24type Plan struct {
25 ID string `json:"id" db:"id"`
26 Name string `json:"name" db:"name"`
27 Slug string `json:"slug" db:"slug"`
28 Active bool `json:"active" db:"active"`
29 Interval BillingInterval `json:"interval" db:"billing_interval"`
30 BasePriceCents int64 `json:"base_price_cents" db:"base_price_cents"`
31 PricePerSeatCents int64 `json:"price_per_seat_cents" db:"price_per_seat_cents"`
32 IncludedSeats int `json:"included_seats" db:"included_seats"`
33 Features []string `json:"features" db:"features"`
34 StripePriceID string `json:"stripe_price_id" db:"stripe_price_id"`
35 MaxProjects *int `json:"max_projects" db:"max_projects"`
36 MaxStorageMB int64 `json:"max_storage_mb" db:"max_storage_mb"`
37 CreatedAt time.Time `json:"created_at" db:"created_at"`
38}
39 
40type Subscription struct {
41 ID string `json:"id" db:"id"`
42 CustomerID string `json:"customer_id" db:"customer_id"`
43 PlanID string `json:"plan_id" db:"plan_id"`
44 StripeSubscriptionID string `json:"stripe_subscription_id" db:"stripe_subscription_id"`
45 StripeCustomerID string `json:"stripe_customer_id" db:"stripe_customer_id"`
46 Status SubscriptionStatus `json:"status" db:"status"`
47 Quantity int `json:"quantity" db:"quantity"`
48 CurrentPeriodStart time.Time `json:"current_period_start" db:"current_period_start"`
49 CurrentPeriodEnd time.Time `json:"current_period_end" db:"current_period_end"`
50 CancelAtPeriodEnd bool `json:"cancel_at_period_end" db:"cancel_at_period_end"`
51 TrialEndsAt *time.Time `json:"trial_ends_at" db:"trial_ends_at"`
52 CancelledAt *time.Time `json:"cancelled_at" db:"cancelled_at"`
53 CreatedAt time.Time `json:"created_at" db:"created_at"`
54 UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
55}
56 

Billing Service

go
1package billing
2 
3import (
4 "context"
5 "fmt"
6 "time"
7 
8 "github.com/google/uuid"
9 "github.com/stripe/stripe-go/v76"
10 "github.com/stripe/stripe-go/v76/subscription"
11)
12 
13type Store interface {
14 GetPlan(ctx context.Context, id string) (*Plan, error)
15 GetPlanBySlug(ctx context.Context, slug string) (*Plan, error)
16 GetSubscription(ctx context.Context, id string) (*Subscription, error)
17 GetSubscriptionByCustomer(ctx context.Context, customerID string) (*Subscription, error)
18 GetSubscriptionByStripeID(ctx context.Context, stripeSubID string) (*Subscription, error)
19 CreateSubscription(ctx context.Context, sub *Subscription) error
20 UpdateSubscription(ctx context.Context, sub *Subscription) error
21 SaveWebhookEvent(ctx context.Context, eventID string) error
22 IsWebhookProcessed(ctx context.Context, eventID string) (bool, error)
23 RecordUsage(ctx context.Context, event *UsageEvent) error
24 GetUsageSummary(ctx context.Context, subID, metricID string, start, end time.Time) (int64, error)
25}
26 
27type Service struct {
28 store Store
29}
30 
31func NewService(store Store) *Service {
32 return &Service{store: store}
33}
34 
35func (s *Service) CreateSubscription(
36 ctx context.Context,
37 customerID string,
38 planSlug string,
39 quantity int,
40 stripeCustomerID string,
41) (*Subscription, error) {
42 plan, err := s.store.GetPlanBySlug(ctx, planSlug)
43 if err != nil {
44 return nil, fmt.Errorf("get plan: %w", err)
45 }
46 if !plan.Active {
47 return nil, fmt.Errorf("plan %s is not active", planSlug)
48 }
49 
50 // Create Stripe subscription
51 params := &stripe.SubscriptionParams{
52 Customer: stripe.String(stripeCustomerID),
53 Items: []*stripe.SubscriptionItemsParams{
54 {
55 Price: stripe.String(plan.StripePriceID),
56 Quantity: stripe.Int64(int64(quantity)),
57 },
58 },
59 }
60 
61 stripeSub, err := subscription.New(params)
62 if err != nil {
63 return nil, fmt.Errorf("create stripe subscription: %w", err)
64 }
65 
66 sub := &Subscription{
67 ID: uuid.New().String(),
68 CustomerID: customerID,
69 PlanID: plan.ID,
70 StripeSubscriptionID: stripeSub.ID,
71 StripeCustomerID: stripeCustomerID,
72 Status: StatusActive,
73 Quantity: quantity,
74 CurrentPeriodStart: time.Unix(stripeSub.CurrentPeriodStart, 0),
75 CurrentPeriodEnd: time.Unix(stripeSub.CurrentPeriodEnd, 0),
76 CreatedAt: time.Now(),
77 UpdatedAt: time.Now(),
78 }
79 
80 if err := s.store.CreateSubscription(ctx, sub); err != nil {
81 return nil, fmt.Errorf("save subscription: %w", err)
82 }
83 
84 return sub, nil
85}
86 
87func (s *Service) ChangePlan(
88 ctx context.Context,
89 subscriptionID string,
90 newPlanSlug string,
91) (*Subscription, *ProrationResult, error) {
92 sub, err := s.store.GetSubscription(ctx, subscriptionID)
93 if err != nil {
94 return nil, nil, fmt.Errorf("get subscription: %w", err)
95 }
96 
97 currentPlan, err := s.store.GetPlan(ctx, sub.PlanID)
98 if err != nil {
99 return nil, nil, fmt.Errorf("get current plan: %w", err)
100 }
101 
102 newPlan, err := s.store.GetPlanBySlug(ctx, newPlanSlug)
103 if err != nil {
104 return nil, nil, fmt.Errorf("get new plan: %w", err)
105 }
106 
107 proration := CalculateProration(
108 currentPlan, newPlan, sub.Quantity, sub.Quantity,
109 sub.CurrentPeriodStart, sub.CurrentPeriodEnd, time.Now(),
110 )
111 
112 // Update Stripe
113 params := &stripe.SubscriptionParams{
114 Items: []*stripe.SubscriptionItemsParams{
115 {
116 ID: stripe.String(sub.StripeSubscriptionID),
117 Price: stripe.String(newPlan.StripePriceID),
118 },
119 },
120 ProrationBehavior: stripe.String("create_prorations"),
121 }
122 
123 _, err = subscription.Update(sub.StripeSubscriptionID, params)
124 if err != nil {
125 return nil, nil, fmt.Errorf("update stripe subscription: %w", err)
126 }
127 
128 sub.PlanID = newPlan.ID
129 sub.UpdatedAt = time.Now()
130 
131 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
132 return nil, nil, fmt.Errorf("update subscription: %w", err)
133 }
134 
135 return sub, proration, nil
136}
137 
138func (s *Service) UpdateQuantity(
139 ctx context.Context,
140 subscriptionID string,
141 newQuantity int,
142) (*Subscription, error) {
143 sub, err := s.store.GetSubscription(ctx, subscriptionID)
144 if err != nil {
145 return nil, fmt.Errorf("get subscription: %w", err)
146 }
147 
148 params := &stripe.SubscriptionParams{
149 Items: []*stripe.SubscriptionItemsParams{
150 {
151 ID: stripe.String(sub.StripeSubscriptionID),
152 Quantity: stripe.Int64(int64(newQuantity)),
153 },
154 },
155 ProrationBehavior: stripe.String("create_prorations"),
156 }
157 
158 _, err = subscription.Update(sub.StripeSubscriptionID, params)
159 if err != nil {
160 return nil, fmt.Errorf("update stripe quantity: %w", err)
161 }
162 
163 sub.Quantity = newQuantity
164 sub.UpdatedAt = time.Now()
165 
166 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
167 return nil, fmt.Errorf("update subscription: %w", err)
168 }
169 
170 return sub, nil
171}
172 
173func (s *Service) CancelSubscription(
174 ctx context.Context,
175 subscriptionID string,
176 immediately bool,
177) (*Subscription, error) {
178 sub, err := s.store.GetSubscription(ctx, subscriptionID)
179 if err != nil {
180 return nil, fmt.Errorf("get subscription: %w", err)
181 }
182 
183 if immediately {
184 _, err = subscription.Cancel(sub.StripeSubscriptionID, nil)
185 } else {
186 params := &stripe.SubscriptionParams{
187 CancelAtPeriodEnd: stripe.Bool(true),
188 }
189 _, err = subscription.Update(sub.StripeSubscriptionID, params)
190 }
191 
192 if err != nil {
193 return nil, fmt.Errorf("cancel stripe subscription: %w", err)
194 }
195 
196 if immediately {
197 now := time.Now()
198 sub.Status = StatusCancelled
199 sub.CancelledAt = &now
200 } else {
201 sub.CancelAtPeriodEnd = true
202 }
203 sub.UpdatedAt = time.Now()
204 
205 if err := s.store.UpdateSubscription(ctx, sub); err != nil {
206 return nil, fmt.Errorf("update subscription: %w", err)
207 }
208 
209 return sub, nil
210}
211 

Proration Calculator

go
1package billing
2 
3import (
4 "math"
5 "time"
6)
7 
8type ProrationResult struct {
9 CreditCents int64 `json:"credit_cents"`
10 ChargeCents int64 `json:"charge_cents"`
11 NetCents int64 `json:"net_cents"`
12 EffectiveAt time.Time `json:"effective_at"`
13 Description string `json:"description"`
14}
15 
16func CalculateProration(
17 currentPlan, newPlan *Plan,
18 currentQty, newQty int,
19 periodStart, periodEnd time.Time,
20 changeDate time.Time,
21) *ProrationResult {
22 totalDays := periodEnd.Sub(periodStart).Hours() / 24
23 remainingDays := periodEnd.Sub(changeDate).Hours() / 24
24 
25 currentTotal := float64(currentPlan.BasePriceCents) +
26 float64(currentPlan.PricePerSeatCents)*float64(max(0, currentQty-currentPlan.IncludedSeats))
27 currentDaily := currentTotal / totalDays
28 credit := int64(math.Round(currentDaily * remainingDays))
29 
30 newTotal := float64(newPlan.BasePriceCents) +
31 float64(newPlan.PricePerSeatCents)*float64(max(0, newQty-newPlan.IncludedSeats))
32 newDaily := newTotal / totalDays
33 charge := int64(math.Round(newDaily * remainingDays))
34 
35 return &ProrationResult{
36 CreditCents: credit,
37 ChargeCents: charge,
38 NetCents: charge - credit,
39 EffectiveAt: changeDate,
40 Description: fmt.Sprintf("%s (%d seats) → %s (%d seats), %d days remaining",
41 currentPlan.Name, currentQty, newPlan.Name, newQty, int(remainingDays)),
42 }
43}
44 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Webhook Handler

go
1package stripe
2 
3import (
4 "encoding/json"
5 "io"
6 "log/slog"
7 "net/http"
8 
9 "github.com/stripe/stripe-go/v76"
10 "github.com/stripe/stripe-go/v76/webhook"
11 "yourapp/internal/billing"
12)
13 
14type WebhookHandler struct {
15 service *billing.Service
16 store billing.Store
17 webhookSecret string
18 logger *slog.Logger
19}
20 
21func NewWebhookHandler(service *billing.Service, store billing.Store, secret string, logger *slog.Logger) *WebhookHandler {
22 return &WebhookHandler{
23 service: service,
24 store: store,
25 webhookSecret: secret,
26 logger: logger,
27 }
28}
29 
30func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
31 body, err := io.ReadAll(r.Body)
32 if err != nil {
33 http.Error(w, "failed to read body", http.StatusBadRequest)
34 return
35 }
36 
37 event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), h.webhookSecret)
38 if err != nil {
39 http.Error(w, "invalid signature", http.StatusUnauthorized)
40 return
41 }
42 
43 // Acknowledge immediately
44 w.WriteHeader(http.StatusOK)
45 
46 // Process asynchronously
47 go func() {
48 if err := h.processEvent(event); err != nil {
49 h.logger.Error("webhook processing failed",
50 "event_id", event.ID,
51 "event_type", event.Type,
52 "error", err,
53 )
54 }
55 }()
56}
57 
58func (h *WebhookHandler) processEvent(event stripe.Event) error {
59 ctx := context.Background()
60 
61 // Idempotency check
62 processed, err := h.store.IsWebhookProcessed(ctx, event.ID)
63 if err != nil {
64 return fmt.Errorf("check webhook processed: %w", err)
65 }
66 if processed {
67 return nil
68 }
69 
70 switch event.Type {
71 case "customer.subscription.updated":
72 var sub stripe.Subscription
73 if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
74 return fmt.Errorf("unmarshal subscription: %w", err)
75 }
76 return h.handleSubscriptionUpdated(ctx, &sub)
77 
78 case "customer.subscription.deleted":
79 var sub stripe.Subscription
80 if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
81 return fmt.Errorf("unmarshal subscription: %w", err)
82 }
83 return h.handleSubscriptionDeleted(ctx, &sub)
84 
85 case "invoice.payment_failed":
86 var inv stripe.Invoice
87 if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
88 return fmt.Errorf("unmarshal invoice: %w", err)
89 }
90 return h.handlePaymentFailed(ctx, &inv)
91 
92 case "invoice.paid":
93 var inv stripe.Invoice
94 if err := json.Unmarshal(event.Data.Raw, &inv); err != nil {
95 return fmt.Errorf("unmarshal invoice: %w", err)
96 }
97 return h.handleInvoicePaid(ctx, &inv)
98 }
99 
100 // Mark as processed
101 return h.store.SaveWebhookEvent(ctx, event.ID)
102}
103 
104func (h *WebhookHandler) handleSubscriptionUpdated(ctx context.Context, stripeSub *stripe.Subscription) error {
105 sub, err := h.store.GetSubscriptionByStripeID(ctx, stripeSub.ID)
106 if err != nil {
107 return err
108 }
109 if sub == nil {
110 h.logger.Warn("subscription not found", "stripe_id", stripeSub.ID)
111 return nil
112 }
113 
114 sub.Status = mapStripeStatus(stripeSub.Status)
115 sub.Quantity = int(stripeSub.Items.Data[0].Quantity)
116 sub.CurrentPeriodStart = time.Unix(stripeSub.CurrentPeriodStart, 0)
117 sub.CurrentPeriodEnd = time.Unix(stripeSub.CurrentPeriodEnd, 0)
118 sub.CancelAtPeriodEnd = stripeSub.CancelAtPeriodEnd
119 sub.UpdatedAt = time.Now()
120 
121 return h.store.UpdateSubscription(ctx, sub)
122}
123 
124func (h *WebhookHandler) handleSubscriptionDeleted(ctx context.Context, stripeSub *stripe.Subscription) error {
125 sub, err := h.store.GetSubscriptionByStripeID(ctx, stripeSub.ID)
126 if err != nil || sub == nil {
127 return err
128 }
129 
130 now := time.Now()
131 sub.Status = billing.StatusCancelled
132 sub.CancelledAt = &now
133 sub.UpdatedAt = now
134 
135 return h.store.UpdateSubscription(ctx, sub)
136}
137 
138func (h *WebhookHandler) handlePaymentFailed(ctx context.Context, invoice *stripe.Invoice) error {
139 if invoice.Subscription == nil {
140 return nil
141 }
142 
143 sub, err := h.store.GetSubscriptionByStripeID(ctx, invoice.Subscription.ID)
144 if err != nil || sub == nil {
145 return err
146 }
147 
148 sub.Status = billing.StatusPastDue
149 sub.UpdatedAt = time.Now()
150 
151 return h.store.UpdateSubscription(ctx, sub)
152}
153 
154func mapStripeStatus(status stripe.SubscriptionStatus) billing.SubscriptionStatus {
155 switch status {
156 case stripe.SubscriptionStatusActive:
157 return billing.StatusActive
158 case stripe.SubscriptionStatusTrialing:
159 return billing.StatusTrialing
160 case stripe.SubscriptionStatusPastDue:
161 return billing.StatusPastDue
162 case stripe.SubscriptionStatusCanceled:
163 return billing.StatusCancelled
164 case stripe.SubscriptionStatusPaused:
165 return billing.StatusPaused
166 default:
167 return billing.StatusActive
168 }
169}
170 

Usage Metering

go
1package billing
2 
3import (
4 "context"
5 "fmt"
6 "time"
7)
8 
9type UsageEvent struct {
10 IdempotencyKey string `json:"idempotency_key" db:"idempotency_key"`
11 SubscriptionID string `json:"subscription_id" db:"subscription_id"`
12 MetricID string `json:"metric_id" db:"metric_id"`
13 Quantity int64 `json:"quantity" db:"quantity"`
14 Timestamp time.Time `json:"timestamp" db:"timestamp"`
15 Metadata map[string]interface{} `json:"metadata" db:"metadata"`
16}
17 
18type UsageSummary struct {
19 MetricID string `json:"metric_id"`
20 Total int64 `json:"total"`
21 PeriodStart time.Time `json:"period_start"`
22 PeriodEnd time.Time `json:"period_end"`
23}
24 
25func (s *Service) RecordUsage(ctx context.Context, event *UsageEvent) error {
26 return s.store.RecordUsage(ctx, event)
27}
28 
29func (s *Service) GetUsageSummary(
30 ctx context.Context,
31 subscriptionID string,
32 metricID string,
33 periodStart, periodEnd time.Time,
34) (*UsageSummary, error) {
35 total, err := s.store.GetUsageSummary(ctx, subscriptionID, metricID, periodStart, periodEnd)
36 if err != nil {
37 return nil, fmt.Errorf("get usage summary: %w", err)
38 }
39 
40 return &UsageSummary{
41 MetricID: metricID,
42 Total: total,
43 PeriodStart: periodStart,
44 PeriodEnd: periodEnd,
45 }, nil
46}
47 
48func (s *Service) CheckUsageLimit(
49 ctx context.Context,
50 subscriptionID string,
51 metricID string,
52 limit int64,
53) (bool, int64, error) {
54 sub, err := s.store.GetSubscription(ctx, subscriptionID)
55 if err != nil {
56 return false, 0, err
57 }
58 
59 current, err := s.store.GetUsageSummary(
60 ctx, subscriptionID, metricID,
61 sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
62 )
63 if err != nil {
64 return false, 0, err
65 }
66 
67 return current < limit, current, nil
68}
69 

HTTP Handlers

go
1package handler
2 
3import (
4 "encoding/json"
5 "net/http"
6 
7 "yourapp/internal/billing"
8)
9 
10type BillingHandler struct {
11 service *billing.Service
12}
13 
14func (h *BillingHandler) GetSubscription(w http.ResponseWriter, r *http.Request) {
15 customerID := r.Context().Value("user_id").(string)
16 
17 sub, err := h.service.GetSubscriptionByCustomer(r.Context(), customerID)
18 if err != nil {
19 http.Error(w, "subscription not found", http.StatusNotFound)
20 return
21 }
22 
23 json.NewEncoder(w).Encode(sub)
24}
25 
26func (h *BillingHandler) ChangePlan(w http.ResponseWriter, r *http.Request) {
27 customerID := r.Context().Value("user_id").(string)
28 var req struct {
29 PlanSlug string `json:"plan_slug"`
30 }
31 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
32 http.Error(w, "invalid request", http.StatusBadRequest)
33 return
34 }
35 
36 sub, err := h.service.GetSubscriptionByCustomer(r.Context(), customerID)
37 if err != nil {
38 http.Error(w, "subscription not found", http.StatusNotFound)
39 return
40 }
41 
42 updated, proration, err := h.service.ChangePlan(r.Context(), sub.ID, req.PlanSlug)
43 if err != nil {
44 http.Error(w, err.Error(), http.StatusInternalServerError)
45 return
46 }
47 
48 json.NewEncoder(w).Encode(map[string]interface{}{
49 "subscription": updated,
50 "proration": proration,
51 })
52}
53 
54func (h *BillingHandler) UpdateSeats(w http.ResponseWriter, r *http.Request) {
55 customerID := r.Context().Value("user_id").(string)
56 var req struct {
57 Quantity int `json:"quantity"`
58 }
59 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
60 http.Error(w, "invalid request", http.StatusBadRequest)
61 return
62 }
63 
64 sub, err := h.service.GetSubscriptionByCustomer(r.Context(), customerID)
65 if err != nil {
66 http.Error(w, "subscription not found", http.StatusNotFound)
67 return
68 }
69 
70 updated, err := h.service.UpdateQuantity(r.Context(), sub.ID, req.Quantity)
71 if err != nil {
72 http.Error(w, err.Error(), http.StatusInternalServerError)
73 return
74 }
75 
76 json.NewEncoder(w).Encode(updated)
77}
78 

Conclusion

Go's strengths in billing systems are not about concurrency — they are about reliability. The explicit error handling forces you to think about every failure mode at the point it occurs. Stripe API timeouts, webhook signature failures, and database write conflicts are handled at the call site, not buried in exception handlers three stack frames away.

The Store interface enables clean testing: swap in an in-memory implementation for unit tests and a PostgreSQL implementation for integration tests. The billing service itself contains zero database-specific code, making it straightforward to test business logic (proration calculation, plan changes, usage limits) in isolation.

For production deployments, run the webhook handler behind a reverse proxy that buffers request bodies (Stripe webhooks can be large) and add Prometheus metrics for webhook processing latency, subscription state transitions, and usage metering throughput. The structured logging with slog gives you queryable logs for debugging billing issues without adding a logging framework dependency.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026