Skip to main content

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 1,000. list_all follows the pages for you, fetching each one as the loop reaches it:

page = meterbase.customers.list(plan="pro", limit=1000)
page.data  #      the newest 1,000 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)

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:

  1. Each request carries webhook-id, webhook-timestamp (Unix seconds) and webhook-signature.
  2. Compute HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of the secret after whsec_.
  3. webhook-signature is a space-separated list of v1,{base64}. Accept the request if any of them equals yours, compared in constant time.
  4. 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 or plan change was already used for a different request. Nothing was written
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
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.dev",
    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.

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, and a grant or plan change given an idempotency_key, on connection failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter. Retry-After is honoured when the engine 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.

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.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:

  • check waits 3 s by default. The other calls keep 35 s, and a timeout set on the client, or through with_options, still wins.
  • retrieve_by_external_id raises before sending when the id is missing, empty or whitespace. Before, a None was 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 outside make test on purpose — a mutating suite should only run because someone asked for it.

Metadata

Release files for meterbase 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for meterbase 0.4.0
File Size Uploaded Provenance
meterbase-0.4.0.tar.gz 80.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for meterbase 0.4.0
File Interpreter ABI Platform
meterbase-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 163.8 kB

Release files / meterbase-0.4.0.tar.gz

Download URL meterbase-0.4.0.tar.gz
Size 80.6 kB
Tags Source
SHA-256 checksum
How to use checksums
0658a59e4f38cc41477171c753f47de242909d4afb862305a43484fbeebfc8c0
BLAKE2b-256 checksum
How to use checksums
f0c940d83b65dcaf952944086fc1ca4181c3f0236c1af36d93131747beca5d6d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 6, 2026.

Transparency log

Release files / meterbase-0.4.0-py3-none-any.whl

Download URL meterbase-0.4.0-py3-none-any.whl
Size 83.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7d42652ed457cad7605196df2b7bb3956dee4a783163cb80b5bdf2f2acb0df81
BLAKE2b-256 checksum
How to use checksums
960486bef03902867a8dfd5572f1dfdab27adfda6d02eaf9e94b649051741002
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

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 6, 2026.

Transparency log

Security for meterbase 0.4.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.

Select from 2 files

Attestation for meterbase-0.4.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 6, 2026.

Transparency log

A 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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page