Usage-Based Billing Basics

The essential playbook for implementing usage-based billing basics in your SaaS.

Usage-based billing charges customers based on actual consumption instead of a fixed monthly amount. For small SaaS products, the hard parts are defining billable units, recording usage reliably, syncing that usage to your billing provider, and handling limits, retries, and delayed events without overcharging or undercharging users.

This page covers a practical Stripe-first implementation for metered billing in a small SaaS.

Quick Fix / Quick Setup

Start with one billable metric, one aggregation window, and server-side usage reporting only. Do not calculate billable usage from frontend events. Store every usage event in your database before sending it to Stripe so you can replay safely.

python
# Example: report a usage event to Stripe from a backend worker
# Python
import stripe
import os

stripe.api_key = os.environ['STRIPE_SECRET_KEY']

# Use your own internal identifiers and idempotency keys
customer_id = 'cus_123'
subscription_item_id = 'si_123'
usage_quantity = 25
idempotency_key = 'usage:team_42:api_calls:2026-04-20T10'

stripe.subscription_items.create_usage_record(
    subscription_item_id,
    quantity=usage_quantity,
    timestamp='now',
    action='increment',
    idempotency_key=idempotency_key,
)

print('usage reported')

Minimum safe setup:

  • Define exactly one usage metric first, for example: API requests, generated reports, or GB stored.
  • Decide when usage is recorded: on request completion, job success, file upload completion, or daily aggregation.
  • Create a product and metered price in Stripe.
  • Store usage events in your database with: account_id, metric, quantity, event_time, source, and idempotency_key.
  • Run billing submission from a backend worker or cron job, not from the browser.
  • Add a usage dashboard in your app so customers can see current consumption before the invoice closes.
  • Set soft limits or alerts before users hit a cost spike.

What’s happening

Usage-based billing means the invoice depends on measured consumption such as:

  • API calls
  • seats
  • storage GB
  • emails sent
  • processing minutes

Stripe does not know your app usage automatically. Your app must define, collect, aggregate, and submit usage data.

Core constraints:

  • your internal usage log must be correct
  • your customer-facing usage dashboard must match internal totals
  • your Stripe invoice line items must match what you believe should be charged

Most billing bugs come from:

  • duplicate events
  • delayed event delivery
  • missing idempotency keys
  • timezone mistakes
  • unclear metric definitions
  • usage sent to the wrong subscription item
  • worker retry logic that replays without deduplication

For MVPs, keep the model simple:

  • one metered feature
  • monthly billing period
  • clear unit price
  • immutable internal audit logs

Step-by-step implementation

1. Choose a billable unit

It must be easy to count, explain, and audit.

Good examples:

  • successful API calls
  • processed images
  • seats above included quota
  • storage GB-month

Avoid vague units like:

  • “activity”
  • “engagement”
  • “operations” without a strict definition

Rule: if support cannot explain exactly why a customer was charged, the unit is too vague.

2. Define aggregation rules

Decide whether usage is sent:

  • per event
  • hourly
  • daily

For small SaaS teams, hourly or daily aggregation is usually simpler and safer.

Example rule:

  • raw event written on every successful action
  • hourly job sums unsent usage by account_id + metric_name + billing_window
  • worker sends one aggregated increment to Stripe

This reduces:

  • provider writes
  • retry noise
  • duplicate reporting risk

3. Choose a billing model

Common patterns:

  • base subscription plus overage
  • pure metered billing
  • tiered volume pricing
  • hybrid seat + usage

For early production systems, use:

  • fixed monthly subscription
  • included quota
  • overage per unit above quota

This is easier to explain and debug than pure metered billing.

4. Configure Stripe products and prices

Create:

  • a product
  • a recurring price
  • a metered usage component where supported by your Stripe setup

Check that the customer subscription contains the correct metered subscription_item_id. Most implementation mistakes happen because usage is reported to the wrong item.

Related setup guide: Stripe Subscription Setup (Step-by-Step)

5. Add an internal usage_events table

Use immutable raw events.

Suggested schema:

sql
CREATE TABLE usage_events (
  id BIGSERIAL PRIMARY KEY,
  account_id BIGINT NOT NULL,
  metric_name TEXT NOT NULL,
  quantity NUMERIC(18,6) NOT NULL CHECK (quantity >= 0),
  unit TEXT NOT NULL,
  source_type TEXT NOT NULL,
  source_id TEXT NOT NULL,
  occurred_at_utc TIMESTAMPTZ NOT NULL,
  billing_period_start_utc TIMESTAMPTZ,
  billing_period_end_utc TIMESTAMPTZ,
  idempotency_key TEXT NOT NULL UNIQUE,
  sync_status TEXT NOT NULL DEFAULT 'pending',
  synced_at TIMESTAMPTZ,
  provider_event_id TEXT,
  provider_subscription_item_id TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_usage_events_account_metric_time
  ON usage_events(account_id, metric_name, occurred_at_utc);

CREATE INDEX idx_usage_events_sync_status
  ON usage_events(sync_status);

Recommended statuses:

  • pending
  • syncing
  • synced
  • failed
  • adjusted

Do not silently update historical raw usage rows. If you need a correction, add an adjustment event.

6. Record usage only from trusted backend actions

Do not record billable usage from frontend JavaScript.

Good sources:

  • job completed successfully
  • API request passed authorization and completed
  • file upload finalized
  • report generated and persisted

Example backend write:

python
def record_usage_event(db, account_id, metric_name, quantity, source_type, source_id, occurred_at, unit="count"):
    idempotency_key = f"{account_id}:{metric_name}:{source_type}:{source_id}"

    db.execute("""
        INSERT INTO usage_events (
            account_id,
            metric_name,
            quantity,
            unit,
            source_type,
            source_id,
            occurred_at_utc,
            idempotency_key,
            sync_status
        )
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending')
        ON CONFLICT (idempotency_key) DO NOTHING
    """, (
        account_id,
        metric_name,
        quantity,
        unit,
        source_type,
        source_id,
        occurred_at,
        idempotency_key
    ))

7. Build an async billing sync worker

The worker should:

  1. read unsynced rows
  2. aggregate by account + metric + billing window
  3. look up the correct Stripe subscription item
  4. send usage with idempotency keys
  5. persist provider response details
  6. mark rows as synced only after success

Example worker pattern:

python
import os
import stripe
from collections import defaultdict

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]

def sync_usage_batch(db, rows):
    grouped = defaultdict(int)

    for row in rows:
        key = (
            row["account_id"],
            row["provider_subscription_item_id"],
            row["metric_name"],
            row["occurred_hour"],
        )
        grouped[key] += int(row["quantity"])

    for (account_id, subscription_item_id, metric_name, occurred_hour), quantity in grouped.items():
        idempotency_key = f"usage:{account_id}:{metric_name}:{occurred_hour.isoformat()}"

        stripe.subscription_items.create_usage_record(
            subscription_item_id,
            quantity=quantity,
            timestamp=int(occurred_hour.timestamp()),
            action="increment",
            idempotency_key=idempotency_key,
        )

        db.execute("""
            UPDATE usage_events
            SET sync_status = 'synced',
                synced_at = NOW()
            WHERE account_id = %s
              AND metric_name = %s
              AND date_trunc('hour', occurred_at_utc) = %s
              AND sync_status IN ('pending', 'syncing')
        """, (account_id, metric_name, occurred_hour))

Important: do not mark rows as synced before the provider call succeeds.

8. Use idempotency keys everywhere

Every provider write needs an idempotency key.

Example format:

text
usage:{account_id}:{metric_name}:{billing_window_start_iso}

If a worker retries after:

  • timeout
  • HTTP 500
  • process restart
  • queue retry

the same idempotency key prevents duplicate charges.

9. Handle billing period boundaries carefully

Use UTC everywhere.

Store:

  • occurred_at_utc
  • billing window start/end in UTC
  • invoice period start/end in UTC

Do not derive billing boundaries in browser time.

Decide a policy for late-arriving events:

  • count in original service period
  • count in next open billing period

Pick one rule and document it.

10. Expose current usage to users

In-app visibility reduces disputes.

Show:

  • included amount
  • current usage
  • projected overage
  • reset date
  • unit price

Use a derived table for dashboard reads.

Example daily aggregate table:

sql
CREATE TABLE account_usage_daily (
  account_id BIGINT NOT NULL,
  metric_name TEXT NOT NULL,
  usage_date DATE NOT NULL,
  quantity NUMERIC(18,6) NOT NULL,
  PRIMARY KEY (account_id, metric_name, usage_date)
);

11. Add alerts for abnormal spikes

Detect:

  • sudden increase in event count
  • failed sync backlog
  • invoice estimate spikes
  • account abuse

Example SQL check:

sql
SELECT account_id, metric_name, SUM(quantity) AS qty_24h
FROM usage_events
WHERE occurred_at_utc >= NOW() - INTERVAL '24 hours'
GROUP BY 1,2
ORDER BY qty_24h DESC
LIMIT 20;

12. Reconcile before invoice finalization

Compare:

  • raw event count
  • aggregated quantity sent
  • Stripe invoice line item quantity

If these do not match, do not trust billing.

Use webhooks to monitor invoice lifecycle and subscription state. Related guide: Handling Webhooks Correctly

13. Define behavior for unpaid invoices and limits

Pick a policy:

  • allow grace period
  • soft throttle
  • hard block new billable usage
  • block only expensive operations

This must align with subscription state handling. Related guide: Managing Subscription States

14. Test edge cases before production

Required test cases:

  • duplicate submissions
  • worker retry after timeout
  • failed jobs that should not count usage
  • plan changes mid-cycle
  • subscription cancellation mid-cycle
  • wrong subscription item mapping
  • late-arriving events
  • invoice preview mismatch
  • unpaid invoice behavior

Common causes

  • Usage events are counted on the frontend instead of the backend.
  • Duplicate usage submissions due to missing idempotency keys.
  • Usage is sent to the wrong Stripe subscription item or price.
  • Billing periods are calculated in local time instead of UTC.
  • Background workers fail, so recorded usage never reaches Stripe.
  • Plan changes mid-cycle are not reflected in usage or pricing logic.
  • Developers mutate historical usage rows instead of creating adjustment events.
  • Invoice totals differ because internal aggregation logic does not match provider expectations.
  • Webhooks are ignored, so subscription state and billing state drift apart.
  • Retries after timeouts create duplicate charges because sync status is not persisted.

Debugging tips

Check application state before blaming Stripe.

Validate worker and app health

bash
curl -s http://localhost:8000/health
ps aux | grep -E 'celery|rq|worker'
journalctl -u your-app.service -n 200 --no-pager
journalctl -u your-worker.service -n 200 --no-pager
grep -R "usage\|stripe" /var/log/nginx /var/log 2>/dev/null | tail -n 100

Inspect local usage data

bash
python -c "from app.models import UsageEvent; print('verify usage table access in app context')"

psql "$DATABASE_URL" -c "
SELECT metric_name, sync_status, COUNT(*), SUM(quantity)
FROM usage_events
GROUP BY 1,2
ORDER BY 1,2;
"

psql "$DATABASE_URL" -c "
SELECT id, account_id, metric_name, quantity, occurred_at_utc, idempotency_key, sync_status
FROM usage_events
ORDER BY occurred_at_utc DESC
LIMIT 20;
"

Inspect Stripe-side behavior

bash
stripe logs tail
stripe trigger invoice.upcoming
stripe trigger customer.subscription.updated

What to compare

Compare these three numbers for the same account and billing window:

  • raw event count or quantity in usage_events
  • aggregated quantity sent by the worker
  • invoice line item quantity in Stripe

If they differ:

  1. verify the correct subscription_item_id
  2. verify UTC timestamps
  3. verify duplicate or missing idempotency keys
  4. verify the worker did not mark rows synced before provider success
  5. verify plan changes did not remap usage to another price

Useful implementation rules

  • store raw usage first
  • aggregate after storage
  • send from backend only
  • make worker retries safe
  • never edit historical raw events silently
  • append adjustments for corrections
app action
usage_events
sync worker
Stripe usage record
invoice preview
reconciliation

Process Flow

Checklist

  • One clear billable metric is defined and documented.
  • Usage is recorded on the backend only.
  • Every usage event has an idempotency key.
  • Raw usage events are stored before provider submission.
  • A retry-safe worker syncs usage to Stripe.
  • UTC is used for all billing timestamps.
  • Users can view current usage in-app.
  • Invoice preview or reconciliation checks exist.
  • Abnormal usage alerts are configured.
  • Plan changes and cancellations are tested mid-cycle.
  • Webhooks update subscription and invoice state.
  • The correct metered subscription item is stored per account.
  • Adjustment events are used instead of mutating history.
  • Support can explain how a charge was calculated.
  • Production billing rollout is tested in a Stripe test account first.

Related guides

FAQ

What is the safest first version of usage-based billing?

Use a base subscription with one metered overage metric, record all usage internally, and batch-sync from a backend worker with idempotency keys.

Should I block users immediately when they exceed usage?

Usually use soft limits first. Hard limits are appropriate only when overages create real infrastructure cost or abuse risk.

How often should I sync usage to Stripe?

Hourly or daily batching is enough for many small SaaS products. Real-time sync is usually unnecessary unless customers need immediate invoice visibility.

How do I handle corrections?

Do not edit historical raw events. Append adjustment events and make the correction path auditable.

Can I implement usage-based billing without webhooks?

You can submit usage without them, but you still need webhooks to keep subscription state, invoice outcomes, and account access in sync.

Should I send every usage event to Stripe immediately?

Usually no. For most small SaaS products, aggregate usage in your database and sync in batches for lower cost and easier retries.

Should the frontend report usage directly?

No. Frontend usage can be manipulated or duplicated. Record billable events on trusted backend paths only.

How do I prevent double charging?

Store raw events, use unique idempotency keys, and make your sync worker retry-safe.

What if a job fails after usage was recorded?

Record usage only after the billable action truly succeeds, or write compensating adjustment events.

Can I combine seats and usage billing?

Yes. This is common for SaaS products with a fixed subscription plus metered resource consumption.

Final takeaway

Usage-based billing is mostly a data integrity problem, not a pricing page problem.

Start with:

  • one metric
  • backend-only event capture
  • immutable usage logs
  • retry-safe provider sync
  • UTC billing boundaries
  • invoice reconciliation before charging real customers

If your internal usage records are trustworthy, billing, dashboards, and dispute handling become much easier.