Metered billing and usage tracking for SaaS products using Stripe. Use when: usage based billing, metered billing, usage tracking stripe, stripe metered price, usage record stripe, stripe usage report, consumption billing, pay as you go billing, stripe meter, usage aggregation, billing meter stripe, usage cap, usage alert, usage overage, stripe usage record create, metered subscription item, usage based pricing, API call billing, seat usage billing, data usage billing, storage usage billing, usage reset, usage rollup, usage reporting stripe, stripe billing meter, event based billing, tiered usage pricing.
Invoke this skill when you need to:
| Feature | Stripe Meters (new, recommended) | Legacy Usage Records |
|---|---|---|
| API style | Event-based (stripe meters resource) | Record per subscription item |
| Accuracy | High (deduplication support) | Manual deduplication needed |
| Real-time visible | Yes (in Dashboard) |
| Limited |
| Availability | GA as of 2024 | Deprecated path |
| Deduplication | Idempotency key per event | Manual |
Use Stripe Meters for new implementations.
Create a Meter (once, in setup code or Stripe Dashboard):
import "github.com/stripe/stripe-go/v76/billing/meter"
// One-time: create the meter
m, err := meter.New(&stripe.BillingMeterParams{
DisplayName: stripe.String("API Calls"),
EventName: stripe.String("api_call"), // Must match event_name when reporting
DefaultAggregation: &stripe.BillingMeterDefaultAggregationParams{
Formula: stripe.String("sum"), // sum | count | max | last_during_period
},
CustomerMapping: &stripe.BillingMeterCustomerMappingParams{
EventPayloadKey: stripe.String("stripe_customer_id"),
Type: stripe.String("by_id"),
},
ValueSettings: &stripe.BillingMeterValueSettingsParams{
EventPayloadKey: stripe.String("value"), // field in event payload carrying usage quantity
},
})
Price using the Meter:
// Create a metered Price linked to the Meter ID
p, err := price.New(&stripe.PriceParams{
Product: stripe.String(productID),
Currency: stripe.String("usd"),
Recurring: &stripe.PriceRecurringParams{
Interval: stripe.String("month"),
UsageType: stripe.String("metered"),
Meter: stripe.String(m.ID),
},
BillingScheme: stripe.String("per_unit"),
UnitAmount: stripe.Int64(1), // $0.01 per unit (1 cent per API call)
})
Report usage to Stripe as billing events occur:
import "github.com/stripe/stripe-go/v76/billing/meterevent"
func (u *UsageReporter) ReportAPICall(ctx context.Context, tenantID string, callCount int) error {
tenant, err := u.tenantRepo.Get(ctx, tenantID)
if err != nil {
return err
}
// Idempotency: use a deterministic key for deduplication
idempotencyKey := fmt.Sprintf("api_call_%s_%d", tenantID, time.Now().Truncate(time.Minute).Unix())
_, err = meterevent.New(&stripe.BillingMeterEventParams{
EventName: stripe.String("api_call"),
Payload: map[string]string{
"stripe_customer_id": tenant.StripeCustomerID,
"value": strconv.Itoa(callCount),
},
Identifier: stripe.String(idempotencyKey), // deduplication key
})
if err != nil {
return fmt.Errorf("report usage to stripe: %w", err)
}
return nil
}
Batching strategy — report usage in batches, not per-request:
// Buffer usage increments in Redis or local counter
// Flush every minute via a background job
func (j *UsageFlushJob) Run(ctx context.Context) error {
buckets, err := j.usageBuffer.DrainAll(ctx)
if err != nil {
return err
}
for tenantID, count := range buckets {
if err := j.reporter.ReportAPICall(ctx, tenantID, count); err != nil {
slog.Error("usage flush failed",
slog.String("tenant_id", tenantID),
slog.Int("count", count),
slog.String("err", err.Error()),
)
// Re-enqueue on failure — don't lose usage
j.usageBuffer.Add(ctx, tenantID, count)
}
}
return nil
}
Track usage in your own database — Stripe is the billing source of truth but too slow for real-time limit checks:
-- Usage accumulation per billing period
CREATE TABLE tenant_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
metric TEXT NOT NULL, -- 'api_calls', 'storage_gb', 'seats'
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
quantity BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, metric, period_start)
);
CREATE INDEX idx_tenant_usage_tenant_period ON tenant_usage(tenant_id, metric, period_start);
Atomic usage increment (PostgreSQL UPSERT):
INSERT INTO tenant_usage (tenant_id, metric, period_start, period_end, quantity)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, metric, period_start)
DO UPDATE SET
quantity = tenant_usage.quantity + EXCLUDED.quantity,
updated_at = NOW();
Enforce hard caps and notify customers approaching their limit:
func (u *UsageService) CheckAndEnforce(ctx context.Context, tenantID, metric string, increment int) error {
plan, err := u.planRepo.GetForTenant(ctx, tenantID)
if err != nil {
return err
}
limit := plan.GetLimit(metric) // e.g., 10_000 API calls/month; -1 = unlimited
if limit < 0 {
return nil // Unlimited plan
}
current, err := u.usageRepo.GetCurrentPeriod(ctx, tenantID, metric)
if err != nil {
return err
}
// Hard cap enforcement
if current+int64(increment) > int64(limit) {
return &UsageLimitExceededError{
Metric: metric,
Current: current,
Limit: int64(limit),
}
}
// Soft alert: 80% threshold
pct := float64(current+int64(increment)) / float64(limit)
if pct >= 0.80 && pct-float64(increment)/float64(limit) < 0.80 {
u.notifier.SendUsageAlert(ctx, tenantID, metric, pct)
}
return nil
}
Reset local usage counters when the billing period renews (driven by invoice.paid):
func (j *UsageResetJob) HandleRenewal(ctx context.Context, tenantID string, newPeriodStart, newPeriodEnd time.Time) error {
metrics := []string{"api_calls", "storage_gb", "exports"}
return j.db.WithTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
for _, metric := range metrics {
if err := j.usageRepo.ResetPeriodTx(ctx, tx, tenantID, metric, newPeriodStart, newPeriodEnd); err != nil {
return err
}
}
return nil
})
}
formula (sum/count/max) for the metric typeIdentifier for deduplicationtenant_usage table tracks usage atomically via UPSERT — no lost incrementsinvoice.paid fires at the start of the new billing period