meterbase
Python SDK for the Meterbase engine. A sync client and an async one with the same calls, for Python 3.10+. Its one dependency is httpx.
Install
pip install meterbase
Usage
Two calls, in this order: check → do the work → track — or, for work
that must not be done twice, reserve.
import os
from meterbase import Meterbase
meterbase = Meterbase(api_key=os.environ["METERBASE_API_KEY"])
result = meterbase.check(
customer_id="acct_1", # the tenant's own id, not ours
meter_id="ai_tokens", # the meter's key, not ours
quantity=50_000, # what you are about to spend; defaults to 1
)
if not result.allowed:
raise RuntimeError(f"Refused: {result.reason}")
# Allowed, and still worth reading: every window comes back on every call.
tight = next((w for w in result.rate_limits if w.remaining < w.max / 10), None)
if tight:
print(f"Slowing down until {tight.resets_at}")
completion = generate(prompt)
meterbase.track(
customer_id="acct_1",
meter_id="ai_tokens",
quantity=completion.usage.total_tokens,
)
An API key names its own workspace, so there is no workspace to pass. A
sandbox's keys start mb_sk_test_ and act on the sandbox alone, so give
development, CI and staging one of those: nothing else in your code changes.
Create one client and reuse it: it keeps a pool of connections open, and
meterbase.close() — or a with block — closes them.
check is the gate, and the only call that ever refuses. It never raises for a
denial — that is an answer, not a failure. Absence of entitlement is not
permission: a customer with no plan and no grant is denied.
available is the whole capacity — the plan's unused entitlement for the
current period, with what is left of any rollover carry,
plus every open grant — and is None under no_cap, where capacity is not a
number. It is one figure and not a breakdown: check answers from a single
cached integer, and itemising where the capacity came from would cost it that.
For the per-grant detail, read a customer's allowances.
A check answers two gates, and allowed needs both: the capacity has to
cover the quantity, and every rate limit in force — at most so much of this
meter per minute, hour or day — has to admit it. reason names the one that
said no, and is None when the answer is yes. rate_limits reports every
window whether or not it refused, so a caller can ease off as its headroom
closes instead of finding the ceiling by hitting it; it is [] when no rule
applies. A limit is protective and not commercial, so it is never folded into
available: a customer well inside their plan can still be limited, and one on
no_cap is precisely the customer a runaway loop costs most.
Under a soft cap — included usage, then overage; see
Plan allowances — the capacity admits any quantity, so only
a rate limit can refuse. available is what is left of the included amount and
stops at 0, allow_overage=True says why a quantity past it was still allowed,
and the overage in a request is max(quantity - available, 0).
allow_overage is False everywhere else, no_cap included.
Async
from meterbase import AsyncMeterbase
async with AsyncMeterbase(api_key=os.environ["METERBASE_API_KEY"]) as meterbase:
result = await meterbase.check(customer_id="acct_1", meter_id="ai_tokens")
Every call on Meterbase is on AsyncMeterbase, with the same parameters and
the same answers: the sync client is generated from the async one, so the two
cannot drift. Everything below is written for the sync client; add await for
the async one.
Recording usage
track counts work that already happened — the tokens were spent, the image
was generated — so it never refuses on capacity. A quantity beyond the
customer's capacity is recorded, drives available to 0, and the next check
says no, unless the cap is soft. The answer is a receipt,
with no verdict and nothing to branch on:
receipt = meterbase.track(
customer_id="acct_1",
meter_id="ai_tokens",
quantity=250_000,
idempotency_key="0199e0c0-8f3a-7c21-9d44-6b2e91a0f5c3", # a UUIDv7; generated when you omit it
)
receipt.event.replayed # False the first time, True for a retry of the same call
The engine dates the event, not you. There is no occurred_at to send: an
event is stamped by the database clock the write runs under, which is what
chooses the period it counts against. occurred_at and recorded_at come back
on the receipt as the same instant, both on the wire so a reader of either
keeps working. Nothing can be dated into a period that has already closed, so a
bill drawn from a period is never moved by a report that arrives after it.
Idempotency
idempotency_key identifies one logical event, and is that event's id.
It has to be a UUIDv7, whose first 48 bits are the millisecond it was
minted. That timestamp is not decoration: the engine admits a key only while
it is within one hour of the engine's clock, and keeps the claim behind it
until exactly that hour is up. A retry is therefore recognised for as long as
it is accepted at all.
| The call sends | The engine answers |
|---|---|
| a fresh key | the recorded event, replayed: false |
| a key it has already seen | that event with replayed: true, writing nothing |
| a key more than an hour old, or ahead | 422 too_late, writing nothing |
| anything that is not a UUIDv7 | 422 invalid_idempotency_key, writing nothing |
uuid.uuid4() mints a v4, which the engine refuses. uuid.uuid7(), on Python
3.14, mints the right version from this machine's clock — but the simplest key
is none: omit it and the SDK mints one.
A key it has already seen replays whatever the rest of the body says: there is one claim behind the key and nothing to compare a retry against, so a changed quantity is a replay rather than an error. The upside is that a replay costs one lookup and stores nothing.
The hour is a retry budget. Retry inside it and your call is recognised.
Past it, too_late — and do not then resend under a fresh key, because the
original may have been recorded. Nothing is written on either refusal: both
are checked before the engine touches anything.
Clocks matter now. The key is minted here, from this machine's clock, so
a device running more than an hour off would have every call refused. When
this SDK mints the key it handles that itself: too_late carries the engine's
server_time, the client corrects its offset and retries once, and you never
see the error. When you supply the key, you get TooLateError with
server_time on it, because only you can know whether that key names work
that was already recorded.
The SDK generates a key when you omit one, and reuses it across that call's retries. For a retry from further out than this SDK — a queue, a restarted worker — mint the key once the work is done, and store it with the job before the first send:
if job.usage_key is None:
job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
save_job(job) # before sending, so a rerun reuses it
meterbase.track(
customer_id=job.customer_id,
meter_id="ai_tokens",
quantity=job.tokens,
idempotency_key=job.usage_key,
)
It is dated by the engine's clock as this client knows it, and the hour it is admitted for starts then: mint it when the work is queued and a slow queue can outlast it.
Work you cannot do twice
check → work → track has a hole: two concurrent callers both pass the gate,
both do the work, and both record it. For an API call that costs nothing that
is an accepted trade. For an LLM completion, an image, a transcode, it is the
whole problem — the customer is over their cap and the money is already spent.
reserve closes it. It makes the same decision check makes and then holds
the capacity, so two reserves for the same last units cannot both be granted:
hold = meterbase.reserve(
customer_id="acct_1",
meter_id="ai_tokens",
quantity=50_000, # what you are about to spend; defaults to 1
expires_in_seconds=60, # how long to hold it; defaults to 60, at most 900
)
if not hold.allowed:
raise RuntimeError(f"Refused: {hold.reason}")
try:
completion = generate(prompt)
hold.commit(completion.usage.total_tokens) # records what it cost
except Exception:
hold.release() # nothing happened, so nothing is recorded
raise
The rule for choosing: can you afford to do the work twice? check. No?
reserve. A reserve costs a database transaction where a check costs a cached
read — refusals included — so it is not the call to put in front of cheap work.
A reserve answers everything check answers, plus the hold: reservation_id,
the quantity it set aside, and expires_at. available is already net of it.
Past a soft cap it holds rather than refuses, and available stops at 0.
A refusal holds nothing, so its reservation_id and expires_at are None.
commit is track under the hold's own id, which is what makes it a commit
rather than a second event beside it. Pass what the work actually cost and the
difference comes back; omit it to record what was held. Going over is allowed —
the hold only ever guaranteed what it reserved. On a refusal there is nothing
to commit, and commit raises MeterbaseError.
release gives the capacity back and records nothing. It is built for the
except block, so it answers None instead of raising when there is nothing
left to release — already committed, already released, or refused and so never
held — because raising there would mask the error you are already handling.
meterbase.release(reservation_id=...) raises that 404, as every other call
does.
Nothing is lost if your process dies. A hold expires on its own, and the
capacity comes back with nothing having run. A commit that arrives after
expires_at is still recorded — you lose the guarantee, not the event — and
the SDK logs a warning on the meterbase logger when that happens, which is
the only feedback there is on an expires_in_seconds guessed too short.
reservation_id is a UUIDv7 on the same terms as idempotency_key, generated
when you omit it, and it becomes the event's id once the hold is committed. One
id names the hold, the claim and the event. An id is for one reserve and its
retries: once its hold is committed or released, sending it again reserves
afresh rather than finding anything.
Customers
At sign-up, ensure the customer. One call gets or creates them by your own id and gives them a first plan, and running it again changes nothing:
ensured = meterbase.customers.ensure(
external_id="acct_1",
name="Acme", # applied only when it creates the customer
plan="free", # a plan's key or id
)
ensured.created, ensured.plan_result, ensured.customer.plan
plan_result says what it did with the plan: assigned, already_held,
kept_other when the customer holds another plan, or none when you named
none. It never changes a plan the customer holds — a checkout that must move
them calls customers.plan — and never cancels a
scheduled change. cycle_anchor puts a first plan's periods on a date the
customer already has, and mint_one_time=False writes the plan's one-time
grants already spent, for a customer you import with their remaining bonus.
ensure and retrieve_by_external_id raise MeterbaseError before sending
when the id is missing, empty or whitespace: without one, the lookup would name
no customer.
customer = meterbase.customers.create(
external_id="acct_1",
name="Acme",
metadata={"tier": "pro"},
)
found = meterbase.customers.retrieve_by_external_id("acct_1") # or None
meterbase.customers.update(customer.id, name="Acme Inc")
meterbase.customers.delete(customer.id) # soft delete, idempotent
Reading one customer tells you their plan. retrieve and
retrieve_by_external_id carry plan — the one in force now — and
pending_plan, the change waiting to land, each None when there is none:
acme = meterbase.customers.retrieve(customer.id)
if acme.plan:
acme.plan.key # "pro"
acme.plan.cycle # "monthly" — what their period is measured in
acme.plan.anchor_at # what it is measured from
acme.pending_plan # None, or the plan they move to and when
plan.id is the plan's id, not the assignment's: what you want from an
embed is which plan, and customers.plan.retrieve is where instructions are
identified. None means they hold no plan, which is a default deny — a
customer with no plan and no grant is denied.
Lists come a page at a time. customers.list answers one page, newest
first: limit customers, 100 unless you set it and at most 100. list_all
follows the pages for you, fetching each one as the loop reaches it:
page = meterbase.customers.list(plan="pro", limit=50)
page.data # the newest 50 customers on pro
page.has_more # True when another page follows the last of them
for customer in meterbase.customers.list_all(has_plan=False):
... # every customer holding no plan
On the async client it is async for customer in meterbase.customers.list_all().
plan, a key or an id, and has_plan filter on the plan in force now; a
pending change counts once it lands. include_deleted=True adds deleted
customers. To page by hand, pass the last id as starting_after while
has_more is True. A walk reads no customer twice, and one created while it
runs is not in it. Both raise before sending when plan or starting_after is
None, which would otherwise widen the list.
customers.list stays lean and does not embed, along with create, update
and delete; the same read would run per row for an answer most callers of a
list do not want.
Alert thresholds
thresholds is a list of whole percents — 1–1000, at most 32, and a mark over
100 is an overage alert — and it hangs off both meters and customers. A
customer's list beats the meter's, which beats the workspace's defaults.
meterbase.meters.create(key="ai_tokens", name="Tokens", thresholds=[80, 100])
meterbase.customers.update(customer.id, thresholds=[50, 90]) # this customer only
It is the one field where None is a value rather than silence:
| You pass | It means |
|---|---|
| nothing | keep the stored list |
thresholds=None |
inherit again — the meter's, then the workspace's |
thresholds=[] |
alerts off for this scope alone |
thresholds=[80] |
these marks |
That is why every optional parameter defaults to NOT_GIVEN rather than to
None: a parameter you leave out is not sent at all, and None is sent as
JSON null.
Each crossing is delivered as a webhook; see Webhooks.
Meters
meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
meters = meterbase.meters.list().data
meterbase.meters.retrieve("api_calls") # by key, or by id
meterbase.meters.archive(meter_id) # soft delete, idempotent
A meter's key works wherever its id does: retrieve, update, usage and
archive, meter_id on a grant or a plan allowance, and the meter_id filter
on their lists. Code written against a sandbox then runs unchanged in live,
where the ids differ. A key that names no meter raises NotFoundError. A
canonical UUID is always read as an id, so creating or renaming a meter to a
key shaped like one raises InvalidRequestError.
Plans
A plan has one billing period, fixed when the plan is created. Everything it meters resets on that period, measured from each customer's own anchor — which is why two customers on the same monthly plan renew on different days.
plan = meterbase.plans.create(
key="pro",
name="Pro",
cycle="monthly", # daily · weekly · monthly · quarterly · half_yearly · yearly
)
meterbase.plans.update(plan.id, name="Pro Plus")
meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
cycle is not patchable. Changing it would re-date every period the plan's
customers were already measured against, so a different cadence is a different
plan — move a customer with customers.plan.assign.
A plan's key works wherever its id does, on the same terms as a meter's:
plans.retrieve("pro"), plans.allowances.set("pro", ...), plan_id on
customers.plan.assign and plan on each change. A key that names no plan
raises NotFoundError, and a key shaped like a UUID is refused.
Plan allowances
What a plan grants, one meter at a time. An allowance says how much, never how often: the period is the plan's cycle. There is no update and no delete — an edit appends the next version, so which amount applied when stays derivable.
# The recurring cap, refreshed every period.
meterbase.plans.allowances.set(
plan.id,
meter_id=meter.id,
amount=1_000_000, # or no_cap=True
)
# A signup bonus beside it, minted once when the plan is assigned.
meterbase.plans.allowances.set(
plan.id,
meter_id=meter.id,
amount=500,
kind="one_time",
valid_for={"months": 3, "days": 0},
)
versions = meterbase.plans.allowances.list(plan.id).data
Each (plan, meter) pair carries up to two lineages, recurring and
one_time, numbering themselves separately. kind defaults to recurring.
apply_mode says when a version reaches customers already on the plan:
immediate, for the whole period they are in, or next_cycle, at each one's
next boundary. Omitted, a meter's first recurring version on the plan is
immediate, since there is nothing to phase out, and every later one is
next_cycle. one_time is always immediate.
A soft cap keeps the amount and drops the refusal: included usage, then overage.
meterbase.plans.allowances.set(
plan.id, meter_id=meter.id, amount=5_000, allow_overage=True
)
Once amount is used up, check and reserve admit rather than refuse, with
allow_overage=True on the answer, and the usage past it is overage: read it
as overage_used on the entitlement. Rate limits
still refuse, thresholds still fire — a mark over 100 is an overage alert — and
a grant is spent before any overage is. allow_overage defaults to False and
is refused beside no_cap and on one_time. Like the amount, it is a term of
the version, so turning it on or off follows apply_mode: at each customer's
next cycle by default.
Rollover carries what a period leaves of amount into the next one, which
spends it first. Whatever that period leaves of the carry is lost, so a carry
never stacks.
meterbase.plans.allowances.set(plan.id, meter_id=meter.id, amount=1_000, rollover=True)
Both periods' versions must roll over, so moving to a plan that does not ends
the carry. A restart passes nothing on, and usage the carry paid reaches no
threshold. Read it as carried_in and carried_used on
the entitlement. rollover defaults to False,
goes beside allow_overage and on an amount of 0, and is refused beside
no_cap and on one_time. It follows apply_mode too, and the first carry
arrives a boundary after the version lands.
A customer's plan
A customer's plan is an append-only history, not a field: assigning is the only write, and it serves both a first plan and every later change. What differs between the changes is when the new plan starts and what happens to the period the change lands in — so each of those is its own call.
Putting them on a plan
meterbase.customers.plan.assign(customer.id, plan_id=plan.id)
A first assignment is always recorded as immediate: there is no period to
wait out. It is the one call that takes cycle_anchor, which puts their
billing periods on a date they already have — migrating them from another
system, say. Their renewal day is fixed from here and does not move again
unless you restart them.
Moving them to another plan
| Call | The new plan starts | This period's cap |
|---|---|---|
change_at_next_cycle |
at their renewal | untouched — they keep what they paid for |
change_now |
now | the new plan's amount, for the whole period |
change_now + "prorate" |
now | the two plans weighted by how long each was held |
restart |
now | a fresh period starts today |
# Upgrade them at their renewal. Nothing is split, so nothing is pro-rated.
meterbase.customers.plan.change_at_next_cycle(customer.id, plan=pro.id)
# Move them now. Usage already spent still counts, so a downgrade can deny
# until the period ends.
meterbase.customers.plan.change_now(customer.id, plan=starter.id)
# Move them now and split the period fairly: 1,000/mo held for half a month
# then 400/mo for the other half is a cap of 700.
meterbase.customers.plan.change_now(
customer.id, plan=starter.id, reconciliation="prorate"
)
# Start over today: the running period closes where the change lands and
# keeps its usage, and the new plan's full amount opens at once.
meterbase.customers.plan.restart(customer.id, plan=pro.id)
restart is the one that moves their renewal day, because it re-anchors
them to today. The other three leave it exactly where it was.
change(customer_id, plan=..., effective=..., reconciliation=...) is the same
call with both knobs in the open, for when they are chosen at runtime. assign
is the wire shape underneath all four, and still takes plan_id.
Two plans on different cycles
Plans on different cycles share no period to keep, so change_now between
them raises CycleChangeRequiresResetError, with "none" or "prorate"
alike. A weekly plan and a month-based one share no boundary to wait for
either, so change_at_next_cycle between those two raises it too. restart is
the move that works: it starts a fresh period on the new cycle.
from meterbase import CycleChangeRequiresResetError
try:
meterbase.customers.plan.change_at_next_cycle(customer.id, plan=weekly.id)
except CycleChangeRequiresResetError:
meterbase.customers.plan.restart(customer.id, plan=weekly.id)
From a webhook
A provider may deliver a webhook twice, and a second restart would start a
second fresh period. Pass the event's id as idempotency_key, 1–255
characters, and a retry repeats nothing:
moved = meterbase.customers.plan.restart(
customer.id, plan="pro", idempotency_key=event.id
)
moved.replayed # True when this event had already moved them: nothing was written
assign, change, change_now and change_at_next_cycle take one too. The
same request under the key again answers with the assignment it made, as it is
now, and writes nothing — so a second delivery of an old event cannot cancel a
change scheduled since. Another request under the key raises
IdempotencyKeyReusedError. A key is kept for good, per workspace. With a key,
the SDK retries the call too.
Reading where they stand
current = meterbase.customers.plan.retrieve(customer.id) # or None
scheduled = meterbase.customers.plan.pending(customer.id) # or None
trail = meterbase.customers.plan.history(customer.id).data
retrieve is the plan in force now, which is not always the newest
instruction — a change dated ahead does not govern yet — and is None for a
customer holding no plan. That is a default deny rather than a failure, so it
is an answer here rather than a raised NotFoundError. An id that names no
customer reads as None too: the engine answers it as a customer holding no
plan.
pending is the change waiting to land, and at most one ever is: a newer
instruction supersedes the one before it. history is the whole trail,
superseded rows included, newest first — it is the audit log of what was asked
for, not only of what happened.
To call off a change that has not landed:
meterbase.customers.plan.cancel_scheduled_change(customer.id)
Naming the plan a customer already holds is what supersedes a pending
instruction, and writes no new row of its own — so this reads the plan in force
and names it back. It returns the assignment still in force, or None for a
customer holding no plan, who can have nothing pending.
It takes no idempotency_key: it names whatever plan is in force when it runs,
so a retry could name a different one and be refused as a different request.
For a cancel a webhook orders, assign the plan the event says they keep, under
the event's id.
How much they have used
entitlement = meterbase.customers.entitlement(customer.id) # or None
for meter in entitlement.meters if entitlement else []:
cap = "unlimited" if meter.amount is None else meter.amount
print(meter.meter_id, f"{meter.used} of {cap}")
entitlement is the plan's side of the current period, one entry per meter the
plan meters: the read behind a usage bar in your own app. used is what
amount is measured against, total adds what the carry and grants paid for,
and available is what check would answer. overage_used is how far used
has run past amount — what a soft cap bills for, and
reported under a hard cap too — and None where there is no amount;
allow_overage says whether the cap in effect is soft. rollover says whether
it rolls over: carried_in is what the last period left of
its amount, carried_used what this one has spent of it, and
carried_in - carried_used what goes at the period's end. Both are 0 where
nothing rolled over and None where there is no amount. Meters are named by
id, and like retrieve it is None for a customer holding no plan.
Unlike check, it is not answered from a cache, so read it where usage is
shown rather than on every request.
Closed periods
customers.periods.last is the newest period that has ended, with the figures
to bill from: per meter, the cap check answered at its last instant, used
against it, and overage_used, which is what a soft cap bills. carried_in,
carried_used and carried_out are what a rolling allowance took from the
period before, spent, and passed to the next, and total - used - carried_used
is what grants absorbed. They come from the counters behind check, which
count each event exactly once. Usage by day is for where
usage went, not for billing.
A billing job reads the newest, then walks back by passing each from_ as
before, until it reaches the to it last billed. None ends the walk:
nothing closed before that, or no plan at all.
from meterbase.types import ClosedPeriod
unbilled: list[ClosedPeriod] = []
period = meterbase.customers.periods.last(customer.id)
while period and period.to != last_billed_to:
unbilled.insert(0, period) # oldest first
period = meterbase.customers.periods.last(customer.id, before=period.from_)
The period's from is a Python keyword, so it reads as period.from_ — or as
period["from"], which reads any field exactly as the engine sent it.
Pass from_ back verbatim. It carries microseconds, and a before short
of one names the period before it. That is why before is a string, and why
the timestamps on every model stay strings: re-formatting a datetime is one
way to lose the precision, or the offset, that makes them exact. Keep to as it
came too: it is what the loop compares.
For a minute after a period ends, while a track dated inside it may still be
committing, last raises PeriodSettlingError. Read again after its
retry_after seconds; from then on the answer never changes. It is not retried
for you: the wait runs to a minute, and whether to sit it out is the job's
call.
Usage by day
week = meterbase.customers.usage(customer.id, days=7)
period = meterbase.customers.usage(customer.id, period="current") # or None
calls = meterbase.meters.usage(meter_id, days=30)
Usage per UTC day: days is 1–90 days ending today, and a customer can also
be read by period, "current" or "last". Each meter lists every day that
has begun, zeros included; a customer's meters with no usage in the range are
left out. A period the customer never had is None.
Figures can be up to five minutes old. check and entitlement are live.
A customer's allowances
Capacity on top of whatever the plan gives.
grant = meterbase.customers.allowances.grant(
customer.id,
meter_id=meter.id,
amount=500,
source="purchased", # or "bonus" · "manual"
)
grants = meterbase.customers.allowances.list(customer.id, include_closed=True).data
meterbase.customers.allowances.revoke(customer.id, grant.id)
Revoking withdraws what is left without erasing what was consumed, and is
idempotent. Grants the engine minted itself from a plan's one_time allowance
appear here with source="plan"; they cannot be created through grant.
A grant made from a payment's webhook takes the payment's id as its
idempotency_key, 1–255 characters: the provider may deliver the webhook
twice, and a retry under the key cannot grant twice.
grant = meterbase.customers.allowances.grant(
customer.id,
meter_id="ai_tokens",
amount=500_000,
source="purchased",
idempotency_key=payment.id,
)
grant.replayed # True when this payment had already granted: nothing was written
The same request under the key again answers with that grant as it is now,
perhaps spent or revoked since, and writes nothing. Another request under the
key raises IdempotencyKeyReusedError, so build the body the same way every
time: compute expires_at from the payment's own time, not from when the
handler ran. A key is kept for good, per workspace. With a key, the SDK retries
the call too.
To find what a payment granted — to take it back on a refund, say — list by its
key. Once the grant is revoked or has expired, add include_closed=True.
paid = meterbase.customers.allowances.list(customer.id, idempotency_key=payment.id)
for grant in paid.data: # the grant the payment made, or none
meterbase.customers.allowances.revoke(customer.id, grant.id)
Billing
Once the workspace has connected Stripe or Dodo Payments under Payments in
the dashboard, and bound its plans and packs to Stripe prices or Dodo products,
Meterbase sells them: it opens the checkout, receives the provider's webhooks,
and moves the customer's plan or grants the pack. There is no webhook handler of
yours in between. These calls go to the billing service at
https://billing.meterbase.tech rather than the engine, with the same API key;
see Options. There each API key has three limits, whichever
customers the calls are for: billing.checkout, change, cancel, keep,
portal, auto_top_up.set and auto_top_up.remove share 30 requests per
10 s, billing.state has 30 per 10 s of its own, and auto_top_up.get and
purchases share 120 per 10 s. All calls from one IP address share 300 per
10 s on top. A 429 there raises RateLimitError with retry_after, once the
call's retries have run out; purchases.wait keeps waiting instead.
A Dodo Payments subscription made outside Meterbase's checkout, from a buy link or your own server, is held until you link it to its customer once under Payments: anyone with a buy link can name a customer, so Meterbase trusts only a name its own checkout signed. Give each Dodo subscription product a Subscription Period longer than its billing period, such as 10 years: with the two equal, Dodo Payments ends the subscription after one charge.
Checkout
session = meterbase.billing.checkout(
"acct_1", # the tenant's own id
plan="pro", # the plan's key; or pack="tokens_200k", quantity=2
success_url="https://app.example.com/billing/done",
cancel_url="https://app.example.com/pricing",
)
redirect(session.url)
Give exactly one of plan or pack, and quantity only with a pack. A pack is
named as the pack offers from billing.state name it:
by its price's lookup key, or by the price id when it has none. Dodo Payments
has no lookup keys, so there it is the product id, which differs between test
and live mode: take it from the offer, chosen by
what it grants. A plan bound at more than one interval
sells its first bound price, unless price names another: a plan offer's
price.id or price.lookup_key. checkout reuses the provider's customer
already linked to the customer, and a customer Meterbase has not seen yet is
created when their purchase is applied. success_url is https, or http on
localhost; Meterbase adds session_id, its own opaque id for the checkout, to
it. The session expires at session.expires_at: an hour after it opens on
Stripe, and 24 hours on Dodo Payments.
On Stripe, save_payment_method=True on a pack checkout keeps the card on the
customer's Stripe record once they pay, for auto top-up to
charge later. A subscription keeps its card on its own, so a plan checkout does
not take it.
A plan's checkout opens a new subscription, so it is for a customer without a
paying one. For a customer whose subscription pays and is not cancelling
(billing.status is active, trialing or past_due in their
state, and cancel_at_period_end is false), it raises
ConflictError with code == "already_subscribed": move them with
billing.change instead, as their plan offers'
via == "change" says. A customer whose subscription is cancelling can check
out a new one, which takes over when the old one ends, its purchase reading
held until then. That is a second subscription, charged from the start, so
keep and then change is the way to move them.
A plan changed in the provider's own portal or dashboard still reaches the ledger through the provider's event, as long as it moves onto a bound price: a subscription moved onto any other is held for review under Payments.
A checkout is never retried unless it is given an idempotency_key, 1 to 200
printable ASCII characters with no spaces. Until the session expires, a repeat
with the same key answers that session rather than opening another when it
names the same customer, plan or pack, price and quantity, and raises
IdempotencyKeyReusedError (409 idempotency_key_reused) otherwise. The return
URLs stay the first request's. Once the session expires, the key opens a new
one. What each call can raise is under Billing errors.
The return page
The provider can send the customer back before its webhook has arrived, so the
return page waits for the purchase to land. Dodo Payments adds query parameters
of its own, such as status; they prove nothing about payment, so read the
purchase by session_id:
purchase = meterbase.purchases.wait(request.args["session_id"], timeout=15)
if purchase is None:
... # no checkout has that session_id
elif purchase.state in ("waiting", "settling"):
... # still on its way: show "Confirming your payment"
elif purchase.state in ("fulfilled", "late", "held"):
... # paid; only "fulfilled" means the plan or the pack is in place
else:
... # "failed" or "expired": nothing was bought
state |
Meaning |
|---|---|
waiting |
the checkout is open, or it was paid and is not applied yet |
settling |
paid by a method that clears later, such as a bank debit |
fulfilled |
the plan or the pack is in place |
late |
paid over 90 s ago and still not applied: the customer can close the page |
held |
paid, and held for review under Payments in the dashboard |
failed |
the payment failed |
expired |
the checkout closed unpaid |
wait reads the purchase every interval seconds, 2 unless set, for up to
timeout seconds, 90 unless set, and returns as soon as the state is anything
but waiting or settling. Running out of time is not an error: it returns
the last read, so show a purchase still pending as pending, and read it again
from the page. Many hosts cut a request off before 90 s, so a wait inside the
page's request passes a shorter timeout, as above. It returns None when the
purchase was never found, which means no checkout has that id, as with an
edited link. A 404 reads as waiting, in case the provider does not show a new
session yet. A RateLimitError reads as waiting too, with the next read after
its retry_after when that is longer than interval, since every return page
on one key shares the 120 reads per 10 s.
timeout bounds the polling, not each read, which keeps the client's own
timeout and retries. A negative or NaN timeout, an interval that is not
above 0, or an empty session id raises MeterbaseError and sends nothing.
purchases.retrieve reads it once.
A customer's billing
state = meterbase.billing.state("acct_1")
state.plan # BillingPlan(key="pro", name="Pro"), or None
if state.billing and state.billing.status == "past_due":
print("Payment failing, next retry:", state.billing.next_retry_at) # None on Dodo
for offer in state.offers:
print(offer.action, offer.via, offer.plan or offer.pack, offer.price.unit_amount)
One read for a pricing or account page: the plan and its period, a change
waiting to land (pending_change), each meter the plan meters (allowances),
the open grants beside it, purchased packs included (grants), the provider's
subscription the plan follows (billing, None when there is none), every
bound price on offer, and the customer's auto top-up rules
(auto_top_up, empty without one). A customer Meterbase has not seen yet reads as one with no
plan, every plan offer an upgrade, so a pricing page can show a new visitor
what to buy before they buy. A customer that was deleted raises NotFoundError
(404 customer_not_found).
billing.status reads the same on both providers, with a cancelled or expired
subscription ended. Dodo Payments has no trialing, since a trial reads
active, and its on_hold reads as unpaid; next_retry_at and lookup_key
are always None there. billing.scheduled_change is a plan change the
provider has scheduled, with plan None when the scheduled price is not
bound. pending_change is the ledger's: while the ledger's period does not end
with the provider's, it stays None, and only scheduled_change shows the
change.
An offer's action is current, switch_interval for the same plan billed
over another interval, upgrade or downgrade by price per month against the
current plan, or purchase for a pack. via names the call that buys it:
change for a plan offer while the customer's subscription pays, cancelling
included, checkout otherwise (a Dodo Payments subscription that ends by its
term too), and None for current. A plan offer is sold
with plan=offer.plan, price=offer.price.id, and a pack offer with
pack=offer.pack. New actions can be added; treat one you do not know as
unavailable. price.unit_amount is the price as listed, in the currency's minor
unit, cents for USD. Dodo Payments adds tax at checkout, by the buyer's
location, unless the product's price is tax inclusive, so a Dodo Payments buyer
can pay more than the offer shows.
A pack offer's grants is what one pack gives, one entry per meter: amount
on the meter whose key is meter, twice that for quantity=2, expiring
expires_days after the payment, or never when that is None. A plan offer's
grants is empty. Choose a pack by its grants rather than by name, telling two
packs on one meter apart by amount: Dodo Payments has no lookup keys, and its
product ids differ between test and live mode.
A pack can grant up to five meters at once, such as 500,000 ai_tokens and 50
credits for one price, each with its own expiry. A payment grants every meter
in the pack, and a refund takes back the refunded share of each. grant is the
first entry of grants, None on a plan offer, and is deprecated: it shows
one meter of a pack that may grant several.
top_up = next(
(o for o in state.offers if [g.meter for g in o.grants] == ["ai_tokens"]),
None,
)
# top_up.grants == [{"meter": "ai_tokens", "amount": 200_000, "expires_days": 90}]
if top_up and top_up.pack:
session = meterbase.billing.checkout(
"acct_1",
pack=top_up.pack,
success_url="https://app.example.com/billing/done",
)
On Stripe, a plan can bill usage past a soft cap (allow_overage): Meterbase
adds it as a line to the customer's next renewal invoice. An allowance's
overage_used is the usage past amount this period, None under no_cap,
and overage_price the price per unit it is billed at, None when none is
bound. A plan offer's overage lists that plan's overage prices by meter, so a
pricing page can show them before a purchase. Dodo Payments bills no overage
from Meterbase yet, so there both are None and []. An overage price is often
below one minor unit, so read its unit_amount_decimal, the amount in minor
units as a decimal string: $2 per million tokens is "0.0002", and its
unit_amount is None.
Change, cancel, keep, portal
For a customer whose subscription pays, billing.change moves it to another
plan, or to the same plan at another interval, on the provider. Meterbase then
brings the ledger along at once, before the provider's own event arrives.
change = meterbase.billing.change(
"acct_1",
plan="scale", # the plan's key
# price="scale_yearly", a plan offer's price.id or price.lookup_key
# when="now" or "period_end"
)
if change.outcome == "declined":
... # the charge was refused and nothing changed; change.declined says why
Without price, the plan's price at the subscription's current interval is
used, else its first bound price. action is what the plan offer shows, and
sets when the change happens unless when says otherwise:
action |
when by default |
|---|---|
upgrade |
"now", charging the difference at once |
downgrade |
"period_end" |
switch_interval |
"now" to a longer interval, "period_end" to a shorter one |
outcome |
Means |
|---|---|
changed |
the subscription is on the new price now |
scheduled |
the change happens at effective_at, the period end |
pending |
Dodo Payments took the change and is still charging for it; the plan moves once the payment succeeds |
declined |
Stripe refused the charge, and nothing changed: declined is card_declined or authentication_required |
unchanged |
the subscription is already on that price |
preview |
preview=True: what the change would do, with nothing written |
due_now is what the provider takes now, in minor units; a negative amount is a
credit to the customer's balance on Stripe. It is None for a change at the
period end, and on Dodo Payments after a real change, which Dodo answers with no
amount; a preview has it on both. An upgrade is charged off session to the saved
payment method, so a card that asks for 3D Secure answers declined with
authentication_required. ledger_updated is true when billing.state already
shows the result; after changed or scheduled, a false means the provider's
event brings it there.
change, cancel, keep and portal are safe to repeat, so the SDK retries
them as it does a GET. A change already made answers unchanged, or
scheduled for the same change at the period end, and nothing is charged twice.
billing.cancel ends the subscription at the period end, effective_at, and
never at once. Until then the customer keeps their plan, and then moves to the
fallback plan set under Payments; without one, that event waits there for
review. A cancel already set answers unchanged. Usage past a soft cap in this
last period is recorded, not invoiced, so use a hard cap where that matters.
billing.keep drops a scheduled plan change or cancel, on the provider and then
in the ledger, and answers unchanged when nothing was scheduled. While either
is scheduled (billing.scheduled_change, or cancel_at_period_end), change
and cancel raise ConflictError with code == "change_scheduled" rather than
replace it, so a cancelling customer who wants another plan takes two calls:
meterbase.billing.keep("acct_1")
meterbase.billing.change("acct_1", plan="scale")
On Dodo Payments, an upgrade answers pending while its charge runs: read
billing.state afterwards to see whether the plan moved, since a failed charge
leaves it as it was and nothing records the attempt. Another change meanwhile
raises ConflictError with code == "change_pending". cancel_at_period_end
is also true there for a subscription that ends by its own term, which keep
cannot clear: change raises ConflictError with code == "ends_by_term", and
cancel answers unchanged. Start a new subscription with checkout; it takes
over when this one ends.
billing.portal makes a session in the provider's customer portal, where the
customer updates their payment method, reads invoices and cancels:
portal = meterbase.billing.portal(
"acct_1", return_url="https://app.example.com/account"
)
redirect(portal.url) # now
return_url is required: https, or http on localhost. On Stripe the session
uses a portal setup named Meterbase, which Meterbase adds to the account once:
payment methods, invoices, billing details, and cancelling at the period end,
with switching plans left to change. A Stripe session expires 5 minutes after
it is made, and once opened an hour after its last use, so make one when the
customer asks and redirect at once. A cancel in the Stripe portal may fail
while a change is scheduled; call keep, then billing.cancel. On Dodo
Payments the portal is set up in Dodo, and a session lasts 24 hours. A customer
who has not paid through the provider yet has no portal, and raises
ConflictError with code == "no_provider_customer".
Auto top-up
On Stripe, a customer can have Meterbase buy them a pack whenever their balance on a meter runs low, charged to the card they saved. The rule is the customer's, so show them what it does and take their consent in your app, then set it:
meterbase.billing.auto_top_up.set(
"acct_1",
meter="ai_tokens",
pack="tokens_200k", # a pack offer's `pack`; it must grant the meter
when_below=20_000, # units of the meter
cap={"amount_minor": 4000, "currency": "usd"}, # at most $40 per period
# quantity=1, packs per charge
# cooldown_seconds=600, between charges
)
When the customer's available capacity on the meter, plan allowance and grants
together, drops below when_below, Meterbase charges their card for quantity
packs and the pack's grant lands as a purchase does, within a minute or so. It
charges at most cap per window, the customer's billing period when they have
a plan and 30 rolling days otherwise, then waits until the window turns, and at
least cooldown_seconds between charges. The cap's currency must be the
pack's. A rule set again replaces the one it had on that meter and clears
failing; with the balance already under the mark, the first charge runs at
once.
The card is the one the customer saved on Stripe: their default, else their
subscription's, else the newest attached. A customer with none raises
ConflictError with code == "no_saved_card": send them through checkout
for a pack with save_payment_method=True, and set the rule when they return.
The rule needs the workspace's auto top-up switched on under Payments, with
a Stripe key that may write PaymentIntents, and is not available on Dodo
Payments yet; see Billing errors for what each refusal is
called.
A customer's billing carries every rule in
auto_top_up, and auto_top_up.get reads them alone, off ones included:
for rule in meterbase.billing.auto_top_up.get("acct_1").rules:
print(rule.meter, rule.status) # "on", "failing" or "off"
print(rule.spent.amount_minor, "of", rule.cap.amount_minor, "this window")
print(rule.paused_until) # set while the cap is reached
if rule.failing:
print(rule.failing.reason) # such as "card_declined"
if rule.card:
print(rule.card.brand, rule.card.last4)
A declined card, or one that asks for 3D Secure, makes the rule failing:
failing.reason is Stripe's decline code or authentication_required, and
failing.payment_intent names the PaymentIntent, which your app can confirm
with Stripe.js for a card that asks for 3D Secure. Nothing retries a failing
card on a timer, and the plan allowance keeps working. Meterbase tries once
more when the balance runs out, in case the card was fixed in the portal, and
set again tries at once. A full refund or a lost dispute on a top-up turns
the rule off. remove turns it off by hand; a charge already running still
grants its pack:
meterbase.billing.auto_top_up.remove("acct_1", "ai_tokens")
With or without a rule, the crossing posts a balance.low webhook
to your endpoints.
Billing errors
code |
Raises | Calls | When |
|---|---|---|---|
unauthenticated |
AuthenticationError, 401 |
all | unknown, revoked or malformed key |
payments_not_connected |
ConflictError, 409 |
all | the workspace has no payment provider connected |
payments_not_live |
ConflictError, 409 |
checkout, change, cancel, keep, portal |
a live workspace that has not finished Go live in the dashboard, or that Meterbase has closed |
already_subscribed |
ConflictError, 409 |
checkout |
a plan checkout for a customer who already pays: use change |
customer_deleted |
ConflictError, 409 |
checkout, auto_top_up.set |
the customer was deleted |
idempotency_key_reused |
IdempotencyKeyReusedError, 409 |
checkout |
the idempotency_key was used for a different checkout |
not_subscribed |
ConflictError, 409 |
change, cancel, keep |
the customer has no active subscription through the provider, or its price is bound to no plan |
payment_failing |
ConflictError, 409 |
change, cancel |
the last payment failed: send the customer to portal to update their card, then try again |
change_scheduled |
ConflictError, 409 |
change, cancel |
a plan change or a cancel is already scheduled: call keep first |
change_pending |
ConflictError, 409 |
change |
Dodo Payments: a plan change is waiting for its payment; read state, then try again |
ends_by_term |
ConflictError, 409 |
change |
Dodo Payments: the subscription ends by its term; start a new one with checkout |
customer_busy |
ConflictError, 409 |
change, cancel, keep |
another change for the customer is running; try again in a moment |
no_provider_customer |
ConflictError, 409 |
portal |
the customer has not paid through the provider yet |
no_saved_card |
ConflictError, 409 |
auto_top_up.set |
the customer has no card saved on Stripe: checkout a pack with save_payment_method=True first |
auto_top_up_off |
ConflictError, 409 |
auto_top_up.set |
auto top-up is switched off for the workspace under Payments |
auto_top_up_unsupported |
ConflictError, 409 |
auto_top_up.set |
the workspace is on Dodo Payments, where auto top-up is not available yet |
auto_top_up_blocked |
ConflictError, 409 |
auto_top_up.set |
a top-up on this card was charged back; the rule stays off until the workspace allows it again in the dashboard |
provider_permission_missing |
ConflictError, 409 |
auto_top_up.set |
the connected Stripe key may not write PaymentIntents; the message names the permission |
customer_not_found |
NotFoundError, 404 |
all but checkout, auto_top_up.set and purchases |
the customer was deleted |
purchase_not_found |
NotFoundError, 404 |
purchases |
no checkout has that session id; wait reads it as waiting |
price_not_bound |
InvalidRequestError, 422 |
checkout, change, auto_top_up.set |
the plan or pack has nothing bound, price is not bound to the plan, or the pack does not grant the meter |
currency_mismatch |
InvalidRequestError, 422 |
auto_top_up.set |
cap.currency is not the pack's currency; the message names it |
invalid_request |
InvalidRequestError, 422 |
all but purchases |
a field failed validation; the message names it |
rate_limited |
RateLimitError, 429 |
all | past a limit, with retry_after; wait keeps waiting |
provider_error |
ServerError, 502 |
all | the provider refused or failed; the message says which |
unavailable |
ServerError, 503 |
all | the connected key could not be opened: disconnect, then connect it again |
engine_unavailable |
ServerError, 503 |
all | Meterbase could not be reached |
Webhooks
Meterbase posts to your endpoints when a customer crosses a usage threshold, and when they run out of capacity on a meter. Add an endpoint in the dashboard under Webhooks; each has its own signing secret, shown once when you add it and revealable from its settings.
| Event | When |
|---|---|
threshold.crossed |
a customer's usage of a meter crosses one of their thresholds |
entitlement.reached |
that crossing at 100% of their plan's entitlement, sent as well as threshold.crossed |
capacity.exhausted |
a track spends the last unit a hard cap allows, so check refuses them from then on |
{
"id": "0199c3b1-3f05-7a90-b6e2-49c817d05fa3",
"type": "threshold.crossed",
"timestamp": "2026-09-18T14:08:12Z",
"data": {
"customer_id": "acct_1",
"meter_id": "ai_tokens",
"plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
"threshold": 80,
"used": 801400,
"entitlement": 1000000,
"period": {
"start": "2026-09-01T00:00:00Z",
"end": "2026-10-01T00:00:00Z"
},
"usage_event_id": "0199c3b0-9e41-7d2a-8c55-2f6e1a3bc738"
}
}
used and entitlement are the plan's figures at the crossing; neither counts
grants, so ask check for capacity. entitlement.reached marks the plan's
entitlement, not spent capacity: a grant can still have balance, and a soft cap
admits past it.
capacity.exhausted carries no figures, since nothing is left:
{
"id": "0199e2c4-7b10-7c3a-9d41-6a2f0e1b5c88",
"type": "capacity.exhausted",
"timestamp": "2026-10-14T09:12:03Z",
"data": {
"customer_id": "acct_1",
"meter_id": "ai_tokens",
"plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
"period": {
"start": "2026-10-01T00:00:00Z",
"end": "2026-11-01T00:00:00Z"
},
"usage_event_id": "0199e2c4-7aff-7d2a-8c55-2f6e1a3bc738"
}
}
plan and period are None when grants were the customer's only capacity on
the meter. It counts the plan, a carry and every grant, so it is sent once each
time all of them run out, and again after a new grant or period is spent. A soft
cap or No Cap never sends it. A hold on the last unit does not send it; the
track that commits the hold does. A grant expiring, a revoke or a plan change
can leave a customer at 0 without it. Existing endpoints receive it only once
you add it to their events.
Verifying a delivery
Verify every request before you trust it. verify_webhook checks the signature
and the timestamp, then returns the event:
from flask import Flask, request
from meterbase import WebhookVerificationError, verify_webhook
from meterbase.types import CapacityExhaustedEvent, EntitlementReachedEvent
app = Flask(__name__)
@app.post("/meterbase")
def meterbase_webhook():
try:
event = verify_webhook(
payload=request.get_data(), # the raw body: re-serialized JSON will not verify
headers=request.headers,
secret=os.environ["METERBASE_WEBHOOK_SECRET"],
)
except WebhookVerificationError:
return "", 400
if isinstance(event, EntitlementReachedEvent):
... # event.data.customer_id has used their plan's allowance for event.data.meter_id
elif isinstance(event, CapacityExhaustedEvent):
... # check refuses event.data.customer_id on event.data.meter_id from now on
return "", 204
In FastAPI or Starlette, pass await request.body() and request.headers; in
Django, request.body and request.headers. Header names are matched without
regard to case, so a plain dict works too.
The event is a ThresholdCrossedEvent, an EntitlementReachedEvent or a
CapacityExhaustedEvent, so isinstance tells them apart. New types can be
added: one this release does not know reads as a plain WebhookEvent, so ignore
any you do not handle.
verify_webhook is synchronous, and fast enough to call from an async handler.
Without the SDK, it is Standard Webhooks:
- Each request carries
webhook-id,webhook-timestamp(Unix seconds) andwebhook-signature. - Compute HMAC-SHA256 over
{webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret afterwhsec_. webhook-signatureis a space-separated list ofv1,{base64}. Accept the request if any of them equals yours, compared in constant time.- Refuse a timestamp more than five minutes from your clock.
Responding and retries
Answer with a 2xx within 10 seconds. Anything else is a failure, a redirect included: Meterbase does not follow them.
| Attempt | After the one before |
|---|---|
| 1 | within a minute of the event |
| 2 | 1 minute |
| 3 | 6 minutes |
| 4 | 36 minutes |
| 5 | 3 hours 36 minutes |
| 6 | 21 hours 36 minutes |
Each wait varies by up to 10%. An event keeps its id on every attempt and
every endpoint, so dedupe on it, and do not rely on order. The dashboard logs
every attempt for 30 days and can retry a delivery by hand.
An answer of 410 Gone pauses the endpoint, as does a failure a day into a
streak of them. A paused endpoint holds new deliveries until you resume it,
with or without replaying them.
Rotating a secret
Rotate from the endpoint's settings. For the next 24 hours both secrets sign
every request, so deploy the new one at your own pace; while you switch over,
secret takes both: secret=[new_secret, old_secret].
Errors
Every failure is a MeterbaseError. Catch the specific one you care about, or
the base class for all of them.
from meterbase import ConflictError, RateLimitError
try:
meterbase.customers.create(external_id="acct_1")
except ConflictError as error:
... # error.code == "customer_external_id_taken"
except RateLimitError as error:
... # error.retry_after, in seconds
| Class | When |
|---|---|
AuthenticationError |
401 — unknown, revoked or malformed key |
PermissionDeniedError |
403 — the key may not use this route |
NotFoundError |
404 |
ConflictError |
409 — a key or external id is taken |
PeriodSettlingError |
409 — a ConflictError: the period ended under a minute ago, so read it again after retry_after seconds |
IdempotencyKeyReusedError |
409 — a ConflictError: the idempotency_key on a grant, a plan change or a billing.checkout was already used for a different request. No grant or plan change was written, and no checkout opened |
InvalidRequestError |
422 — a field failed validation; or 400 invalid_query, a query parameter that did |
CycleChangeRequiresResetError |
422 — an InvalidRequestError: the two plans' cycles differ, so use restart |
InvalidIdempotencyKeyError |
422 — an InvalidRequestError: the key was not a UUIDv7 |
TooLateError |
422 — an InvalidRequestError: the key is over an hour from the engine's clock, which it carries on server_time. Nothing was recorded |
ReservationMismatchError |
422 — an InvalidRequestError: the reservation was made for a different customer or meter |
RateLimitError |
429 — check or reserve past the workspace's fair-use rate, code == "rate_limited", with retry_after in seconds. reserve retries it for you; check raises at once, so fail open or closed. Also a billing call past its per-key limit, once the call's retries have run out; purchases.wait keeps waiting |
ServerError |
5xx |
APIConnectionError / APITimeoutError |
the request never got an answer |
WebhookVerificationError |
verify_webhook: not a delivery Meterbase signed in the last five minutes |
APIError carries status, code, message, request_id and the parsed
body. Branch on code, which is stable; message is for humans and may
change. The two connection errors are named so they do not shadow Python's own
ConnectionError and TimeoutError. Every error pickles with its fields, so a
task queue can carry one back from a worker.
error_from_response(status=..., code=..., message=...) builds the error the
SDK would raise for a response, for realistic errors in your own tests.
Options
Meterbase(
api_key="mb_sk_live_…",
base_url="https://api.meterbase.tech",
billing_base_url="https://billing.meterbase.tech", # billing and purchases
timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
max_retries=2,
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
)
timeout bounds each attempt — connecting, sending, and each wait for the
engine to answer — not the whole call. check waits 3 s unless the client
sets timeout: it gates a request someone is waiting on, and answers in
milliseconds when the engine is well. Decide what an APITimeoutError means
there — failing open or failing closed — rather than leaving the user waiting
out the 35 s the writes get. Pass an http_client for proxies,
limits or a transport of your own: an httpx.Client for Meterbase, an
httpx.AsyncClient for AsyncMeterbase. billing and purchases go to
billing_base_url, https://billing.meterbase.tech unless set, and every other
call to base_url, over the same connections.
For other settings on some calls, derive a client with them. It shares the original's connections, and its clock:
meterbase.with_options(timeout=2.0, max_retries=0).check(
customer_id="acct_1", meter_id="ai_tokens"
)
Cancelling an async call — task.cancel(), or a deadline of your own such as
asyncio.timeout — ends it where it stands, mid-attempt or in the wait before
a retry, and the cancellation reaches you untouched. Nothing is sent after it:
an attempt already on the wire may still land, but it is never replayed, so a
cancelled track cannot record usage twice. A sync call cannot be cancelled;
bound it with timeout and max_retries instead.
Retries apply to GET, DELETE, track, reserve, customers.ensure,
billing.change, cancel, keep and portal, and a grant, plan change or
billing.checkout given an idempotency_key, on connection failures, timeouts,
408, 429 and 5xx, with exponential backoff and full jitter. Retry-After is
honoured when Meterbase sends it, up to eight seconds. Every other POST and
PATCH is never replayed, because a retried create could produce a second row.
An ensure can be, because running it twice changes nothing: one whose first
attempt landed answers created false. So can a keyed grant or plan change,
which the engine answers with what the first attempt wrote: one whose first
attempt landed answers replayed true. A keyed checkout is answered by
Meterbase with the session its first attempt opened. So is a billing.change,
which Meterbase checks against the subscription itself: one whose first attempt
landed answers unchanged, or scheduled at the period end.
track and reserve are the exception because they carry an idempotency key,
and because the SDK fixes that key before the retry loop starts: every
attempt of one call names the same logical event, so the engine replays it
instead of counting the work twice. If you retry track yourself, pass your
own idempotency_key and keep it the same across attempts for exactly that
reason — and do it inside the hour the engine holds that key for.
Types
Every answer is the engine's JSON as it arrived, snake_case included, so this
SDK reads the same as the API reference and cannot drift from it through a
translation layer. Each field is a typed, read-only attribute — result.allowed,
hold.rate_limits[0].remaining — and a nested object is a model too.
model.to_dict() returns the JSON exactly as it was sent, and model["field"]
reads one field of it: the way to reach a field the engine has added since this
release. Times stay RFC 3339 strings, as sent. The models are in
meterbase.types, and they are type hints rather than validation: an answer
the engine shapes differently reaches you rather than failing the call that
carried it.
Versioning
Pre-1.0, the minor version is where breaking changes land — deliberately, so a
change that will not run against your code cannot arrive as a patch. The SDK
follows the engine's API, as the TypeScript SDK (mbase-sdk) does.
0.7.0 adds billing.auto_top_up.set, get and remove, save_payment_method
on a pack checkout, and auto_top_up on billing state. Stripe only; Dodo Payments
answers auto_top_up_unsupported. Nothing in it breaks code written for 0.6.
0.6.0 adds billing.change, billing.cancel, billing.keep and
billing.portal, and billing_base_url now defaults to
https://billing.meterbase.tech. The old host still answers, so nothing in it
breaks code written for 0.5.
0.5.1 adds grants to a billing offer: every meter a pack grants. grant,
the first of them, stays and is deprecated.
0.5.0 adds billing and purchases, for a workspace that sells through
Stripe or Dodo Payments: billing.checkout, billing.state,
purchases.retrieve and purchases.wait, with the billing_base_url option.
They go to the Meterbase service rather than the engine. Nothing in it breaks
code written for 0.4.0.
0.4.0 pages the customer list, and adds customers.list_all and the
limit, starting_after, plan and has_plan parameters. One change can break
code: customers.list() answers one page of 100, newest first, where it used to
answer every customer. Iterate list_all to read them all. The engine pages the
same way, so 0.3.0 and earlier now read only the newest 100.
0.3.0 adds an idempotency_key on grants and plan changes, which also makes
them safe to retry, replayed on their answers, and IdempotencyKeyReusedError.
Nothing in it breaks code written for 0.2.0.
0.2.0 adds customers.ensure and idempotency_key(), and changes two
behaviours:
checkwaits 3 s by default. The other calls keep 35 s, and atimeoutset on the client, or throughwith_options, still wins.retrieve_by_external_idraises before sending when the id is missing, empty or whitespace. Before, aNonewas dropped from the query, and the engine answered with every customer, the newest first, which this returned.
Development
uv sync
make test # pytest, against a local test double
make check # generated code, types, lint, format, build, tests, packaging
One client, generated twice
The async client in src/meterbase/_async is the source. make unasync
generates the sync client in src/meterbase/_sync from it — async def
becomes def, await goes, AsyncMeterbase becomes Meterbase — and the
async tests in tests/_async into sync ones in tests/_sync. Edit the async
side and regenerate; make check fails when the two disagree. The one place
they genuinely differ, sleeping and running two calls at once, is _io.py,
written by hand on both sides.
Two layers of tests
make test runs against a real HTTP server on an ephemeral port rather
than a stubbed transport, so request building, headers, status handling and the
retry loop are exercised for real, for both clients. It can make the server
drop a socket or stall, which is the only way to test the retry, timeout and
cancellation paths.
What it cannot check is whether those scripted responses match the engine: a route that does not exist, a renamed field or a changed error code all pass here and fail in production.
make contract closes that gap by running against a live engine.
cp .env.example .env # then fill in a key, or export the two variables
make contract
It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
allowances become real entitlement a check can see; that tracked usage takes
exactly that much away, that a replay takes nothing, and that a reused key
replays whatever the body says; that a hold's commit records what the work cost
and gives the rest back; that a rate limit comes back on every check and
refuses with the allowance untouched; that a soft cap admits and holds past its
amount and reports the overage; that a rolling allowance carries nothing into a
first period or across a restart; that a period a restart closes settles
for a minute, which the run waits out, and then reads final; that plan changes
schedule, cancel and re-anchor as documented; and — reading each model's
annotations — that the engine's fields still match the SDK's types exactly, in
both directions.
It creates and deletes real data. Point it at a sandbox, with an
mb_sk_test_key. It only touches resources it created, tagging each with a per-run id, and cleans up afterwards; soft-deleted customers and archived meters and plans remain, as the engine intends. It is outsidemake teston purpose — a mutating suite should only run because someone asked for it.
Metadata
Release files for meterbase 0.7.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| meterbase-0.7.0.tar.gz | 107.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| meterbase-0.7.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 212.0 kB
Release files / meterbase-0.7.0.tar.gz
| Download URL | meterbase-0.7.0.tar.gz |
|---|---|
| Size | 107.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7c74e8645d8c8884692230eae682ca996790cf50be178c58479acb94848ca50c
|
|
BLAKE2b-256 checksum How to use checksums |
b17d85ce9799ebc708de17199a705f845de39b02a525b16be541e1903196aa74
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logRelease files / meterbase-0.7.0-py3-none-any.whl
| Download URL | meterbase-0.7.0-py3-none-any.whl |
|---|---|
| Size | 104.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9be7a062a4a01a0e879a707867dd7b523cd86fd33c894eaf80539fcbd267da79
|
|
BLAKE2b-256 checksum How to use checksums |
605495e2c5ead3ca6c79d133ae66fa921a4188b09a7285d0ec136baf9387a08b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logSecurity for meterbase 0.7.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for meterbase-0.7.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 21, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.