Metadata-Version: 2.5
Name: meterbase
Version: 0.1.2
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. 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** — including its own, since `track` is a `POST` it will replay.
Supply your own when the caller already has an id for the work — a job id, a
request id — so that a retry from further out than this SDK replays too. Keys
are retained 35 days; a retry after that records a second event, which is not a
case this API sets out to serve.

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

```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
everyone = meterbase.customers.list(include_deleted=True).data

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.

`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.archive(meter_id)  # soft delete, idempotent
```

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

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

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

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

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

## Webhooks

Meterbase posts to your endpoints when a customer crosses a usage threshold.
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` |

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

### 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 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
    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` or an `EntitlementReachedEvent`, 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 crossing |
| 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                                |
| `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

```python
Meterbase(
    api_key="mb_sk_live_…",
    base_url="https://api.meterbase.dev",
    timeout=10.0,  # seconds, per attempt
    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. 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:

```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` and `reserve`, 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.

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

## 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 test workspace. 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.
