Per-Call Billing with Stripe: Usage Records, Metered Subscriptions, and API Key Management
Per-call billing sounds simple: count API calls, charge for them. In practice, it requires a billing architecture that stays in sync across your key store, usage counters, and Stripe's metered subscription system. This post covers the implementation in enough detail to get you past the sharp edges.
The billing model: metered subscriptions
Stripe has three billing primitives relevant to APIs: - Flat-rate subscriptions: Fixed monthly fee. Wrong for per-call. - Metered subscriptions: Report usage each billing period, Stripe charges based on the total. Right. - Payment Links / one-off charges: For top-ups. Useful as a supplement.
For an API with per-call pricing, you want metered subscriptions with a usage-based price. Concretely: a subscription item with billing_scheme: per_unit and aggregate_usage: sum. Each call gets reported as a usage record. Stripe sums the records and charges at the end of the billing period.
The setup: products, prices, and subscriptions
Create a product:
product = stripe.Product.create(name="Screenshot API — Pay As You Go")
Create a metered price:
price = stripe.Price.create(
product=product.id,
unit_amount=1, # $0.001 per call (amount in cents * 1000 = units)
currency="usd",
recurring={
"interval": "month",
"usage_type": "metered",
"aggregate_usage": "sum",
},
)
Note: Stripe's minimum currency unit is 1 cent. For sub-cent pricing ($0.001/call), use a multiplier. Common pattern: set unit_amount=1 (1 cent) and transform_quantity: {"divide_by": 100, "round": "up"} — so 100 calls = 1 cent billed, effectively $0.0001/call. Adjust the divisor to hit your target price.
Create a subscription when a user upgrades:
subscription = stripe.Subscription.create(
customer=customer_id,
items=[{"price": METERED_PRICE_ID}],
)
subscription_item_id = subscription["items"]["data"][0]["id"]
Store subscription_item_id against the user's API key. You'll need it to report usage.
Reporting usage
Every API call that comes in with a paid key needs a usage record:
stripe.SubscriptionItem.create_usage_record(
subscription_item_id,
quantity=1,
timestamp=int(time.time()),
action="increment",
)
Warning: don't do this synchronously in your API handler. The Stripe API call adds ~100-300ms of latency. Queue usage records and flush them in batch. A background worker that flushes every 60 seconds is sufficient; Stripe accepts backdated timestamps within the current billing period.
Batch reporting pattern:
# In your API handler: queue the record
redis.rpush("usage_queue", json.dumps({
"subscription_item_id": sub_item_id,
"timestamp": int(time.time()),
}))
# In a background worker (runs every 60s):
records = redis.lrange("usage_queue", 0, -1)
redis.delete("usage_queue")
for record in records:
r = json.loads(record)
stripe.SubscriptionItem.create_usage_record(
r["subscription_item_id"],
quantity=1,
timestamp=r["timestamp"],
action="increment",
)
This keeps your API response time clean and makes usage reporting resilient to transient Stripe outages.
Key management: linking keys to subscriptions
Your key store needs to know which keys are on which tier. At minimum:
api_keys table:
key_hash TEXT PRIMARY KEY -- bcrypt/SHA256 of the raw key
email TEXT
tier TEXT -- 'free' | 'paid'
rate_limit INTEGER -- calls per day
stripe_customer_id TEXT
stripe_sub_item_id TEXT -- needed for usage reporting
created_at TIMESTAMP
is_active BOOLEAN
When a user upgrades:
1. Create or retrieve Stripe customer
2. Create metered subscription
3. Update their key row: tier='paid', rate_limit=NULL (or high limit), stripe_sub_item_id=<id>
When checking rate limits on each API call:
1. Hash the incoming key, look it up
2. If tier='free': check daily counter against limit
3. If tier='paid': skip rate-limit check, queue usage record
Handling billing failures
Stripe sends invoice.payment_failed when a charge doesn't go through. Handle it:
@app.route("/stripe/webhook", methods=["POST"])
def stripe_webhook():
event = stripe.Webhook.construct_event(
request.data, request.headers["Stripe-Signature"], WEBHOOK_SECRET
)
if event["type"] == "invoice.payment_failed":
customer_id = event["data"]["object"]["customer"]
# Downgrade all keys for this customer back to free tier
db.execute(
"UPDATE api_keys SET tier='free', rate_limit=100 WHERE stripe_customer_id=?",
[customer_id]
)
elif event["type"] == "customer.subscription.deleted":
# Subscription cancelled — same downgrade logic
...
return "", 200
Don't hard-delete keys on payment failure — just downgrade them. The user still has the key; when they update their payment method and the invoice clears, re-upgrade them via invoice.paid.
The free-to-paid transition UX
The worst implementation: redirect users to a full checkout page, require them to create an account first, then return them to the original flow. The best implementation: collect card details inline with Stripe Elements, confirm the subscription, activate the key, and return a single success message. No redirects, no new accounts.
For API consumers specifically (developers integrating your API into their own products), the friction of a full checkout page is a real conversion killer. These are technical users who will abandon any flow that feels like a consumer checkout. Keep it as close to a single form field as possible.
The target UX for a B2A API upgrade:
1. API call returns 429 with upgrade_url
2. Developer visits upgrade_url with their existing API key pre-filled as a URL parameter
3. They enter card details + confirm
4. Key is upgraded immediately — no new key, no re-integration
5. Confirmation page shows new rate limits and billing schedule
This last point matters: re-integration is a real cost for a developer who has already embedded a key in their application or shared it with a customer. Upgrading the existing key in place (not issuing a new one) removes the re-integration barrier entirely.
Edge cases that will actually break you
Clock skew on usage records: Stripe rejects usage records more than 1 hour in the future or more than 30 days in the past. If your server time drifts, records get silently dropped. Use NTP. Check ntpq -p if you ever see unexplained usage discrepancies.
Idempotency on usage records: Network errors can cause you to retry a usage report that already succeeded. The idempotency_key parameter on create_usage_record prevents double-billing. Set it to a hash of (subscription_item_id, call_timestamp, call_id).
Trial period mismatch: If you offer a free trial on the paid tier (e.g., 1000 free calls before billing starts), track this in your key store, not in Stripe trial periods. Stripe trial periods are date-based, not usage-based. When the trial ends, Stripe starts billing regardless of usage — which is the wrong model for a per-call API.
Subscription item rotation on plan changes: If a user upgrades from a $0.001/call plan to a $0.0005/call plan, don't cancel and recreate the subscription. Use stripe.SubscriptionItem.modify to change the price on the existing item. Cancelling and recreating triggers a prorated invoice that confuses users.
The core of per-call billing is simple: count calls, report usage, let Stripe handle the math. The complexity is in the edge cases — especially the failure modes and the key management transitions. Getting these right is what separates a billing system that works at launch from one that generates support tickets every billing cycle.
Next in this arc: handling B2A-specific billing challenges — agents that don't monitor their own spending, budget controls for automated consumers, and what to do when an agent runs up a large bill.