Usage-based billing is the dominant pricing model for AI and API-driven SaaS products. Instead of flat monthly fees, customers pay for what they consume—API calls, tokens processed, documents indexed, or compute hours used. Stripe’s Billing API provides the infrastructure to implement this model, but integrating it correctly requires careful design around usage metering, event aggregation, subscription lifecycle management, and webhook-driven state synchronization.
The core challenge is bridging the gap between your application’s real-time usage tracking and Stripe’s billing cycle. Your system sees usage events in real time (every API call, every document processed), but Stripe bills on a periodic cycle (monthly, annually). You need a metering layer that aggregates high-frequency usage events into billing-compatible records, handles clock skew and late-arriving events, and ensures no usage is double-counted or lost.
As a concrete example, consider an AI platform that implements this pattern for a service charging per-query and per-document-indexed. It uses Stripe’s billing meters with 15-minute bucketed UPSERT windows, daily batch reporting as a safety net, and webhook-driven subscription lifecycle management that keeps the local billing state synchronized with Stripe’s authoritative state.
Stripe Billing Meters are the API primitive for reporting usage. A meter defines a usage type (e.g., “api_calls”, “tokens_processed”) and an aggregation mode (sum, max, last). Your application reports usage events to the meter, and Stripe aggregates them into the customer’s invoice at the end of each billing period. Meters handle deduplication via idempotency keys and support both real-time and batch reporting.
Instead of reporting every individual usage event to Stripe (which would generate millions of API calls per day), the bucketed UPSERT pattern aggregates usage into fixed time windows (typically 15 minutes). Within each bucket, usage is summed locally and reported to Stripe as a single meter event with an idempotency key derived from the customer ID, meter ID, and bucket timestamp. If the same bucket is reported twice (due to retries), the UPSERT semantics ensure the value is replaced, not doubled.
Stripe communicates subscription state changes—creation, renewal, cancellation, payment failure, trial expiration—via webhooks. Your application must handle these events to keep its local subscription state in sync with Stripe. The webhook handler is the source of truth for “is this customer allowed to use the product?” because Stripe is the authoritative billing system. Missing or mishandling a webhook can lead to customers being billed without access or having access without being billed.
Even with real-time metering, a daily batch job reconciles local usage records against what Stripe has received. This catches metering gaps caused by network failures, application crashes, or bugs in the real-time path. The batch job queries the local usage database for the previous day, compares totals against Stripe’s meter event summaries, and reports any discrepancies. It also serves as a fallback reporter: if real-time metering fails, the daily batch ensures usage is still reported before the billing cycle closes.
Every usage report to Stripe must include an idempotency key that uniquely identifies the event. This key is typically a hash of (customer_id, meter_id, bucket_start_time). If the same key is sent twice, Stripe ignores the duplicate. This property is critical for exactly-once billing semantics: retries after network timeouts won’t double-charge customers, and crash-recovery can safely re-report the last bucket without risk of over-billing.
The integration has three main components: a usage collector that aggregates events into buckets, a meter reporter that sends buckets to Stripe, and a webhook handler that manages subscription state.
package billing
import (
"crypto/sha256"
"fmt"
"sync"
"time"
)
const BucketDuration = 15 * time.Minute
type UsageBucket struct {
CustomerID string
MeterID string
BucketStart time.Time
Value int64
Reported bool
}
func (b *UsageBucket) IdempotencyKey() string {
h := sha256.Sum256([]byte(fmt.Sprintf(
"%s:%s:%d", b.CustomerID, b.MeterID, b.BucketStart.Unix())))
return fmt.Sprintf("%x", h[:16])
}
type UsageCollector struct {
mu sync.Mutex
buckets map[string]*UsageBucket
}
func NewUsageCollector() *UsageCollector {
return &UsageCollector{
buckets: make(map[string]*UsageBucket),
}
}
func (uc *UsageCollector) Record(customerID, meterID string, value int64) {
now := time.Now().UTC()
bucketStart := now.Truncate(BucketDuration)
key := fmt.Sprintf("%s:%s:%d", customerID, meterID, bucketStart.Unix())
uc.mu.Lock()
defer uc.mu.Unlock()
bucket, ok := uc.buckets[key]
if !ok {
bucket = &UsageBucket{
CustomerID: customerID,
MeterID: meterID,
BucketStart: bucketStart,
}
uc.buckets[key] = bucket
}
bucket.Value += value
}
func (uc *UsageCollector) FlushClosedBuckets() []*UsageBucket {
now := time.Now().UTC()
currentBucket := now.Truncate(BucketDuration)
uc.mu.Lock()
defer uc.mu.Unlock()
var ready []*UsageBucket
for key, bucket := range uc.buckets {
if bucket.BucketStart.Before(currentBucket) && !bucket.Reported {
ready = append(ready, bucket)
bucket.Reported = true
delete(uc.buckets, key)
}
}
return ready
}
package billing
import (
"context"
"fmt"
"log"
"time"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/billing/meterevent"
)
type MeterReporter struct {
collector *UsageCollector
interval time.Duration
}
func NewMeterReporter(collector *UsageCollector) *MeterReporter {
return &MeterReporter{
collector: collector,
interval: 1 * time.Minute,
}
}
func (mr *MeterReporter) Start(ctx context.Context) {
ticker := time.NewTicker(mr.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
mr.flushAll()
return
case <-ticker.C:
mr.flushClosed()
}
}
}
func (mr *MeterReporter) flushClosed() {
buckets := mr.collector.FlushClosedBuckets()
for _, bucket := range buckets {
if err := mr.reportBucket(bucket); err != nil {
log.Printf("failed to report bucket %s: %v",
bucket.IdempotencyKey(), err)
}
}
}
func (mr *MeterReporter) reportBucket(bucket *UsageBucket) error {
params := &stripe.BillingMeterEventParams{
EventName: stripe.String(bucket.MeterID),
Payload: map[string]string{
"stripe_customer_id": bucket.CustomerID,
"value": fmt.Sprintf("%d", bucket.Value),
},
Identifier: stripe.String(bucket.IdempotencyKey()),
Timestamp: stripe.Int64(bucket.BucketStart.Unix()),
}
_, err := meterevent.New(params)
return err
}
func (mr *MeterReporter) flushAll() {
buckets := mr.collector.FlushClosedBuckets()
for _, bucket := range buckets {
_ = mr.reportBucket(bucket)
}
}
package billing
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/webhook"
)
type SubscriptionStore interface {
Activate(customerID, subscriptionID, planID string) error
Suspend(customerID string, reason string) error
Cancel(customerID string) error
}
type WebhookHandler struct {
secret string
store SubscriptionStore
}
func NewWebhookHandler(secret string, store SubscriptionStore) *WebhookHandler {
return &WebhookHandler{secret: secret, store: store}
}
func (wh *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
event, err := webhook.ConstructEvent(
body, r.Header.Get("Stripe-Signature"), wh.secret)
if err != nil {
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}
switch event.Type {
case "customer.subscription.created",
"customer.subscription.updated":
wh.handleSubscriptionChange(event)
case "customer.subscription.deleted":
wh.handleSubscriptionDeleted(event)
case "invoice.payment_failed":
wh.handlePaymentFailed(event)
}
w.WriteHeader(http.StatusOK)
}
func (wh *WebhookHandler) handleSubscriptionChange(event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return
}
switch sub.Status {
case stripe.SubscriptionStatusActive,
stripe.SubscriptionStatusTrialing:
planID := ""
if len(sub.Items.Data) > 0 {
planID = sub.Items.Data[0].Price.ID
}
wh.store.Activate(sub.Customer.ID, sub.ID, planID)
case stripe.SubscriptionStatusPastDue:
wh.store.Suspend(sub.Customer.ID, "payment_past_due")
case stripe.SubscriptionStatusCanceled:
wh.store.Cancel(sub.Customer.ID)
}
}
func (wh *WebhookHandler) handleSubscriptionDeleted(event stripe.Event) {
var sub stripe.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
return
}
wh.store.Cancel(sub.Customer.ID)
}
func (wh *WebhookHandler) handlePaymentFailed(event stripe.Event) {
var invoice stripe.Invoice
if err := json.Unmarshal(event.Data.Raw, &invoice); err != nil {
return
}
if invoice.Subscription != nil {
wh.store.Suspend(invoice.Customer.ID,
fmt.Sprintf("payment_failed: %s", invoice.ID))
}
}
In a typical AI platform project, every API query increments a usage counter. The UsageCollector aggregates these into 15-minute buckets, and the MeterReporter flushes closed buckets to Stripe every minute. This means usage appears on the customer’s Stripe dashboard within 16 minutes of the actual API call, providing near-real-time visibility into costs.
The webhook handler manages the full subscription lifecycle: when a customer signs up through the web UI, the frontend creates a Stripe Checkout session. Once payment succeeds, Stripe fires a customer.subscription.created webhook that activates the customer’s account in the local database. If a payment fails at renewal, the invoice.payment_failed webhook suspends the customer’s API access, and a follow-up customer.subscription.updated with status past_due keeps the local state synchronized.
The daily batch reconciliation job runs at 02:00 UTC, querying the local usage database for all events from the previous day and comparing against Stripe’s meter event summaries. In practice, discrepancies are rare (less than 0.01% of events), but when they occur, it’s usually due to an application crash that lost in-memory buckets before they were flushed. The batch job catches these gaps and reports the missing usage.
To prevent usage loss during crashes, write each usage event to a local write-ahead log (WAL) before aggregating it in memory. On restart, replay the WAL to reconstruct any in-memory buckets that weren’t flushed to Stripe. The WAL can be a simple append-only file or a database table. Truncate processed entries after successful Stripe reporting.
When the application receives a shutdown signal (SIGTERM), flush all open buckets to Stripe before exiting. This is the flushAll() method in the reporter. Without this, the current 15-minute bucket’s usage is lost. Set the Kubernetes terminationGracePeriodSeconds high enough to allow the flush to complete (typically 30 seconds is sufficient).
Use stripe listen --forward-to localhost:8080/webhooks during development to receive webhook events from Stripe’s test mode. Trigger events with stripe trigger customer.subscription.created. This enables end-to-end testing of the webhook handler without creating real subscriptions.
webhook.ConstructEvent signature verification in development creates a habit that sometimes leaks to production. An attacker who discovers your webhook endpoint can forge subscription events, granting themselves free access or canceling other customers. Always verify signatures, even in staging.time.Now().