meterbase 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. meterbase/__init__.py +59 -0
  2. meterbase/_async/__init__.py +0 -0
  3. meterbase/_async/_io.py +22 -0
  4. meterbase/_async/_transport.py +181 -0
  5. meterbase/_async/client.py +427 -0
  6. meterbase/_async/resources/__init__.py +16 -0
  7. meterbase/_async/resources/customer_allowances.py +89 -0
  8. meterbase/_async/resources/customer_periods.py +41 -0
  9. meterbase/_async/resources/customer_plan.py +228 -0
  10. meterbase/_async/resources/customers.py +176 -0
  11. meterbase/_async/resources/meters.py +102 -0
  12. meterbase/_async/resources/plans.py +140 -0
  13. meterbase/_http.py +205 -0
  14. meterbase/_json.py +18 -0
  15. meterbase/_keys.py +39 -0
  16. meterbase/_models.py +177 -0
  17. meterbase/_not_given.py +32 -0
  18. meterbase/_sync/__init__.py +4 -0
  19. meterbase/_sync/_io.py +21 -0
  20. meterbase/_sync/_transport.py +185 -0
  21. meterbase/_sync/client.py +431 -0
  22. meterbase/_sync/resources/__init__.py +20 -0
  23. meterbase/_sync/resources/customer_allowances.py +93 -0
  24. meterbase/_sync/resources/customer_periods.py +45 -0
  25. meterbase/_sync/resources/customer_plan.py +230 -0
  26. meterbase/_sync/resources/customers.py +176 -0
  27. meterbase/_sync/resources/meters.py +106 -0
  28. meterbase/_sync/resources/plans.py +144 -0
  29. meterbase/_time.py +49 -0
  30. meterbase/_version.py +1 -0
  31. meterbase/errors.py +309 -0
  32. meterbase/py.typed +0 -0
  33. meterbase/resources.py +41 -0
  34. meterbase/types.py +600 -0
  35. meterbase/webhooks.py +154 -0
  36. meterbase-0.1.0.dist-info/METADATA +867 -0
  37. meterbase-0.1.0.dist-info/RECORD +39 -0
  38. meterbase-0.1.0.dist-info/WHEEL +4 -0
  39. meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,867 @@
1
+ Metadata-Version: 2.5
2
+ Name: meterbase
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Meterbase engine
5
+ Project-URL: Homepage, https://meterbase.tech
6
+ Project-URL: Documentation, https://meterbase.tech/docs
7
+ Project-URL: Repository, https://github.com/usemeterbase/py-sdk
8
+ Author: Meterbase
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: billing,entitlements,meterbase,metering,sdk,usage
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: httpx<1,>=0.27
26
+ Description-Content-Type: text/markdown
27
+
28
+ # meterbase
29
+
30
+ Python SDK for the [Meterbase](https://github.com/usemeterbase/engine) engine.
31
+ A sync client and an async one with the same calls, for Python 3.10+. Its one
32
+ dependency is [httpx](https://www.python-httpx.org).
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install meterbase
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ Two calls, in this order: **`check` → do the work → `track`** — or, for work
43
+ that must not be done twice, [`reserve`](#work-you-cannot-do-twice).
44
+
45
+ ```python
46
+ import os
47
+
48
+ from meterbase import Meterbase
49
+
50
+ meterbase = Meterbase(api_key=os.environ["METERBASE_API_KEY"])
51
+
52
+ result = meterbase.check(
53
+ customer_id="acct_1", # the tenant's own id, not ours
54
+ meter_id="ai_tokens", # the meter's key, not ours
55
+ quantity=50_000, # what you are about to spend; defaults to 1
56
+ )
57
+
58
+ if not result.allowed:
59
+ raise RuntimeError(f"Refused: {result.reason}")
60
+
61
+ # Allowed, and still worth reading: every window comes back on every call.
62
+ tight = next((w for w in result.rate_limits if w.remaining < w.max / 10), None)
63
+ if tight:
64
+ print(f"Slowing down until {tight.resets_at}")
65
+
66
+ completion = generate(prompt)
67
+
68
+ meterbase.track(
69
+ customer_id="acct_1",
70
+ meter_id="ai_tokens",
71
+ quantity=completion.usage.total_tokens,
72
+ )
73
+ ```
74
+
75
+ An API key names its own workspace, so there is no workspace to pass. Create
76
+ one client and reuse it: it keeps a pool of connections open, and
77
+ `meterbase.close()` — or a `with` block — closes them.
78
+
79
+ `check` is the gate, and the only call that ever refuses. It never raises for a
80
+ denial — that is an answer, not a failure. Absence of entitlement is not
81
+ permission: a customer with no plan and no grant is denied.
82
+
83
+ `available` is the whole capacity — the plan's unused entitlement for the
84
+ current period plus every open grant — and is `None` under `no_cap`, where
85
+ capacity is not a number. It is one figure and not a breakdown: `check` answers
86
+ from a single cached integer, and itemising where the capacity came from would
87
+ cost it that. For the per-grant detail, read a customer's allowances.
88
+
89
+ A check answers **two gates**, and `allowed` needs both: the capacity has to
90
+ cover the quantity, and every rate limit in force — _at most so much of this
91
+ meter per minute, hour or day_ — has to admit it. `reason` names the one that
92
+ said no, and is `None` when the answer is yes. `rate_limits` reports every
93
+ window whether or not it refused, so a caller can ease off as its headroom
94
+ closes instead of finding the ceiling by hitting it; it is `[]` when no rule
95
+ applies. A limit is protective and not commercial, so it is never folded into
96
+ `available`: a customer well inside their plan can still be limited, and one on
97
+ `no_cap` is precisely the customer a runaway loop costs most.
98
+
99
+ Under a **soft cap** — included usage, then overage; see
100
+ [Plan allowances](#plan-allowances) — the capacity admits any quantity, so only
101
+ a rate limit can refuse. `available` is what is left of the included amount and
102
+ stops at 0, `allow_overage=True` says why a quantity past it was still allowed,
103
+ and the overage in a request is `max(quantity - available, 0)`.
104
+ `allow_overage` is `False` everywhere else, `no_cap` included.
105
+
106
+ ### Async
107
+
108
+ ```python
109
+ from meterbase import AsyncMeterbase
110
+
111
+ async with AsyncMeterbase(api_key=os.environ["METERBASE_API_KEY"]) as meterbase:
112
+ result = await meterbase.check(customer_id="acct_1", meter_id="ai_tokens")
113
+ ```
114
+
115
+ Every call on `Meterbase` is on `AsyncMeterbase`, with the same parameters and
116
+ the same answers: the sync client is generated from the async one, so the two
117
+ cannot drift. Everything below is written for the sync client; add `await` for
118
+ the async one.
119
+
120
+ ### Recording usage
121
+
122
+ `track` counts work that already happened — the tokens were spent, the image
123
+ was generated — so it **never refuses on capacity**. A quantity beyond the
124
+ customer's capacity is recorded, drives `available` to 0, and the next `check`
125
+ says no, unless the cap is [soft](#plan-allowances). The answer is a receipt,
126
+ with no verdict and nothing to branch on:
127
+
128
+ ```python
129
+ receipt = meterbase.track(
130
+ customer_id="acct_1",
131
+ meter_id="ai_tokens",
132
+ quantity=250_000,
133
+ idempotency_key="0199e0c0-8f3a-7c21-9d44-6b2e91a0f5c3", # a UUIDv7; generated when you omit it
134
+ )
135
+
136
+ receipt.event.replayed # False the first time, True for a retry of the same call
137
+ ```
138
+
139
+ **The engine dates the event, not you.** There is no `occurred_at` to send: an
140
+ event is stamped by the database clock the write runs under, which is what
141
+ chooses the period it counts against. `occurred_at` and `recorded_at` come back
142
+ on the receipt as the same instant, both on the wire so a reader of either
143
+ keeps working. Nothing can be dated into a period that has already closed, so a
144
+ bill drawn from a period is never moved by a report that arrives after it.
145
+
146
+ #### Idempotency
147
+
148
+ `idempotency_key` identifies one logical event, and **is** that event's id.
149
+ It has to be a **UUIDv7**, whose first 48 bits are the millisecond it was
150
+ minted. That timestamp is not decoration: the engine admits a key only while
151
+ it is within **one hour** of the engine's clock, and keeps the claim behind it
152
+ until exactly that hour is up. A retry is therefore recognised for as long as
153
+ it is accepted at all.
154
+
155
+ | The call sends | The engine answers |
156
+ | ------------------------------------- | ------------------------------------------------- |
157
+ | a fresh key | the recorded event, `replayed: false` |
158
+ | a key it has already seen | that event with `replayed: true`, writing nothing |
159
+ | a key more than an hour old, or ahead | `422 too_late`, writing nothing |
160
+ | anything that is not a UUIDv7 | `422 invalid_idempotency_key`, writing nothing |
161
+
162
+ `uuid.uuid4()` mints a v4, which the engine refuses. `uuid.uuid7()`, on Python
163
+ 3.14, mints the right version from this machine's clock — but the simplest key
164
+ is none: omit it and the SDK mints one.
165
+
166
+ A key it has already seen replays **whatever the rest of the body says**:
167
+ there is one claim behind the key and nothing to compare a retry against, so
168
+ a changed quantity is a replay rather than an error. The upside is that a
169
+ replay costs one lookup and stores nothing.
170
+
171
+ **The hour is a retry budget.** Retry inside it and your call is recognised.
172
+ Past it, `too_late` — and do not then resend under a fresh key, because the
173
+ original may have been recorded. Nothing is written on either refusal: both
174
+ are checked before the engine touches anything.
175
+
176
+ **Clocks matter now.** The key is minted here, from this machine's clock, so
177
+ a device running more than an hour off would have every call refused. When
178
+ this SDK mints the key it handles that itself: `too_late` carries the engine's
179
+ `server_time`, the client corrects its offset and retries once, and you never
180
+ see the error. When **you** supply the key, you get `TooLateError` with
181
+ `server_time` on it, because only you can know whether that key names work
182
+ that was already recorded.
183
+
184
+ **The SDK generates a key when you omit one, and reuses it across that call's
185
+ retries** — including its own, since `track` is a `POST` it will replay.
186
+ Supply your own when the caller already has an id for the work — a job id, a
187
+ request id — so that a retry from further out than this SDK replays too. Keys
188
+ are retained 35 days; a retry after that records a second event, which is not a
189
+ case this API sets out to serve.
190
+
191
+ ### Work you cannot do twice
192
+
193
+ `check` → work → `track` has a hole: two concurrent callers both pass the gate,
194
+ both do the work, and both record it. For an API call that costs nothing that
195
+ is an accepted trade. For an LLM completion, an image, a transcode, it is the
196
+ whole problem — the customer is over their cap and the money is already spent.
197
+
198
+ `reserve` closes it. It makes the same decision `check` makes and then **holds**
199
+ the capacity, so two reserves for the same last units cannot both be granted:
200
+
201
+ ```python
202
+ hold = meterbase.reserve(
203
+ customer_id="acct_1",
204
+ meter_id="ai_tokens",
205
+ quantity=50_000, # what you are about to spend; defaults to 1
206
+ expires_in_seconds=60, # how long to hold it; defaults to 60, at most 900
207
+ )
208
+
209
+ if not hold.allowed:
210
+ raise RuntimeError(f"Refused: {hold.reason}")
211
+
212
+ try:
213
+ completion = generate(prompt)
214
+ hold.commit(completion.usage.total_tokens) # records what it cost
215
+ except Exception:
216
+ hold.release() # nothing happened, so nothing is recorded
217
+ raise
218
+ ```
219
+
220
+ **The rule for choosing: can you afford to do the work twice? `check`. No?
221
+ `reserve`.** A reserve costs a database transaction where a check costs a cached
222
+ read — refusals included — so it is not the call to put in front of cheap work.
223
+
224
+ A reserve answers everything `check` answers, plus the hold: `reservation_id`,
225
+ the `quantity` it set aside, and `expires_at`. `available` is already net of it.
226
+ Past a soft cap it holds rather than refuses, and `available` stops at 0.
227
+ A refusal holds nothing, so its `reservation_id` and `expires_at` are `None`.
228
+
229
+ `commit` is `track` under the hold's own id, which is what makes it a commit
230
+ rather than a second event beside it. Pass what the work actually cost and the
231
+ difference comes back; omit it to record what was held. Going over is allowed —
232
+ the hold only ever guaranteed what it reserved. On a refusal there is nothing
233
+ to commit, and `commit` raises `MeterbaseError`.
234
+
235
+ `release` gives the capacity back and records nothing. It is built for the
236
+ `except` block, so it answers `None` instead of raising when there is nothing
237
+ left to release — already committed, already released, or refused and so never
238
+ held — because raising there would mask the error you are already handling.
239
+ `meterbase.release(reservation_id=...)` raises that 404, as every other call
240
+ does.
241
+
242
+ **Nothing is lost if your process dies.** A hold expires on its own, and the
243
+ capacity comes back with nothing having run. A commit that arrives after
244
+ `expires_at` is still recorded — you lose the guarantee, not the event — and
245
+ the SDK logs a warning on the `meterbase` logger when that happens, which is
246
+ the only feedback there is on an `expires_in_seconds` guessed too short.
247
+
248
+ `reservation_id` is a UUIDv7 on the same terms as `idempotency_key`, generated
249
+ when you omit it, and it becomes the event's id once the hold is committed. One
250
+ id names the hold, the claim and the event. An id is for one reserve and its
251
+ retries: once its hold is committed or released, sending it again reserves
252
+ afresh rather than finding anything.
253
+
254
+ ### Customers
255
+
256
+ ```python
257
+ customer = meterbase.customers.create(
258
+ external_id="acct_1",
259
+ name="Acme",
260
+ metadata={"tier": "pro"},
261
+ )
262
+
263
+ found = meterbase.customers.retrieve_by_external_id("acct_1") # or None
264
+ everyone = meterbase.customers.list(include_deleted=True).data
265
+
266
+ meterbase.customers.update(customer.id, name="Acme Inc")
267
+ meterbase.customers.delete(customer.id) # soft delete, idempotent
268
+ ```
269
+
270
+ **Reading one customer tells you their plan.** `retrieve` and
271
+ `retrieve_by_external_id` carry `plan` — the one in force now — and
272
+ `pending_plan`, the change waiting to land, each `None` when there is none:
273
+
274
+ ```python
275
+ acme = meterbase.customers.retrieve(customer.id)
276
+
277
+ if acme.plan:
278
+ acme.plan.key # "pro"
279
+ acme.plan.cycle # "monthly" — what their period is measured in
280
+ acme.plan.anchor_at # what it is measured from
281
+ acme.pending_plan # None, or the plan they move to and when
282
+ ```
283
+
284
+ `plan.id` is the **plan's** id, not the assignment's: what you want from an
285
+ embed is which plan, and `customers.plan.retrieve` is where instructions are
286
+ identified. `None` means they hold no plan, which is a default deny — a
287
+ customer with no plan and no grant is denied.
288
+
289
+ `customers.list` stays lean and does not embed, along with `create`, `update`
290
+ and `delete`; the same read would run per row for an answer most callers of a
291
+ list do not want.
292
+
293
+ ### Alert thresholds
294
+
295
+ `thresholds` is a list of whole percents — 1–1000, at most 32, and a mark over
296
+ 100 is an overage alert — and it hangs off both meters and customers. A
297
+ customer's list beats the meter's, which beats the workspace's defaults.
298
+
299
+ ```python
300
+ meterbase.meters.create(key="ai_tokens", name="Tokens", thresholds=[80, 100])
301
+ meterbase.customers.update(customer.id, thresholds=[50, 90]) # this customer only
302
+ ```
303
+
304
+ It is the one field where `None` is a value rather than silence:
305
+
306
+ | You pass | It means |
307
+ | ------------------- | ------------------------------------------------- |
308
+ | nothing | keep the stored list |
309
+ | `thresholds=None` | inherit again — the meter's, then the workspace's |
310
+ | `thresholds=[]` | alerts off for this scope alone |
311
+ | `thresholds=[80]` | these marks |
312
+
313
+ That is why every optional parameter defaults to `NOT_GIVEN` rather than to
314
+ `None`: a parameter you leave out is not sent at all, and `None` is sent as
315
+ JSON `null`.
316
+
317
+ Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
318
+
319
+ ### Meters
320
+
321
+ ```python
322
+ meterbase.meters.create(key="api_calls", name="API calls") # plus optional thresholds
323
+ meters = meterbase.meters.list().data
324
+ meterbase.meters.archive(meter_id) # soft delete, idempotent
325
+ ```
326
+
327
+ ### Plans
328
+
329
+ A plan has one billing period, fixed when the plan is created. Everything it
330
+ meters resets on that period, measured from each customer's own anchor — which
331
+ is why two customers on the same monthly plan renew on different days.
332
+
333
+ ```python
334
+ plan = meterbase.plans.create(
335
+ key="pro",
336
+ name="Pro",
337
+ cycle="monthly", # daily · weekly · monthly · quarterly · half_yearly · yearly
338
+ )
339
+
340
+ meterbase.plans.update(plan.id, name="Pro Plus")
341
+ meterbase.plans.archive(plan.id) # stops new assignments, keeps existing ones
342
+ ```
343
+
344
+ `cycle` is not patchable. Changing it would re-date every period the plan's
345
+ customers were already measured against, so a different cadence is a different
346
+ plan — move a customer with `customers.plan.assign`.
347
+
348
+ ### Plan allowances
349
+
350
+ What a plan grants, one meter at a time. An allowance says _how much_, never
351
+ _how often_: the period is the plan's cycle. There is no update and no delete —
352
+ an edit appends the next version, so which amount applied when stays derivable.
353
+
354
+ ```python
355
+ # The recurring cap, refreshed every period.
356
+ meterbase.plans.allowances.set(
357
+ plan.id,
358
+ meter_id=meter.id,
359
+ amount=1_000_000, # or no_cap=True
360
+ )
361
+
362
+ # A signup bonus beside it, minted once when the plan is assigned.
363
+ meterbase.plans.allowances.set(
364
+ plan.id,
365
+ meter_id=meter.id,
366
+ amount=500,
367
+ kind="one_time",
368
+ valid_for={"months": 3, "days": 0},
369
+ )
370
+
371
+ versions = meterbase.plans.allowances.list(plan.id).data
372
+ ```
373
+
374
+ Each `(plan, meter)` pair carries up to two lineages, `recurring` and
375
+ `one_time`, numbering themselves separately. `kind` defaults to `recurring`.
376
+
377
+ **A soft cap** keeps the amount and drops the refusal: included usage, then
378
+ overage.
379
+
380
+ ```python
381
+ meterbase.plans.allowances.set(
382
+ plan.id, meter_id=meter.id, amount=5_000, allow_overage=True
383
+ )
384
+ ```
385
+
386
+ Once `amount` is used up, `check` and `reserve` admit rather than refuse, with
387
+ `allow_overage=True` on the answer, and the usage past it is overage: read it
388
+ as `overage_used` on [the entitlement](#how-much-they-have-used). Rate limits
389
+ still refuse, thresholds still fire — a mark over 100 is an overage alert — and
390
+ a grant is spent before any overage is. `allow_overage` defaults to `False` and
391
+ is refused beside `no_cap` and on `one_time`. Like the amount, it is a term of
392
+ the version, so turning it on or off follows `apply_mode`: at each customer's
393
+ next cycle by default.
394
+
395
+ ### A customer's plan
396
+
397
+ A customer's plan is an append-only history, not a field: assigning is the only
398
+ write, and it serves both a first plan and every later change. What differs
399
+ between the changes is **when** the new plan starts and **what happens to the
400
+ period the change lands in** — so each of those is its own call.
401
+
402
+ #### Putting them on a plan
403
+
404
+ ```python
405
+ meterbase.customers.plan.assign(customer.id, plan_id=plan.id)
406
+ ```
407
+
408
+ A first assignment is always recorded as `immediate`: there is no period to
409
+ wait out. It is the one call that takes `cycle_anchor`, which puts their
410
+ billing periods on a date they already have — migrating them from another
411
+ system, say. Their renewal day is fixed from here and does not move again
412
+ unless you `restart` them.
413
+
414
+ #### Moving them to another plan
415
+
416
+ | Call | The new plan starts | This period's cap |
417
+ | ----------------------------- | ------------------- | ------------------------------------------------ |
418
+ | `change_at_next_cycle` | at their renewal | untouched — they keep what they paid for |
419
+ | `change_now` | now | the new plan's amount, for the whole period |
420
+ | `change_now` + `"prorate"` | now | the two plans weighted by how long each was held |
421
+ | `restart` | now | a **fresh period** starts today |
422
+
423
+ ```python
424
+ # Upgrade them at their renewal. Nothing is split, so nothing is pro-rated.
425
+ meterbase.customers.plan.change_at_next_cycle(customer.id, plan=pro.id)
426
+
427
+ # Move them now. Usage already spent still counts, so a downgrade can deny
428
+ # until the period ends.
429
+ meterbase.customers.plan.change_now(customer.id, plan=starter.id)
430
+
431
+ # Move them now and split the period fairly: 1,000/mo held for half a month
432
+ # then 400/mo for the other half is a cap of 700.
433
+ meterbase.customers.plan.change_now(
434
+ customer.id, plan=starter.id, reconciliation="prorate"
435
+ )
436
+
437
+ # Start over today: the running period closes where the change lands and
438
+ # keeps its usage, and the new plan's full amount opens at once.
439
+ meterbase.customers.plan.restart(customer.id, plan=pro.id)
440
+ ```
441
+
442
+ **`restart` is the one that moves their renewal day**, because it re-anchors
443
+ them to today. The other three leave it exactly where it was.
444
+
445
+ `change(customer_id, plan=..., effective=..., reconciliation=...)` is the same
446
+ call with both knobs in the open, for when they are chosen at runtime. `assign`
447
+ is the wire shape underneath all four, and still takes `plan_id`.
448
+
449
+ #### Two plans on different cycles
450
+
451
+ A weekly plan and a monthly one share no boundary to wait for and no period to
452
+ weight, so `change_at_next_cycle` and `change_now` + `"prorate"` both raise
453
+ `CycleChangeRequiresResetError`. `restart` is the move that works, and plain
454
+ `change_now` is the other way out — it keeps the period that is running.
455
+
456
+ ```python
457
+ from meterbase import CycleChangeRequiresResetError
458
+
459
+ try:
460
+ meterbase.customers.plan.change_at_next_cycle(customer.id, plan=weekly.id)
461
+ except CycleChangeRequiresResetError:
462
+ meterbase.customers.plan.restart(customer.id, plan=weekly.id)
463
+ ```
464
+
465
+ #### Reading where they stand
466
+
467
+ ```python
468
+ current = meterbase.customers.plan.retrieve(customer.id) # or None
469
+ scheduled = meterbase.customers.plan.pending(customer.id) # or None
470
+ trail = meterbase.customers.plan.history(customer.id).data
471
+ ```
472
+
473
+ `retrieve` is the plan **in force now**, which is not always the newest
474
+ instruction — a change dated ahead does not govern yet — and is `None` for a
475
+ customer holding no plan. That is a default deny rather than a failure, so it
476
+ is an answer here rather than a raised `NotFoundError`; a customer who does not
477
+ exist at all still raises one.
478
+
479
+ `pending` is the change waiting to land, and at most one ever is: a newer
480
+ instruction supersedes the one before it. `history` is the whole trail,
481
+ superseded rows included, newest first — it is the audit log of what was asked
482
+ for, not only of what happened.
483
+
484
+ To call off a change that has not landed:
485
+
486
+ ```python
487
+ meterbase.customers.plan.cancel_scheduled_change(customer.id)
488
+ ```
489
+
490
+ Naming the plan a customer already holds is what supersedes a pending
491
+ instruction, and writes no new row of its own — so this reads the plan in force
492
+ and names it back. It returns the assignment still in force, or `None` for a
493
+ customer holding no plan, who can have nothing pending.
494
+
495
+ #### How much they have used
496
+
497
+ ```python
498
+ entitlement = meterbase.customers.entitlement(customer.id) # or None
499
+
500
+ for meter in entitlement.meters if entitlement else []:
501
+ cap = "unlimited" if meter.amount is None else meter.amount
502
+ print(meter.meter_id, f"{meter.used} of {cap}")
503
+ ```
504
+
505
+ `entitlement` is the plan's side of the current period, one entry per meter the
506
+ plan meters: the read behind a usage bar in your own app. `used` is what
507
+ `amount` is measured against, `total` adds what grants paid for, and
508
+ `available` is what `check` would answer. `overage_used` is how far `used` has
509
+ run past `amount` — what a [soft cap](#plan-allowances) bills for, and reported
510
+ under a hard cap too — and `None` where there is no amount; `allow_overage`
511
+ says whether the cap in effect is soft. Meters are named by id, and like
512
+ `retrieve` it is `None` for a customer holding no plan.
513
+
514
+ Unlike `check`, it is not answered from a cache, so read it where usage is
515
+ shown rather than on every request.
516
+
517
+ #### Closed periods
518
+
519
+ `customers.periods.last` is the newest period that has ended, with the figures
520
+ to bill from: per meter, the cap `check` answered at its last instant, `used`
521
+ against it, and `overage_used`, which is what a soft cap bills; `total - used`
522
+ is what grants absorbed. They come from the counters behind `check`, which
523
+ count each event exactly once. [Usage by day](#usage-by-day) is for where
524
+ usage went, not for billing.
525
+
526
+ A billing job reads the newest, then walks back by passing each `from_` as
527
+ `before`, until it reaches the `to` it last billed. `None` ends the walk:
528
+ nothing closed before that, or no plan at all.
529
+
530
+ ```python
531
+ from meterbase.types import ClosedPeriod
532
+
533
+ unbilled: list[ClosedPeriod] = []
534
+ period = meterbase.customers.periods.last(customer.id)
535
+
536
+ while period and period.to != last_billed_to:
537
+ unbilled.insert(0, period) # oldest first
538
+ period = meterbase.customers.periods.last(customer.id, before=period.from_)
539
+ ```
540
+
541
+ The period's `from` is a Python keyword, so it reads as `period.from_` — or as
542
+ `period["from"]`, which reads any field exactly as the engine sent it.
543
+
544
+ **Pass `from_` back verbatim.** It carries microseconds, and a `before` short
545
+ of one names the period before it. That is why `before` is a string, and why
546
+ the timestamps on every model stay strings: re-formatting a `datetime` is one
547
+ way to lose the precision, or the offset, that makes them exact. Keep `to` as it
548
+ came too: it is what the loop compares.
549
+
550
+ For a minute after a period ends, while a track dated inside it may still be
551
+ committing, `last` raises `PeriodSettlingError`. Read again after its
552
+ `retry_after` seconds; from then on the answer never changes. It is not retried
553
+ for you: the wait runs to a minute, and whether to sit it out is the job's
554
+ call.
555
+
556
+ #### Usage by day
557
+
558
+ ```python
559
+ week = meterbase.customers.usage(customer.id, days=7)
560
+ period = meterbase.customers.usage(customer.id, period="current") # or None
561
+ calls = meterbase.meters.usage(meter_id, days=30)
562
+ ```
563
+
564
+ Usage per UTC day: `days` is 1–90 days ending today, and a customer can also
565
+ be read by `period`, `"current"` or `"last"`. Each meter lists every day that
566
+ has begun, zeros included; a customer's meters with no usage in the range are
567
+ left out. A period the customer never had is `None`.
568
+
569
+ Figures can be up to five minutes old. `check` and `entitlement` are live.
570
+
571
+ ### A customer's allowances
572
+
573
+ Capacity on top of whatever the plan gives.
574
+
575
+ ```python
576
+ grant = meterbase.customers.allowances.grant(
577
+ customer.id,
578
+ meter_id=meter.id,
579
+ amount=500,
580
+ source="purchased", # or "bonus" · "manual"
581
+ )
582
+
583
+ grants = meterbase.customers.allowances.list(customer.id, include_closed=True).data
584
+
585
+ meterbase.customers.allowances.revoke(customer.id, grant.id)
586
+ ```
587
+
588
+ Revoking withdraws what is left without erasing what was consumed, and is
589
+ idempotent. Grants the engine minted itself from a plan's `one_time` allowance
590
+ appear here with `source="plan"`; they cannot be created through `grant`.
591
+
592
+ ## Webhooks
593
+
594
+ Meterbase posts to your endpoints when a customer crosses a usage threshold.
595
+ Add an endpoint in the dashboard under **Webhooks**; each has its own signing
596
+ secret, shown once when you add it and revealable from its settings.
597
+
598
+ | Event | When |
599
+ | --------------------- | -------------------------------------------------------------------------------------- |
600
+ | `threshold.crossed` | a customer's usage of a meter crosses one of their thresholds |
601
+ | `entitlement.reached` | that crossing at 100% of their plan's entitlement, sent as well as `threshold.crossed` |
602
+
603
+ ```json
604
+ {
605
+ "id": "0199c3b1-3f05-7a90-b6e2-49c817d05fa3",
606
+ "type": "threshold.crossed",
607
+ "timestamp": "2026-09-18T14:08:12Z",
608
+ "data": {
609
+ "customer_id": "acct_1",
610
+ "meter_id": "ai_tokens",
611
+ "plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
612
+ "threshold": 80,
613
+ "used": 801400,
614
+ "entitlement": 1000000,
615
+ "period": {
616
+ "start": "2026-09-01T00:00:00Z",
617
+ "end": "2026-10-01T00:00:00Z"
618
+ },
619
+ "usage_event_id": "0199c3b0-9e41-7d2a-8c55-2f6e1a3bc738"
620
+ }
621
+ }
622
+ ```
623
+
624
+ `used` and `entitlement` are the plan's figures at the crossing; neither counts
625
+ grants, so ask `check` for capacity. `entitlement.reached` marks the plan's
626
+ entitlement, not spent capacity: a grant can still have balance, and a soft cap
627
+ admits past it.
628
+
629
+ ### Verifying a delivery
630
+
631
+ Verify every request before you trust it. `verify_webhook` checks the signature
632
+ and the timestamp, then returns the event:
633
+
634
+ ```python
635
+ from flask import Flask, request
636
+
637
+ from meterbase import WebhookVerificationError, verify_webhook
638
+ from meterbase.types import EntitlementReachedEvent
639
+
640
+ app = Flask(__name__)
641
+
642
+
643
+ @app.post("/meterbase")
644
+ def meterbase_webhook():
645
+ try:
646
+ event = verify_webhook(
647
+ payload=request.get_data(), # the raw body: re-serialized JSON will not verify
648
+ headers=request.headers,
649
+ secret=os.environ["METERBASE_WEBHOOK_SECRET"],
650
+ )
651
+ except WebhookVerificationError:
652
+ return "", 400
653
+
654
+ if isinstance(event, EntitlementReachedEvent):
655
+ ... # event.data.customer_id has used their plan's allowance for event.data.meter_id
656
+ return "", 204
657
+ ```
658
+
659
+ In FastAPI or Starlette, pass `await request.body()` and `request.headers`; in
660
+ Django, `request.body` and `request.headers`. Header names are matched without
661
+ regard to case, so a plain `dict` works too.
662
+
663
+ The event is a `ThresholdCrossedEvent` or an `EntitlementReachedEvent`, so
664
+ `isinstance` tells them apart. New types can be added: one this release does not
665
+ know reads as a plain `WebhookEvent`, so ignore any you do not handle.
666
+ `verify_webhook` is synchronous, and fast enough to call from an async handler.
667
+
668
+ Without the SDK, it is [Standard Webhooks](https://www.standardwebhooks.com):
669
+
670
+ 1. Each request carries `webhook-id`, `webhook-timestamp` (Unix seconds) and
671
+ `webhook-signature`.
672
+ 2. Compute HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed
673
+ with the base64-decoded part of the secret after `whsec_`.
674
+ 3. `webhook-signature` is a space-separated list of `v1,{base64}`. Accept the
675
+ request if any of them equals yours, compared in constant time.
676
+ 4. Refuse a timestamp more than five minutes from your clock.
677
+
678
+ ### Responding and retries
679
+
680
+ Answer with a 2xx within 10 seconds. Anything else is a failure, a redirect
681
+ included: Meterbase does not follow them.
682
+
683
+ | Attempt | After the one before |
684
+ | ------- | ------------------------------- |
685
+ | 1 | within a minute of the crossing |
686
+ | 2 | 1 minute |
687
+ | 3 | 6 minutes |
688
+ | 4 | 36 minutes |
689
+ | 5 | 3 hours 36 minutes |
690
+ | 6 | 21 hours 36 minutes |
691
+
692
+ Each wait varies by up to 10%. An event keeps its `id` on every attempt and
693
+ every endpoint, so dedupe on it, and do not rely on order. The dashboard logs
694
+ every attempt for 30 days and can retry a delivery by hand.
695
+
696
+ An answer of `410 Gone` pauses the endpoint, as does a failure a day into a
697
+ streak of them. A paused endpoint holds new deliveries until you resume it,
698
+ with or without replaying them.
699
+
700
+ ### Rotating a secret
701
+
702
+ Rotate from the endpoint's settings. For the next 24 hours both secrets sign
703
+ every request, so deploy the new one at your own pace; while you switch over,
704
+ `secret` takes both: `secret=[new_secret, old_secret]`.
705
+
706
+ ## Errors
707
+
708
+ Every failure is a `MeterbaseError`. Catch the specific one you care about, or
709
+ the base class for all of them.
710
+
711
+ ```python
712
+ from meterbase import ConflictError, RateLimitError
713
+
714
+ try:
715
+ meterbase.customers.create(external_id="acct_1")
716
+ except ConflictError as error:
717
+ ... # error.code == "customer_external_id_taken"
718
+ except RateLimitError as error:
719
+ ... # error.retry_after, in seconds
720
+ ```
721
+
722
+ | Class | When |
723
+ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
724
+ | `AuthenticationError` | 401 — unknown, revoked or malformed key |
725
+ | `PermissionDeniedError` | 403 — the key may not use this route |
726
+ | `NotFoundError` | 404 |
727
+ | `ConflictError` | 409 — a key or external id is taken |
728
+ | `PeriodSettlingError` | 409 — a `ConflictError`: the period ended under a minute ago, so read it again after `retry_after` seconds |
729
+ | `InvalidRequestError` | 422 — a field failed validation; or `400 invalid_query`, a query parameter that did |
730
+ | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
731
+ | `InvalidIdempotencyKeyError` | 422 — an `InvalidRequestError`: the key was not a UUIDv7 |
732
+ | `TooLateError` | 422 — an `InvalidRequestError`: the key is over an hour from the engine's clock, which it carries on `server_time`. Nothing was recorded |
733
+ | `ReservationMismatchError` | 422 — an `InvalidRequestError`: the reservation was made for a different customer or meter |
734
+ | `RateLimitError` | 429 |
735
+ | `ServerError` | 5xx |
736
+ | `APIConnectionError` / `APITimeoutError` | the request never got an answer |
737
+ | `WebhookVerificationError` | `verify_webhook`: not a delivery Meterbase signed in the last five minutes |
738
+
739
+ `APIError` carries `status`, `code`, `message`, `request_id` and the parsed
740
+ `body`. Branch on `code`, which is stable; `message` is for humans and may
741
+ change. The two connection errors are named so they do not shadow Python's own
742
+ `ConnectionError` and `TimeoutError`. Every error pickles with its fields, so a
743
+ task queue can carry one back from a worker.
744
+
745
+ `error_from_response(status=..., code=..., message=...)` builds the error the
746
+ SDK would raise for a response, for realistic errors in your own tests.
747
+
748
+ ## Options
749
+
750
+ ```python
751
+ Meterbase(
752
+ api_key="mb_sk_live_…",
753
+ base_url="https://api.meterbase.dev",
754
+ timeout=10.0, # seconds, per attempt
755
+ max_retries=2,
756
+ http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
757
+ )
758
+ ```
759
+
760
+ `timeout` bounds each attempt — connecting, sending, and each wait for the
761
+ engine to answer — not the whole call. Pass an `http_client` for proxies,
762
+ limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
763
+ `httpx.AsyncClient` for `AsyncMeterbase`.
764
+
765
+ For other settings on some calls, derive a client with them. It shares the
766
+ original's connections, and its clock:
767
+
768
+ ```python
769
+ meterbase.with_options(timeout=2.0, max_retries=0).check(
770
+ customer_id="acct_1", meter_id="ai_tokens"
771
+ )
772
+ ```
773
+
774
+ **Cancelling** an async call — `task.cancel()`, or a deadline of your own such as
775
+ `asyncio.timeout` — ends it where it stands, mid-attempt or in the wait before
776
+ a retry, and the cancellation reaches you untouched. Nothing is sent after it:
777
+ an attempt already on the wire may still land, but it is never replayed, so a
778
+ cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
779
+ bound it with `timeout` and `max_retries` instead.
780
+
781
+ **Retries** apply to `GET`, `DELETE`, `track` and `reserve`, on connection
782
+ failures, timeouts, 408, 429 and 5xx, with exponential backoff and full jitter.
783
+ `Retry-After` is honoured when the engine sends it, up to eight seconds. Every
784
+ other `POST` and `PATCH` is never replayed, because a retried create could
785
+ produce a second row.
786
+
787
+ `track` and `reserve` are the exception because they carry an idempotency key,
788
+ and because the SDK fixes that key **before** the retry loop starts: every
789
+ attempt of one call names the same logical event, so the engine replays it
790
+ instead of counting the work twice. If you retry `track` yourself, pass your
791
+ own `idempotency_key` and keep it the same across attempts for exactly that
792
+ reason — and do it inside the hour the engine holds that key for.
793
+
794
+ ## Types
795
+
796
+ Every answer is the engine's JSON as it arrived, `snake_case` included, so this
797
+ SDK reads the same as the API reference and cannot drift from it through a
798
+ translation layer. Each field is a typed, read-only attribute — `result.allowed`,
799
+ `hold.rate_limits[0].remaining` — and a nested object is a model too.
800
+
801
+ `model.to_dict()` returns the JSON exactly as it was sent, and `model["field"]`
802
+ reads one field of it: the way to reach a field the engine has added since this
803
+ release. Times stay RFC 3339 strings, as sent. The models are in
804
+ `meterbase.types`, and they are type hints rather than validation: an answer
805
+ the engine shapes differently reaches you rather than failing the call that
806
+ carried it.
807
+
808
+ ## Versioning
809
+
810
+ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
811
+ change that will not run against your code cannot arrive as a patch. The SDK
812
+ follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
813
+
814
+ ## Development
815
+
816
+ ```bash
817
+ uv sync
818
+ make test # pytest, against a local test double
819
+ make check # generated code, types, lint, format, build, tests, packaging
820
+ ```
821
+
822
+ ### One client, generated twice
823
+
824
+ The async client in `src/meterbase/_async` is the source. `make unasync`
825
+ generates the sync client in `src/meterbase/_sync` from it — `async def`
826
+ becomes `def`, `await` goes, `AsyncMeterbase` becomes `Meterbase` — and the
827
+ async tests in `tests/_async` into sync ones in `tests/_sync`. Edit the async
828
+ side and regenerate; `make check` fails when the two disagree. The one place
829
+ they genuinely differ, sleeping and running two calls at once, is `_io.py`,
830
+ written by hand on both sides.
831
+
832
+ ### Two layers of tests
833
+
834
+ **`make test`** runs against a real HTTP server on an ephemeral port rather
835
+ than a stubbed transport, so request building, headers, status handling and the
836
+ retry loop are exercised for real, for both clients. It can make the server
837
+ drop a socket or stall, which is the only way to test the retry, timeout and
838
+ cancellation paths.
839
+
840
+ What it cannot check is whether those scripted responses match the engine: a
841
+ route that does not exist, a renamed field or a changed error code all pass
842
+ here and fail in production.
843
+
844
+ **`make contract`** closes that gap by running against a live engine.
845
+
846
+ ```bash
847
+ cp .env.example .env # then fill in a key, or export the two variables
848
+ make contract
849
+ ```
850
+
851
+ It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
852
+ allowances become real entitlement a `check` can see; that tracked usage takes
853
+ exactly that much away, that a replay takes nothing, and that a reused key
854
+ replays whatever the body says; that a hold's commit records what the work cost
855
+ and gives the rest back; that a rate limit comes back on every `check` and
856
+ refuses with the allowance untouched; that a soft cap admits and holds past its
857
+ amount and reports the overage; that a period a `restart` closes settles for a
858
+ minute, which the run waits out, and then reads final; that plan changes
859
+ schedule, cancel and re-anchor as documented; and — reading each model's
860
+ annotations — that the engine's fields still match the SDK's types exactly, in
861
+ both directions.
862
+
863
+ > **It creates and deletes real data.** Point it at a test workspace. It only
864
+ > touches resources it created, tagging each with a per-run id, and cleans up
865
+ > afterwards; soft-deleted customers and archived meters and plans remain, as
866
+ > the engine intends. It is outside `make test` on purpose — a mutating suite
867
+ > should only run because someone asked for it.