Billing is the one system where bugs have immediate financial consequences. A race condition that double-charges a customer erodes trust overnight. A webhook handler that silently drops a subscription cancellation keeps billing a user who already left. Stripe provides a robust payments API, but integrating it into a production system requires careful attention to idempotency, webhook reliability, and the impedance mismatch between Stripe’s data model and your own.
The platform integrates Stripe for subscription management, usage-based billing, and payment processing using the stripe-go SDK. The integration supports multiple pricing tiers, metered billing for API calls and storage, mid-cycle plan changes (upgrades and downgrades), and automated invoice generation. Stripe webhooks drive state changes on the server side, ensuring the local database stays synchronized with Stripe’s source of truth.
This architecture follows a key principle: Stripe is the source of truth for billing state, not the local database. The local database caches billing state for fast reads and feature gating, but any mutation flows through Stripe first. Webhooks then propagate the change back to update local state. This avoids split-brain scenarios where the app thinks a subscription is active but Stripe has already canceled it.
Never update local subscription state directly. All billing mutations (create, upgrade, cancel) go through the Stripe API. Webhooks deliver the result back to the server, which updates the local database. This ensures the local database always reflects Stripe’s actual state, even when API calls fail or retries create duplicate requests.
Stripe delivers webhooks at least once, meaning your handler may receive the same event multiple times. Every webhook handler must be idempotent: processing the same event twice should produce the same result as processing it once. Store the Stripe event ID and skip processing if it’s already been handled.
For API call and storage billing, the system reports usage to Stripe via meter events rather than tracking it locally. At the end of each billing period, Stripe aggregates the reported usage, applies the pricing tiers, and generates an invoice. This offloads complex billing math (prorations, tier boundaries, overages) to Stripe.
Every incoming webhook must be verified using the signing secret. Stripe signs each webhook payload with an HMAC, and the stripe-go library provides webhook.ConstructEvent to verify the signature before processing. Skipping verification opens the door to forged billing events that could grant free access or cancel legitimate subscriptions.
Stripe’s billing model centers on three objects: a Customer (maps to your user or organization), a Subscription (the recurring billing agreement with one or more price items), and Invoices (the actual charges generated each billing cycle). Understanding this hierarchy is essential because webhook events reference these objects by ID, and your handler must know which local entity each maps to.
When a user selects a plan, the server creates a Stripe Customer (or retrieves the existing one), attaches a payment method via a SetupIntent, and creates a Subscription with the selected Price. The subscription ID and status are stored locally for fast feature-gating queries.
func (s *BillingService) CreateSubscription(ctx context.Context, orgID string, priceID string) error {
org, err := s.orgRepo.Get(ctx, orgID)
if err != nil {
return fmt.Errorf("get org: %w", err)
}
if org.StripeCustomerID == "" {
customer, err := s.stripe.Customers.New(&stripe.CustomerParams{
Email: stripe.String(org.BillingEmail),
Metadata: map[string]string{
"org_id": orgID,
},
})
if err != nil {
return fmt.Errorf("create stripe customer: %w", err)
}
org.StripeCustomerID = customer.ID
s.orgRepo.UpdateStripeCustomerID(ctx, orgID, customer.ID)
}
subParams := &stripe.SubscriptionParams{
Customer: stripe.String(org.StripeCustomerID),
Items: []*stripe.SubscriptionItemsParams{
{Price: stripe.String(priceID)},
},
PaymentBehavior: stripe.String("default_incomplete"),
}
subParams.AddExpand("latest_invoice.payment_intent")
sub, err := s.stripe.Subscriptions.New(subParams)
if err != nil {
return fmt.Errorf("create subscription: %w", err)
}
return s.billingRepo.UpsertSubscription(ctx, orgID, sub.ID, string(sub.Status))
}
The webhook endpoint verifies the signature, parses the event, and dispatches to type-specific handlers. Each handler is idempotent—it upserts state based on the Stripe object’s current fields rather than applying incremental changes.
func (s *BillingService) HandleWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, 65536))
if err != nil {
http.Error(w, "read body", http.StatusBadRequest)
return
}
event, err := webhook.ConstructEvent(payload, r.Header.Get("Stripe-Signature"), s.webhookSecret)
if err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
if s.eventRepo.AlreadyProcessed(r.Context(), event.ID) {
w.WriteHeader(http.StatusOK)
return
}
switch event.Type {
case "customer.subscription.updated", "customer.subscription.deleted":
s.handleSubscriptionChange(r.Context(), event)
case "invoice.payment_succeeded":
s.handlePaymentSucceeded(r.Context(), event)
case "invoice.payment_failed":
s.handlePaymentFailed(r.Context(), event)
}
s.eventRepo.MarkProcessed(r.Context(), event.ID)
w.WriteHeader(http.StatusOK)
}
func (s *BillingService) handleSubscriptionChange(ctx context.Context, event stripe.Event) {
var sub stripe.Subscription
json.Unmarshal(event.Data.Raw, &sub)
orgID := sub.Metadata["org_id"]
s.billingRepo.UpsertSubscription(ctx, orgID, sub.ID, string(sub.Status))
if sub.Status == stripe.SubscriptionStatusCanceled {
s.featureGate.RevokeAccess(ctx, orgID)
}
}
API calls and storage usage are reported to Stripe’s metering API in batches. A background worker aggregates usage from the local telemetry pipeline and reports it periodically. Stripe handles the billing math—applying tiered pricing, calculating overages, and generating the invoice line items.
func (s *BillingService) ReportUsage(ctx context.Context, orgID string) error {
org, err := s.orgRepo.Get(ctx, orgID)
if err != nil {
return err
}
apiCalls, err := s.metricsRepo.GetAPICallCount(ctx, orgID, s.currentBillingPeriod())
if err != nil {
return err
}
storageBytes, err := s.metricsRepo.GetStorageBytes(ctx, orgID)
if err != nil {
return err
}
params := &stripe.BillingMeterEventParams{
EventName: stripe.String("api_calls"),
Payload: map[string]string{
"value": fmt.Sprintf("%d", apiCalls),
"stripe_customer_id": org.StripeCustomerID,
},
}
_, err = s.stripe.BillingMeterEvents.New(params)
if err != nil {
return fmt.Errorf("report api_calls usage: %w", err)
}
storageGB := storageBytes / (1024 * 1024 * 1024)
storageParams := &stripe.BillingMeterEventParams{
EventName: stripe.String("storage_gb"),
Payload: map[string]string{
"value": fmt.Sprintf("%d", storageGB),
"stripe_customer_id": org.StripeCustomerID,
},
}
_, err = s.stripe.BillingMeterEvents.New(storageParams)
return err
}
The feature gate reads cached subscription state from the local database to make fast access-control decisions. It checks the subscription status and plan tier to determine what features an organization can access. This avoids calling the Stripe API on every request while staying in sync through webhooks.
func (g *FeatureGate) CanUseFeature(ctx context.Context, orgID string, feature string) bool {
sub, err := g.billingRepo.GetActiveSubscription(ctx, orgID)
if err != nil || sub == nil {
return false
}
tier := g.planTiers[sub.PriceID]
return tier.HasFeature(feature)
}
When creating subscriptions or reporting usage, include an idempotency key derived from the operation’s natural key (e.g., orgID + priceID + timestamp). Stripe deduplicates requests with the same idempotency key within 24 hours, preventing double-charges from retries or network failures.
Stripe retries failed webhooks with exponential backoff over 72 hours. Design handlers to be safe under retries: use upserts instead of inserts, check event timestamps to avoid processing stale events out of order, and always return 200 after successful processing to stop retries.
For extremely high-traffic endpoints, cache the subscription status in Redis with a short TTL (30–60 seconds). The webhook handler invalidates the cache when subscription state changes. This bounds the staleness window while keeping feature-gate checks fast.
webhook.ConstructEvent and reject invalid signatures with a 401.r.Body can only be read once. Read it into a byte slice first, then use the slice for both verification and JSON parsing.