Metadata-Version: 2.5
Name: meterbase
Version: 0.5.0
Summary: Python SDK for the Meterbase engine
Project-URL: Homepage, https://meterbase.tech
Project-URL: Documentation, https://meterbase.tech/docs
Project-URL: Repository, https://github.com/usemeterbase/py-sdk
Author: Meterbase
License-Expression: MIT
License-File: LICENSE
Keywords: billing,entitlements,meterbase,metering,sdk,usage
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Description-Content-Type: text/markdown

# meterbase

Python SDK for the [Meterbase](https://github.com/usemeterbase/engine) engine.
A sync client and an async one with the same calls, for Python 3.10+. Its one
dependency is [httpx](https://www.python-httpx.org).

## Install

```bash
pip install meterbase
```

## Usage

Two calls, in this order: **`check` → do the work → `track`** — or, for work
that must not be done twice, [`reserve`](#work-you-cannot-do-twice).

```python
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](#plan-allowances) 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](#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

```python
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](#plan-allowances). The answer is a receipt,
with no verdict and nothing to branch on:

```python
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:

```python
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:

```python
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:

```python
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`](#a-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.

```python
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:

```python
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:

```python
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.

```python
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](#webhooks).

### Meters

```python
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.

```python
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.

```python
# 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.

```python
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](#how-much-they-have-used). 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.

```python
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](#how-much-they-have-used). `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

```python
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                  |

```python
# 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.

```python
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:

```python
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

```python
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:

```python
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

```python
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](#plan-allowances) 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](#plan-allowances): `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](#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.

```python
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

```python
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.

```python
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.

```python
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`.

```python
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 service at
`https://service.meterbase.tech` rather than the engine, with the same API key;
see [Options](#options). There each API key has three limits, whichever
customers the calls are for: `billing.checkout` and `billing.state` have 30
requests per 10 s each, and `purchases` has 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 retries a `GET` or a keyed checkout gets 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

```python
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`](#a-customers-billing) 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-customers-billing). 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.

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](#a-customers-billing), and `cancel_at_period_end` is false), it raises
`ConflictError` with `code == "already_subscribed"`. There is no plan-change
call yet: a customer who already pays changes plan or interval in the provider's
customer portal, so link there for their `upgrade`, `downgrade` and
`switch_interval` offers. A customer whose subscription is cancelling can check
out a new one, which takes over when the old one ends; its purchase reads `held`
until then.

Set the portal up once per mode, on the provider's side, and give it only the
prices or products you bound: a subscription moved onto any other is held for
review under **Payments**.

- **Stripe:** under Settings → Billing → Customer portal, turn on **Switch
  plan**, add the bound products and prices, and save. Then **Activate link**,
  and link to the login link it gives (`https://billing.stripe.com/p/login/…`),
  where the customer signs in with the email they paid with. In test mode,
  open a customer's portal from their page in the Stripe Dashboard.
- **Dodo Payments:** put the bound subscription products in one Product
  Collection, since the portal changes plans only within one, and turn on
  **Allow Subscription Updates** under Settings → Subscriptions. Link to
  `https://customer.dodopayments.com/login/{business_id}`, where the customer
  asks for a sign-in link by the email they paid with. Test mode sends no
  sign-in emails, so there your server makes a session with your Dodo key:
  `POST /customers/{customer_id}/customer-portal/session`, with the
  `customer.customer_id` of `GET /subscriptions/{id}`, for the
  `billing.subscription_id` in their [state](#a-customers-billing).

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](#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`:

```python
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

```python
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.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), and every
bound price on offer. 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.

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. A plan offer is sold to a customer
without a paying subscription with `checkout(..., 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 `grant` is what one pack gives: `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 `grant` is `None`. Choose
a pack by its grant 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.

```python
top_up = next(
    (o for o in state.offers if o.grant and o.grant.meter == "ai_tokens"), None
)
# top_up.grant == {"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",
    )
```

### 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                          |
| `already_subscribed`     | `ConflictError`, 409             | `checkout`          | a plan checkout for a customer who already pays                          |
| `customer_deleted`       | `ConflictError`, 409             | `checkout`          | the customer was deleted                                                 |
| `idempotency_key_reused` | `IdempotencyKeyReusedError`, 409 | `checkout`          | the `idempotency_key` was used for a different checkout                  |
| `customer_not_found`     | `NotFoundError`, 404             | `state`             | 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`          | the plan or pack has nothing bound, or `price` is not bound to the plan  |
| `invalid_request`        | `InvalidRequestError`, 422       | `checkout`, `state` | a field failed validation; the message names it                          |
| `rate_limited`           | `RateLimitError`, 429            | all                 | past a [limit](#billing), 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 |

```json
{
  "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:

```json
{
  "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:

```python
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](https://www.standardwebhooks.com):

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.

```python
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](#billing), once a `GET` or a keyed checkout has retried it; `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

```python
Meterbase(
    api_key="mb_sk_live_…",
    base_url="https://api.meterbase.tech",
    billing_base_url="https://service.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://service.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:

```python
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, 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.

`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.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 service at `https://service.meterbase.tech` 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:

- `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

```bash
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.

```bash
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.
