Billing LLM Tokens Through Stripe From a Django Backend
The feature was easy to describe and ugly to bill: a document-summarisation endpoint in a client’s Django SaaS, priced per token instead of per seat. The first version did the obvious thing — call the model, read the token counts off the response, POST a meter event to Stripe, return the summary. It passed review. In production it produced an endpoint whose tail latency was dominated by an outbound HTTP call we did not need to make, a customer billed twice for one summary after a client-side retry, and a month-end reconciliation I could not win, because the only record of what we had billed lived inside Stripe.
The fix was not a better client wrapper. It was accepting that usage reporting is a durable queue with a deadline, and that most of the design decisions are made before you write any Python — in the meter config, which you cannot change afterwards.
The meter is a one-way door
A meter defines how Stripe aggregates your events over a billing period. The docs are blunt about revisions: “After you configure the meter, you can’t make any changes aside from the display name” — see the meter configuration guide. You get an aggregation formula (sum, count or last), a customer_mapping.event_payload_key naming the field that carries the customer ID, and a value_settings.event_payload_key naming the field that carries the number.
The mistake I made was one meter called tokens with a sum formula. Input tokens and output tokens are not the same unit of cost, and cached input is a third unit again: Anthropic’s Messages API reference documents cache_creation_input_tokens and cache_read_input_tokens on the usage object alongside the plain input and output counts. Collapse them into one meter and you have permanently given up the ability to price them separately — the only way back is a new meter and repointing every price at it, mid-cycle, for live subscriptions.
So: one meter per billable unit. llm_input_tokens, llm_output_tokens, llm_cached_input_tokens. Three cheap objects now against a migration you do not want later.
The second config trap is event ingestion mode. Raw is the default and treats every event as standalone. Pre-aggregated means that for a given hourly or daily window, “a newer event sent within the same hourly or daily window overwrites the previous one” (meter configuration). Pick pre-aggregated, then send incremental hourly sums, and every bucket but the last silently disappears. It is a dashboard setting, it never touches your code, and no test you write will catch it.
Why the write does not belong in the view
Two rate limits decide this. The meter event endpoint allows 1000 calls per second per account in live mode, and exactly one concurrent call per customer per meter. The per-account number is generous. The concurrency-of-one is the one that bites: a single customer firing parallel requests serialises at Stripe, inside your request/response cycle.
There is also no latency to win. Stripe “processes meter events asynchronously, so aggregated usage in meter event summaries and on upcoming invoices might not immediately reflect recently received meter events” (recording usage). Blocking the user buys you nothing but a worse p99.
transaction.on_commit() is the natural next instinct, and it is half right. A callback registered inside an atomic() block that rolls back “will be discarded, and never called” (Django transactions) — exactly what you want, since you must not bill for a request whose row never landed. But the same page notes that in autocommit mode outside an atomic() block, “the function will run immediately, not on commit”. Without ATOMIC_REQUESTS and without an explicit atomic(), your deferred send is a synchronous send again. And the callback lives in process memory: if the worker dies between commit and callback, the event is gone and nothing in your database remembers it was owed.
An outbox table you can reconcile against
import uuid
from django.db import models
class UsageEvent(models.Model):
# The Stripe meter event identifier. Generated here, never by Stripe.
identifier = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
stripe_customer_id = models.CharField(max_length=64, db_index=True)
event_name = models.CharField(max_length=64) # llm_input_tokens, ...
value = models.DecimalField(max_digits=18, decimal_places=4)
occurred_at = models.DateTimeField() # when the model call finished
sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [models.Index(fields=["sent_at", "occurred_at"])]
The identifier column is the point of the table. “Every meter event corresponds to an identifier that you can specify in your request. If you don’t specify an identifier, we auto-generate one for you” (recording usage). Generating it yourself means a replayed row is deduplicated by Stripe, and means you can address that exact event later.
Why not lean on the Idempotency-Key header instead? It works, and Stripe suggests V4 UUIDs for it — but for API v1 a key can be removed “automatically after they’re at least 24 hours old”, after which a reuse generates a new request. A backlog you drain two days after an outage is precisely that case. The meter event identifier has no such window.
The drain worker:
from django.db import transaction
from django.utils import timezone
def drain(batch_size=500):
with transaction.atomic():
rows = list(
UsageEvent.objects
.select_for_update(skip_locked=True)
.filter(sent_at__isnull=True)
.order_by("occurred_at")[:batch_size]
)
for row in rows:
stripe.billing.MeterEvent.create(
event_name=row.event_name,
identifier=str(row.identifier),
timestamp=int(row.occurred_at.timestamp()),
payload={
"stripe_customer_id": row.stripe_customer_id,
"value": str(row.value),
},
)
row.sent_at = timezone.now()
UsageEvent.objects.bulk_update(rows, ["sent_at"])
skip_locked=True means rows locked by another transaction are “silently excluded”, so a second worker picks up different rows (QuerySet reference). Two caveats from the same page are worth pinning to the wall: evaluating select_for_update() outside a transaction raises TransactionManagementError, and on an unsupported backend such as SQLite “the call has no effect” and raises nothing. A local suite on SQLite will happily tell you the locking works.
Pre-aggregate, because Stripe tells you to
One event per model call is the wrong granularity. Stripe’s own guidance is to “accumulate usage across multiple actions and send a single aggregated event periodically”, or to use the high-throughput path — API v2 meter event streams take up to 10,000 events per second, live mode only, behind a session token valid for 15 minutes. Almost no mid-size SaaS needs the streaming endpoint. It needs a GROUP BY.
from django.db.models import Sum
from django.db.models.functions import TruncHour
buckets = (
UsageEvent.objects.filter(sent_at__isnull=True)
.annotate(hour=TruncHour("occurred_at"))
.values("stripe_customer_id", "event_name", "hour")
.annotate(total=Sum("value"))
)
Resist the urge to tag those events with anything high-cardinality. Stripe accepts up to 10,000 unique dimension combinations per meter per hour, and 100 per customer per meter across all events; events that introduce a new combination past the limit are rejected with meter_event_dimension_count_too_high. Document IDs and request IDs as dimensions will cross that line on a busy afternoon. Model name, maybe environment — nothing with an unbounded domain.
The window where usage silently stops counting
A meter event timestamp must be within the past 35 calendar days and not more than 5 minutes in the future, the future allowance being for clock drift. That is the acceptance window, and it is much wider than the window that matters. From the same page: Stripe “might accept an event after finalization and include it in meter summaries without updating the finalized invoice or adding the usage to the next invoice.” Accepted and billed are different outcomes, and only one of them pays you.
The real deadline is invoice finalization. “All invoices have a default finalization grace period of 1 hour”, configurable up to 72 hours. Your drain latency has to sit comfortably inside that, which makes the useful alert not “the queue is deep” but “the oldest unsent row is older than ten minutes”.
Then there is the trap that cost my client a real invoice line. If a subscription item’s price changes mid-cycle, “the grace period doesn’t capture late usage” — Stripe skips invoice recomputation during finalization, so usage reported during the grace period “doesn’t appear on current or subsequent invoices” (grace period docs). Any plan-upgrade flow therefore has to flush that customer’s outbox before it touches the subscription item, not after.
Fixing it when it is already wrong
Incorrect events are cancelled with a Meter Event Adjustment, addressed by identifier — and you can only cancel events sent to Stripe within the last 24 hours. That is the third reason to own the identifier rather than let Stripe mint it: without the value in your own database, you cannot name the event you want cancelled.
Finally, subscribe to the error events. Invalid usage surfaces as v1.billing.meter.error_report_triggered and v1.billing.meter.no_meter_found, with codes including meter_event_customer_not_found, timestamp_too_far_in_past and meter_event_invalid_value (recording usage). Because ingestion is asynchronous, a 200 on your POST means accepted for processing, not valid. With no event destination configured, every one of these failures is invisible until a customer reads their invoice.
What I ship now, in order: one meter per billable unit, created before any code; an outbox row written in the same transaction as the work; a drain worker with skip_locked, aggregating per customer per hour; an alert on the age of the oldest unsent row; an event destination for the two meter error events; and an outbox flush wired into the upgrade path. None of it is clever. All of it exists because the billing record has to live in my database first, and in Stripe second.
Sources
- Record usage for billing with the API — Stripe
- Create and configure a meter — Stripe
- Configure an invoice finalization grace period — Stripe
- Idempotent requests — Stripe API
- Database transactions — Django 6.1 documentation
- QuerySet API reference — Django 6.1 documentation
- Messages API reference — Anthropic