meterflow 0.1.1__tar.gz → 0.2.0__tar.gz

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 (22) hide show
  1. {meterflow-0.1.1 → meterflow-0.2.0}/PKG-INFO +125 -8
  2. {meterflow-0.1.1 → meterflow-0.2.0}/README.md +124 -7
  3. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_version.py +1 -1
  4. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/client.py +6 -0
  5. meterflow-0.2.0/src/meterflow/resources/__init__.py +1 -0
  6. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/credits.py +19 -9
  7. meterflow-0.2.0/src/meterflow/resources/portal.py +52 -0
  8. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/subscriptions.py +19 -9
  9. meterflow-0.2.0/src/meterflow/resources/webhooks.py +75 -0
  10. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/types.py +117 -4
  11. meterflow-0.1.1/src/meterflow/resources/__init__.py +0 -1
  12. {meterflow-0.1.1 → meterflow-0.2.0}/LICENSE +0 -0
  13. {meterflow-0.1.1 → meterflow-0.2.0}/pyproject.toml +0 -0
  14. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/__init__.py +0 -0
  15. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_http.py +0 -0
  16. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_retry.py +0 -0
  17. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/errors.py +0 -0
  18. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/py.typed +0 -0
  19. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/entitlements.py +0 -0
  20. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/plans.py +0 -0
  21. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/usage.py +0 -0
  22. {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterflow
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
5
5
  Project-URL: Homepage, https://meter-flow.com
6
6
  Project-URL: Documentation, https://meter-flow.com/guide
@@ -75,6 +75,7 @@ Track what your customers use, enforce credit balances, gate features, and manag
75
75
  - [Usage events](#usage-events)
76
76
  - [Subscriptions](#subscriptions)
77
77
  - [Plans](#plans)
78
+ - [Webhook receipts](#webhook-receipts)
78
79
  - [Entitlements — ask before you act](#entitlements--ask-before-you-act)
79
80
  - [Idempotency — safe retries for writes](#idempotency--safe-retries-for-writes)
80
81
  - [Automatic retries](#automatic-retries)
@@ -235,13 +236,26 @@ bal["total_granted"] # "5250.000000"
235
236
  bal["total_consumed"] # "1705.000000"
236
237
  ```
237
238
 
238
- **Transactions** — the full history, newest first:
239
+ **Transactions** — the history, newest first, **a page at a time**:
239
240
 
240
241
  ```python
241
- for t in client.credits.transactions("customer_123"):
242
+ page = client.credits.transactions("customer_123") # 50 rows by default
243
+ for t in page["data"]:
242
244
  print(t["transaction_type"], t["amount"], t["balance_after"], t["description"], t["created_at"])
245
+
246
+ # The whole history, page by page. `next_cursor` is None once there is nothing after it.
247
+ cursor = None
248
+ while True:
249
+ page = client.credits.transactions("customer_123", limit=200, cursor=cursor)
250
+ handle(page["data"])
251
+ cursor = page["next_cursor"]
252
+ if cursor is None:
253
+ break
243
254
  ```
244
255
 
256
+ > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you; do not build one, and do
257
+ > not read anything out of it. `limit` is 1–200 and defaults to 50.
258
+
245
259
  > **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
246
260
 
247
261
  ### Usage events
@@ -254,7 +268,7 @@ Record an event every time a customer does the thing you charge for. Events are
254
268
  client.usage.record({
255
269
  "event_name": "images.generated",
256
270
  "customer_external_id": "customer_123",
257
- "value": 1, # what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
271
+ "value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
258
272
  "properties": {}, # free-form context; {} when unused
259
273
  "timestamp": "2026-09-30T10:00:00Z", # optional — defaults to arrival time on the server
260
274
  })
@@ -279,6 +293,43 @@ for start in range(0, len(events), MAX_BATCH_EVENTS):
279
293
  client.usage.record_batch(chunk, idempotency_key=f"job-42:chunk-{start // MAX_BATCH_EVENTS}")
280
294
  ```
281
295
 
296
+ ### Record quantities, not calls
297
+
298
+ **A batch of 100 events is still 100 events.** Batching saves HTTP requests, not quota: each item in a `record_batch` is its own record, and your plan's monthly allowance counts records. The thing that actually moves the number is sending **one event that carries the quantity**.
299
+
300
+ If your meter's aggregation is `sum`, MeterFlow adds up the `value` of its events — so one event with `value=100` is **one** event against your monthly allowance and **100 units** against the meter. Record per unit of work instead of per API call:
301
+
302
+ ```python
303
+ # ❌ One event per call — 100 calls is 100 events against your plan.
304
+ for chunk in completion.chunks:
305
+ client.usage.record(
306
+ {
307
+ "event_name": "tokens.used",
308
+ "customer_external_id": "customer_123",
309
+ "value": 1,
310
+ "properties": {},
311
+ }
312
+ )
313
+
314
+ # ✅ One event carrying the quantity — 1 event against your plan, 1,300 units on the meter.
315
+ client.usage.record(
316
+ {
317
+ "event_name": "tokens.used",
318
+ "customer_external_id": "customer_123",
319
+ "value": 1300, # a `sum` meter adds these up
320
+ "properties": {"model": "gpt-4o-mini"},
321
+ }
322
+ )
323
+ ```
324
+
325
+ This is usually the difference between fitting a plan and outgrowing it: 1,000 users × 100 calls a day is ~3M events a month recorded per call, and ~30k recorded per session. Aggregate in your app over whatever window you can afford to lose on a crash — a request, a job, a minute — then send one event.
326
+
327
+ Two things to keep in mind:
328
+
329
+ - **It only helps a `sum` meter.** A `count` meter ignores `value` and counts occurrences, so there each occurrence has to be its own event. `max` and `min` read `value` as a measurement, not a quantity to add, and `unique_count` does not read it at all — it counts distinct values of the property its meter names.
330
+ - **`value` is optional.** Leave it out and MeterFlow fills it in: for a `sum`, `max` or `min` meter that names an `aggregation_field`, the numeric value of that property on the event; otherwise `1`. So the event above can be written with `"properties": {"tokens": 1300}` and no `"value"`, as long as the meter's `aggregation_field` is `tokens`. An explicit `value` always wins, a property that is absent or is not a number falls through to `1`, and a negative one is refused with a `422`.
331
+ - **Billing is unchanged.** A metered plan limit charges per unit — the event's `value` — so 100 units cost the same whether they arrived as 100 events or one. You are not rounding anyone's bill by batching.
332
+
282
333
  **Summary** — a customer's usage, broken down by meter:
283
334
 
284
335
  ```python
@@ -317,8 +368,12 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
317
368
  **List / get:**
318
369
 
319
370
  ```python
320
- everyone = client.subscriptions.list() # whole project (in your key's environment)
371
+ page = client.subscriptions.list() # whole project (in your key's environment)
321
372
  theirs = client.subscriptions.list(customer_id="customer_123") # one customer
373
+ more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
374
+
375
+ for subscription in page["data"]:
376
+ print(subscription["customer_external_id"], subscription["status"])
322
377
  one = client.subscriptions.get(sub["id"])
323
378
  ```
324
379
 
@@ -355,6 +410,27 @@ plan["features"] # ["sso", "priority-support"] — the on/off keys check(
355
410
 
356
411
  Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
357
412
 
413
+ ### Webhook receipts
414
+
415
+ Webhooks are **read-only** through the SDK too: endpoints are registered in the dashboard, where the signing secret is shown once and never again. What the SDK gives you is the other half — which endpoints exist, and what MeterFlow actually sent them.
416
+
417
+ ```python
418
+ endpoints = client.webhooks.list() # this project's endpoints
419
+ receipts = client.webhooks.deliveries(endpoint_id) # newest first
420
+ receipts[0]["event_type"] # "usage.recorded"
421
+ receipts[0]["status"] # "pending" | "retrying" | "success" | "failed"
422
+ receipts[0]["response_code"] # what your server answered — 200, 500, or None if it never answered
423
+ receipts[0]["attempts"] # how many times we have tried
424
+ receipts[0]["next_retry_at"] # when we will try again, or None
425
+ receipts[0]["payload"] # exactly what we sent
426
+ ```
427
+
428
+ Typical use: answering "did that event reach my endpoint?" from your own tooling instead of opening the dashboard — and reading `response_body` when the answer is no.
429
+
430
+ Two things to know. The list is capped at the **most recent 100** attempts per endpoint; it is a debugging surface, not an export. And it follows your key: a `mf_test_*` key reads test receipts, a `mf_live_*` key live ones, while the endpoint list itself is shared by both worlds, because an endpoint is configuration and receives events from each.
431
+
432
+ > This is not the same as [verifying webhooks](#verifying-webhooks) below. That proves a request your server just received really came from us; this reads what we sent.
433
+
358
434
  ### Entitlements — ask before you act
359
435
 
360
436
  The one call to make **before** doing work for a customer: "may they use this feature — and how much is left?" A **hard** limit says no here; recording usage afterwards never blocks, so an app that skips this call gets an honest ledger but no enforcement.
@@ -363,7 +439,7 @@ The one call to make **before** doing work for a customer: "may they use this fe
363
439
  gate = client.entitlements.check("customer_123", "images.generated")
364
440
 
365
441
  if not gate["allowed"]:
366
- # gate["reason"]: "hard_limit_reached" | "no_subscription" | "not_in_plan" | "insufficient_credits"
442
+ # gate["reason"]: "hard_limit_reached" | "no_subscription" | "not_in_plan" | "insufficient_credits" | "spend_cap_reached"
367
443
  return show_upgrade_prompt(gate) # gate["used"] / gate["included"] / gate["period_end"] are there for the copy
368
444
 
369
445
  generate_image()
@@ -388,6 +464,30 @@ result["entitlements"]
388
464
 
389
465
  Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
390
466
 
467
+ ### The balance portal — a page you don't have to build
468
+
469
+ Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
470
+ Call it from your server; send the ``url`` to that customer.
471
+
472
+ ```python
473
+ link = client.portal.create_session({
474
+ "customer_external_id": "customer_123",
475
+ "topup_url": "https://acme.test/billing", # your checkout — "buy more" goes here
476
+ "return_url": "https://acme.test/app", # where "done" goes
477
+ "expires_in_minutes": 60, # default 60, maximum 1440
478
+ })
479
+
480
+ send_email(customer.email, f"Your balance: {link['url']}")
481
+ ```
482
+
483
+ The token in that link reads **that one customer's** balance and ledger, in the environment of the
484
+ key you minted it with, and nothing else: it cannot write, cannot name another customer, and is
485
+ refused everywhere else in the API. It is never your API key — never send that to anyone.
486
+
487
+ > **MeterFlow never takes the payment.** `topup_url` is a link we carry and hand back to the page;
488
+ > what happens there is between you and your own payment provider. Grant the credits with
489
+ > `credits.grant()` when your checkout tells you it was paid.
490
+
391
491
  ## Idempotency — safe retries for writes
392
492
 
393
493
  Connections drop. When your app can't tell whether a write arrived, the correct move is to send it again **with the same idempotency key** — MeterFlow recognises the key and acts only once:
@@ -402,6 +502,17 @@ client.credits.deduct(
402
502
 
403
503
  Every write method accepts the keyword: `credits.grant/deduct`, `usage.record/record_batch`, `subscriptions.create/update`. Use a key that identifies the *business operation* (order ID, job ID) — not a random value per attempt, which would defeat the purpose.
404
504
 
505
+ **A key is bound to the body you first used it with.** MeterFlow stores a fingerprint of the request alongside the key, so a retry only replays when it is genuinely the same request. Reuse the key with a *different* body and the call is refused with `ConflictError` (409) instead of quietly handing back the original record:
506
+
507
+ ```python
508
+ client.credits.grant({"customer_external_id": "customer_123", "amount": 100, "metadata": {}}, idempotency_key="welcome-123")
509
+
510
+ # Same key, different amount -> ConflictError. Nothing is written, and the first grant stands.
511
+ client.credits.grant({"customer_external_id": "customer_123", "amount": 250, "metadata": {}}, idempotency_key="welcome-123")
512
+ ```
513
+
514
+ That is deliberate: silently returning the first record would turn two different operations into one and report success. One key per operation, reused only to retry that same operation. Keys stay namespaced per environment, so a `mf_test_` and a `mf_live_` key never collide.
515
+
405
516
  ## Automatic retries
406
517
 
407
518
  The SDK retries transient failures for you — exponential backoff with jitter, 3 attempts by default:
@@ -434,7 +545,7 @@ Every error carries:
434
545
 
435
546
  - **`request_id`** — the API's `X-Request-ID` for that call. Include it when contacting support; it pinpoints the exact request in our logs.
436
547
  - **`status_code`**, **`error_type`**, and **`retryable`** — for programmatic handling and structured logging.
437
- - **`message`** (also `str(err)`) — the API's own sentence (e.g. `Plan limit reached: the Drip plan includes 5,000 usage events per month…`). A `ValidationError` appends its field detail (`Validation failed: amount: must be greater than 0`) and also exposes it structured as **`.fields`** (`[{"field": ..., "message": ...}]`).
548
+ - **`message`** (also `str(err)`) — the API's own sentence (e.g. `Plan limit reached: the Drip plan includes 50,000 usage events per month…`). A `ValidationError` appends its field detail (`Validation failed: amount: must be greater than 0`) and also exposes it structured as **`.fields`** (`[{"field": ..., "message": ...}]`).
438
549
 
439
550
  ```python
440
551
  from meterflow import MeterFlowError, InsufficientCreditsError, RateLimitError
@@ -491,6 +602,12 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
491
602
  | `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
492
603
  | `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
493
604
  | `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
605
+ | `balance.low` | a spend takes a customer's balance **down through** this endpoint's `low_balance_threshold` — a crossing, so it fires once and not again while they stay below. Raised by both doors that spend: `credits.deduct` and a metered usage charge. **Both crossing events follow from the endpoint's `low_balance_threshold`: set one and the API adds them, clear it and the API removes them — so a line is the only thing to configure.** | `customer_id`, `balance`, `threshold`, `suggested_topup` (the subscription's `topup_amount`, `null` when unset), `transaction_id` |
606
+ | `balance.exhausted` | a balance reaches zero, **or** a request is refused for want of credits (a deduction returning 402, or `entitlements.check` answering `insufficient_credits`). Unlike `balance.low` this repeats: every refused request is its own blocked request | `customer_id`, `balance`, `reason` (`balance_zero` / `insufficient_credits`), `requested_amount` and `feature` where they apply, `transaction_id` on the zero case |
607
+ | `subscription.renewed` | a billing period opens — either a paid period rolling forward or a **trial converting** to its first paid period, told apart by `reason` (the renewer emits no `subscription.updated` for either) | `customer_id`, `subscription_id`, `plan_id`, `status`, `reason` (`period_renewed` / `trial_converted`), `current_period_start`, `current_period_end`, `credits_granted` |
608
+ | `subscription.canceled` | a subscription's status becomes canceled — fired **alongside** `subscription.updated`, once, on the call that actually cancels | `customer_id`, `subscription_id`, `plan_id`, `canceled_at` |
609
+ | `balance.topped_up` | a grant takes a customer's balance **up through** this endpoint's `low_balance_threshold` — the upward mirror of `balance.low`, on the same per-endpoint line, so the endpoint told "low" is the one told "recovered". Subscribed together with `balance.low` by setting the threshold, never on its own | `customer_id`, `balance`, `threshold`, `amount`, `transaction_id` |
610
+ | `cap.reached` | a customer's credit spending reaches their subscription's `spend_cap` for the period — once per subscription per period, whether they landed on the cap or were refused by it. A refusal that still leaves headroom is not a reach and does not fire | `customer_id`, `subscription_id`, `cap`, `spent`, `period_start`, `period_end` |
494
611
 
495
612
  For example, a `credit.granted` delivery:
496
613
 
@@ -554,7 +671,7 @@ with MeterFlow(api_key=os.environ["METERFLOW_API_KEY"]) as client:
554
671
 
555
672
  # Support asks: "what's Ana's situation?"
556
673
  balance = client.credits.balance("ana")
557
- history = client.credits.transactions("ana")
674
+ history = client.credits.transactions("ana")["data"]
558
675
  usage = client.usage.summary("ana")
559
676
  entitlements = client.entitlements.get("ana")
560
677
  ```
@@ -23,6 +23,7 @@ Track what your customers use, enforce credit balances, gate features, and manag
23
23
  - [Usage events](#usage-events)
24
24
  - [Subscriptions](#subscriptions)
25
25
  - [Plans](#plans)
26
+ - [Webhook receipts](#webhook-receipts)
26
27
  - [Entitlements — ask before you act](#entitlements--ask-before-you-act)
27
28
  - [Idempotency — safe retries for writes](#idempotency--safe-retries-for-writes)
28
29
  - [Automatic retries](#automatic-retries)
@@ -183,13 +184,26 @@ bal["total_granted"] # "5250.000000"
183
184
  bal["total_consumed"] # "1705.000000"
184
185
  ```
185
186
 
186
- **Transactions** — the full history, newest first:
187
+ **Transactions** — the history, newest first, **a page at a time**:
187
188
 
188
189
  ```python
189
- for t in client.credits.transactions("customer_123"):
190
+ page = client.credits.transactions("customer_123") # 50 rows by default
191
+ for t in page["data"]:
190
192
  print(t["transaction_type"], t["amount"], t["balance_after"], t["description"], t["created_at"])
193
+
194
+ # The whole history, page by page. `next_cursor` is None once there is nothing after it.
195
+ cursor = None
196
+ while True:
197
+ page = client.credits.transactions("customer_123", limit=200, cursor=cursor)
198
+ handle(page["data"])
199
+ cursor = page["next_cursor"]
200
+ if cursor is None:
201
+ break
191
202
  ```
192
203
 
204
+ > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you; do not build one, and do
205
+ > not read anything out of it. `limit` is 1–200 and defaults to 50.
206
+
193
207
  > **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
194
208
 
195
209
  ### Usage events
@@ -202,7 +216,7 @@ Record an event every time a customer does the thing you charge for. Events are
202
216
  client.usage.record({
203
217
  "event_name": "images.generated",
204
218
  "customer_external_id": "customer_123",
205
- "value": 1, # what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
219
+ "value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
206
220
  "properties": {}, # free-form context; {} when unused
207
221
  "timestamp": "2026-09-30T10:00:00Z", # optional — defaults to arrival time on the server
208
222
  })
@@ -227,6 +241,43 @@ for start in range(0, len(events), MAX_BATCH_EVENTS):
227
241
  client.usage.record_batch(chunk, idempotency_key=f"job-42:chunk-{start // MAX_BATCH_EVENTS}")
228
242
  ```
229
243
 
244
+ ### Record quantities, not calls
245
+
246
+ **A batch of 100 events is still 100 events.** Batching saves HTTP requests, not quota: each item in a `record_batch` is its own record, and your plan's monthly allowance counts records. The thing that actually moves the number is sending **one event that carries the quantity**.
247
+
248
+ If your meter's aggregation is `sum`, MeterFlow adds up the `value` of its events — so one event with `value=100` is **one** event against your monthly allowance and **100 units** against the meter. Record per unit of work instead of per API call:
249
+
250
+ ```python
251
+ # ❌ One event per call — 100 calls is 100 events against your plan.
252
+ for chunk in completion.chunks:
253
+ client.usage.record(
254
+ {
255
+ "event_name": "tokens.used",
256
+ "customer_external_id": "customer_123",
257
+ "value": 1,
258
+ "properties": {},
259
+ }
260
+ )
261
+
262
+ # ✅ One event carrying the quantity — 1 event against your plan, 1,300 units on the meter.
263
+ client.usage.record(
264
+ {
265
+ "event_name": "tokens.used",
266
+ "customer_external_id": "customer_123",
267
+ "value": 1300, # a `sum` meter adds these up
268
+ "properties": {"model": "gpt-4o-mini"},
269
+ }
270
+ )
271
+ ```
272
+
273
+ This is usually the difference between fitting a plan and outgrowing it: 1,000 users × 100 calls a day is ~3M events a month recorded per call, and ~30k recorded per session. Aggregate in your app over whatever window you can afford to lose on a crash — a request, a job, a minute — then send one event.
274
+
275
+ Two things to keep in mind:
276
+
277
+ - **It only helps a `sum` meter.** A `count` meter ignores `value` and counts occurrences, so there each occurrence has to be its own event. `max` and `min` read `value` as a measurement, not a quantity to add, and `unique_count` does not read it at all — it counts distinct values of the property its meter names.
278
+ - **`value` is optional.** Leave it out and MeterFlow fills it in: for a `sum`, `max` or `min` meter that names an `aggregation_field`, the numeric value of that property on the event; otherwise `1`. So the event above can be written with `"properties": {"tokens": 1300}` and no `"value"`, as long as the meter's `aggregation_field` is `tokens`. An explicit `value` always wins, a property that is absent or is not a number falls through to `1`, and a negative one is refused with a `422`.
279
+ - **Billing is unchanged.** A metered plan limit charges per unit — the event's `value` — so 100 units cost the same whether they arrived as 100 events or one. You are not rounding anyone's bill by batching.
280
+
230
281
  **Summary** — a customer's usage, broken down by meter:
231
282
 
232
283
  ```python
@@ -265,8 +316,12 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
265
316
  **List / get:**
266
317
 
267
318
  ```python
268
- everyone = client.subscriptions.list() # whole project (in your key's environment)
319
+ page = client.subscriptions.list() # whole project (in your key's environment)
269
320
  theirs = client.subscriptions.list(customer_id="customer_123") # one customer
321
+ more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
322
+
323
+ for subscription in page["data"]:
324
+ print(subscription["customer_external_id"], subscription["status"])
270
325
  one = client.subscriptions.get(sub["id"])
271
326
  ```
272
327
 
@@ -303,6 +358,27 @@ plan["features"] # ["sso", "priority-support"] — the on/off keys check(
303
358
 
304
359
  Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
305
360
 
361
+ ### Webhook receipts
362
+
363
+ Webhooks are **read-only** through the SDK too: endpoints are registered in the dashboard, where the signing secret is shown once and never again. What the SDK gives you is the other half — which endpoints exist, and what MeterFlow actually sent them.
364
+
365
+ ```python
366
+ endpoints = client.webhooks.list() # this project's endpoints
367
+ receipts = client.webhooks.deliveries(endpoint_id) # newest first
368
+ receipts[0]["event_type"] # "usage.recorded"
369
+ receipts[0]["status"] # "pending" | "retrying" | "success" | "failed"
370
+ receipts[0]["response_code"] # what your server answered — 200, 500, or None if it never answered
371
+ receipts[0]["attempts"] # how many times we have tried
372
+ receipts[0]["next_retry_at"] # when we will try again, or None
373
+ receipts[0]["payload"] # exactly what we sent
374
+ ```
375
+
376
+ Typical use: answering "did that event reach my endpoint?" from your own tooling instead of opening the dashboard — and reading `response_body` when the answer is no.
377
+
378
+ Two things to know. The list is capped at the **most recent 100** attempts per endpoint; it is a debugging surface, not an export. And it follows your key: a `mf_test_*` key reads test receipts, a `mf_live_*` key live ones, while the endpoint list itself is shared by both worlds, because an endpoint is configuration and receives events from each.
379
+
380
+ > This is not the same as [verifying webhooks](#verifying-webhooks) below. That proves a request your server just received really came from us; this reads what we sent.
381
+
306
382
  ### Entitlements — ask before you act
307
383
 
308
384
  The one call to make **before** doing work for a customer: "may they use this feature — and how much is left?" A **hard** limit says no here; recording usage afterwards never blocks, so an app that skips this call gets an honest ledger but no enforcement.
@@ -311,7 +387,7 @@ The one call to make **before** doing work for a customer: "may they use this fe
311
387
  gate = client.entitlements.check("customer_123", "images.generated")
312
388
 
313
389
  if not gate["allowed"]:
314
- # gate["reason"]: "hard_limit_reached" | "no_subscription" | "not_in_plan" | "insufficient_credits"
390
+ # gate["reason"]: "hard_limit_reached" | "no_subscription" | "not_in_plan" | "insufficient_credits" | "spend_cap_reached"
315
391
  return show_upgrade_prompt(gate) # gate["used"] / gate["included"] / gate["period_end"] are there for the copy
316
392
 
317
393
  generate_image()
@@ -336,6 +412,30 @@ result["entitlements"]
336
412
 
337
413
  Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
338
414
 
415
+ ### The balance portal — a page you don't have to build
416
+
417
+ Mint a short-lived link to a MeterFlow-hosted page showing **one** customer's balance and ledger.
418
+ Call it from your server; send the ``url`` to that customer.
419
+
420
+ ```python
421
+ link = client.portal.create_session({
422
+ "customer_external_id": "customer_123",
423
+ "topup_url": "https://acme.test/billing", # your checkout — "buy more" goes here
424
+ "return_url": "https://acme.test/app", # where "done" goes
425
+ "expires_in_minutes": 60, # default 60, maximum 1440
426
+ })
427
+
428
+ send_email(customer.email, f"Your balance: {link['url']}")
429
+ ```
430
+
431
+ The token in that link reads **that one customer's** balance and ledger, in the environment of the
432
+ key you minted it with, and nothing else: it cannot write, cannot name another customer, and is
433
+ refused everywhere else in the API. It is never your API key — never send that to anyone.
434
+
435
+ > **MeterFlow never takes the payment.** `topup_url` is a link we carry and hand back to the page;
436
+ > what happens there is between you and your own payment provider. Grant the credits with
437
+ > `credits.grant()` when your checkout tells you it was paid.
438
+
339
439
  ## Idempotency — safe retries for writes
340
440
 
341
441
  Connections drop. When your app can't tell whether a write arrived, the correct move is to send it again **with the same idempotency key** — MeterFlow recognises the key and acts only once:
@@ -350,6 +450,17 @@ client.credits.deduct(
350
450
 
351
451
  Every write method accepts the keyword: `credits.grant/deduct`, `usage.record/record_batch`, `subscriptions.create/update`. Use a key that identifies the *business operation* (order ID, job ID) — not a random value per attempt, which would defeat the purpose.
352
452
 
453
+ **A key is bound to the body you first used it with.** MeterFlow stores a fingerprint of the request alongside the key, so a retry only replays when it is genuinely the same request. Reuse the key with a *different* body and the call is refused with `ConflictError` (409) instead of quietly handing back the original record:
454
+
455
+ ```python
456
+ client.credits.grant({"customer_external_id": "customer_123", "amount": 100, "metadata": {}}, idempotency_key="welcome-123")
457
+
458
+ # Same key, different amount -> ConflictError. Nothing is written, and the first grant stands.
459
+ client.credits.grant({"customer_external_id": "customer_123", "amount": 250, "metadata": {}}, idempotency_key="welcome-123")
460
+ ```
461
+
462
+ That is deliberate: silently returning the first record would turn two different operations into one and report success. One key per operation, reused only to retry that same operation. Keys stay namespaced per environment, so a `mf_test_` and a `mf_live_` key never collide.
463
+
353
464
  ## Automatic retries
354
465
 
355
466
  The SDK retries transient failures for you — exponential backoff with jitter, 3 attempts by default:
@@ -382,7 +493,7 @@ Every error carries:
382
493
 
383
494
  - **`request_id`** — the API's `X-Request-ID` for that call. Include it when contacting support; it pinpoints the exact request in our logs.
384
495
  - **`status_code`**, **`error_type`**, and **`retryable`** — for programmatic handling and structured logging.
385
- - **`message`** (also `str(err)`) — the API's own sentence (e.g. `Plan limit reached: the Drip plan includes 5,000 usage events per month…`). A `ValidationError` appends its field detail (`Validation failed: amount: must be greater than 0`) and also exposes it structured as **`.fields`** (`[{"field": ..., "message": ...}]`).
496
+ - **`message`** (also `str(err)`) — the API's own sentence (e.g. `Plan limit reached: the Drip plan includes 50,000 usage events per month…`). A `ValidationError` appends its field detail (`Validation failed: amount: must be greater than 0`) and also exposes it structured as **`.fields`** (`[{"field": ..., "message": ...}]`).
386
497
 
387
498
  ```python
388
499
  from meterflow import MeterFlowError, InsufficientCreditsError, RateLimitError
@@ -439,6 +550,12 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
439
550
  | `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
440
551
  | `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
441
552
  | `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
553
+ | `balance.low` | a spend takes a customer's balance **down through** this endpoint's `low_balance_threshold` — a crossing, so it fires once and not again while they stay below. Raised by both doors that spend: `credits.deduct` and a metered usage charge. **Both crossing events follow from the endpoint's `low_balance_threshold`: set one and the API adds them, clear it and the API removes them — so a line is the only thing to configure.** | `customer_id`, `balance`, `threshold`, `suggested_topup` (the subscription's `topup_amount`, `null` when unset), `transaction_id` |
554
+ | `balance.exhausted` | a balance reaches zero, **or** a request is refused for want of credits (a deduction returning 402, or `entitlements.check` answering `insufficient_credits`). Unlike `balance.low` this repeats: every refused request is its own blocked request | `customer_id`, `balance`, `reason` (`balance_zero` / `insufficient_credits`), `requested_amount` and `feature` where they apply, `transaction_id` on the zero case |
555
+ | `subscription.renewed` | a billing period opens — either a paid period rolling forward or a **trial converting** to its first paid period, told apart by `reason` (the renewer emits no `subscription.updated` for either) | `customer_id`, `subscription_id`, `plan_id`, `status`, `reason` (`period_renewed` / `trial_converted`), `current_period_start`, `current_period_end`, `credits_granted` |
556
+ | `subscription.canceled` | a subscription's status becomes canceled — fired **alongside** `subscription.updated`, once, on the call that actually cancels | `customer_id`, `subscription_id`, `plan_id`, `canceled_at` |
557
+ | `balance.topped_up` | a grant takes a customer's balance **up through** this endpoint's `low_balance_threshold` — the upward mirror of `balance.low`, on the same per-endpoint line, so the endpoint told "low" is the one told "recovered". Subscribed together with `balance.low` by setting the threshold, never on its own | `customer_id`, `balance`, `threshold`, `amount`, `transaction_id` |
558
+ | `cap.reached` | a customer's credit spending reaches their subscription's `spend_cap` for the period — once per subscription per period, whether they landed on the cap or were refused by it. A refusal that still leaves headroom is not a reach and does not fire | `customer_id`, `subscription_id`, `cap`, `spent`, `period_start`, `period_end` |
442
559
 
443
560
  For example, a `credit.granted` delivery:
444
561
 
@@ -502,7 +619,7 @@ with MeterFlow(api_key=os.environ["METERFLOW_API_KEY"]) as client:
502
619
 
503
620
  # Support asks: "what's Ana's situation?"
504
621
  balance = client.credits.balance("ana")
505
- history = client.credits.transactions("ana")
622
+ history = client.credits.transactions("ana")["data"]
506
623
  usage = client.usage.summary("ana")
507
624
  entitlements = client.entitlements.get("ana")
508
625
  ```
@@ -1,3 +1,3 @@
1
1
  # The one place the version lives. pyproject.toml reads it (hatch dynamic version) and the
2
2
  # User-Agent header reports it, so a release bump edits this file and nothing else.
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -19,8 +19,10 @@ from ._retry import RetryPolicy
19
19
  from .resources.credits import AsyncCreditsResource, CreditsResource
20
20
  from .resources.entitlements import AsyncEntitlementsResource, EntitlementsResource
21
21
  from .resources.plans import AsyncPlansResource, PlansResource
22
+ from .resources.portal import AsyncPortalResource, PortalResource
22
23
  from .resources.subscriptions import AsyncSubscriptionsResource, SubscriptionsResource
23
24
  from .resources.usage import AsyncUsageResource, UsageResource
25
+ from .resources.webhooks import AsyncWebhooksResource, WebhooksResource
24
26
 
25
27
  DEFAULT_BASE_URL = "https://api.meter-flow.com/api/v1"
26
28
  DEFAULT_TIMEOUT_SECONDS = 30.0
@@ -76,7 +78,9 @@ class MeterFlow(_BaseClient):
76
78
  self.usage = UsageResource(self)
77
79
  self.subscriptions = SubscriptionsResource(self)
78
80
  self.plans = PlansResource(self)
81
+ self.portal = PortalResource(self)
79
82
  self.entitlements = EntitlementsResource(self)
83
+ self.webhooks = WebhooksResource(self)
80
84
 
81
85
  def request(self, spec: RequestSpec) -> Any:
82
86
  """Internal — used by resource classes; not part of the public API."""
@@ -121,7 +125,9 @@ class AsyncMeterFlow(_BaseClient):
121
125
  self.usage = AsyncUsageResource(self)
122
126
  self.subscriptions = AsyncSubscriptionsResource(self)
123
127
  self.plans = AsyncPlansResource(self)
128
+ self.portal = AsyncPortalResource(self)
124
129
  self.entitlements = AsyncEntitlementsResource(self)
130
+ self.webhooks = AsyncWebhooksResource(self)
125
131
 
126
132
  async def request(self, spec: RequestSpec) -> Any:
127
133
  """Internal — used by resource classes; not part of the public API."""
@@ -0,0 +1 @@
1
+ """The six SDK-facing resources, each in a sync and an async flavour sharing one set of request specs."""
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from .._http import RequestSpec, encode_path_segment
8
- from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionResponse
8
+ from ..types import CreditBalanceResponse, CreditDeductRequest, CreditGrantRequest, CreditTransactionPage, CreditTransactionResponse
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from ..client import AsyncMeterFlow, MeterFlow
@@ -23,8 +23,8 @@ def balance_spec(customer_id: str) -> RequestSpec:
23
23
  return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/balance")
24
24
 
25
25
 
26
- def transactions_spec(customer_id: str) -> RequestSpec:
27
- return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions")
26
+ def transactions_spec(customer_id: str, limit: int | None, cursor: str | None) -> RequestSpec:
27
+ return RequestSpec("GET", f"credits/{encode_path_segment(customer_id)}/transactions", query={"limit": limit, "cursor": cursor})
28
28
 
29
29
 
30
30
  class CreditsResource:
@@ -46,9 +46,14 @@ class CreditsResource:
46
46
  result: CreditBalanceResponse = self._client.request(balance_spec(customer_id))
47
47
  return result
48
48
 
49
- def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
50
- """The full history, newest first."""
51
- result: list[CreditTransactionResponse] = self._client.request(transactions_spec(customer_id))
49
+ def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
50
+ """One page of the ledger, newest first.
51
+
52
+ The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
53
+ and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
54
+ back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
55
+ """
56
+ result: CreditTransactionPage = self._client.request(transactions_spec(customer_id, limit, cursor))
52
57
  return result
53
58
 
54
59
 
@@ -71,7 +76,12 @@ class AsyncCreditsResource:
71
76
  result: CreditBalanceResponse = await self._client.request(balance_spec(customer_id))
72
77
  return result
73
78
 
74
- async def transactions(self, customer_id: str) -> list[CreditTransactionResponse]:
75
- """The full history, newest first."""
76
- result: list[CreditTransactionResponse] = await self._client.request(transactions_spec(customer_id))
79
+ async def transactions(self, customer_id: str, *, limit: int | None = None, cursor: str | None = None) -> CreditTransactionPage:
80
+ """One page of the ledger, newest first.
81
+
82
+ The ledger grows with every charge, so it is read a page at a time: ``data`` holds the rows
83
+ and ``next_cursor`` the mark for the page after this one (``None`` on the last). Pass it
84
+ back as ``cursor`` unchanged — it is opaque, and nothing but this API reads it.
85
+ """
86
+ result: CreditTransactionPage = await self._client.request(transactions_spec(customer_id, limit, cursor))
77
87
  return result
@@ -0,0 +1,52 @@
1
+ """The hosted balance portal: mint a link for one of your customers.
2
+
3
+ Only the mint is here. The two reads behind the page — the balance and the ledger — are made by
4
+ the person holding the link, with the link's own token; this client holds a secret key, and the
5
+ two must never be the same credential.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from .._http import RequestSpec
13
+ from ..types import PortalLinkRequest, PortalLinkResponse
14
+
15
+ if TYPE_CHECKING:
16
+ from ..client import AsyncMeterFlow, MeterFlow
17
+
18
+ _DOC = """Mint a link to the hosted balance page for one of your customers.
19
+
20
+ Call this from **your** server and send the ``url`` to that customer. It carries a
21
+ short-lived token that reads their balance and their ledger and nothing else, in the
22
+ environment of the key you called with; it is never your API key and it can write nothing.
23
+
24
+ The page shows the balance and sends "buy more" to your own checkout (``topup_url``):
25
+ MeterFlow never takes the payment. ``expires_in_minutes`` defaults to 60, maximum 1440.
26
+ """
27
+
28
+
29
+ def create_session_spec(body: PortalLinkRequest) -> RequestSpec:
30
+ return RequestSpec("POST", "portal/sessions", body=body)
31
+
32
+
33
+ class PortalResource:
34
+ def __init__(self, client: MeterFlow) -> None:
35
+ self._client = client
36
+
37
+ def create_session(self, body: PortalLinkRequest) -> PortalLinkResponse:
38
+ result: PortalLinkResponse = self._client.request(create_session_spec(body))
39
+ return result
40
+
41
+ create_session.__doc__ = _DOC
42
+
43
+
44
+ class AsyncPortalResource:
45
+ def __init__(self, client: AsyncMeterFlow) -> None:
46
+ self._client = client
47
+
48
+ async def create_session(self, body: PortalLinkRequest) -> PortalLinkResponse:
49
+ result: PortalLinkResponse = await self._client.request(create_session_spec(body))
50
+ return result
51
+
52
+ create_session.__doc__ = _DOC
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import TYPE_CHECKING
6
6
 
7
7
  from .._http import RequestSpec, encode_path_segment
8
- from ..types import SubscriptionCreateRequest, SubscriptionResponse, SubscriptionUpdateRequest
8
+ from ..types import SubscriptionCreateRequest, SubscriptionPage, SubscriptionResponse, SubscriptionUpdateRequest
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from ..client import AsyncMeterFlow, MeterFlow
@@ -15,8 +15,8 @@ def create_spec(body: SubscriptionCreateRequest, idempotency_key: str | None) ->
15
15
  return RequestSpec("POST", "subscriptions", body=body, idempotency_key=idempotency_key)
16
16
 
17
17
 
18
- def list_spec(customer_id: str | None) -> RequestSpec:
19
- return RequestSpec("GET", "subscriptions", query={"customer_id": customer_id})
18
+ def list_spec(customer_id: str | None, limit: int | None, cursor: str | None) -> RequestSpec:
19
+ return RequestSpec("GET", "subscriptions", query={"customer_id": customer_id, "limit": limit, "cursor": cursor})
20
20
 
21
21
 
22
22
  def get_spec(subscription_id: str) -> RequestSpec:
@@ -40,9 +40,14 @@ class SubscriptionsResource:
40
40
  result: SubscriptionResponse = self._client.request(create_spec(body, idempotency_key))
41
41
  return result
42
42
 
43
- def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
44
- """The project's subscriptions in this key's environment, optionally one customer's."""
45
- result: list[SubscriptionResponse] = self._client.request(list_spec(customer_id))
43
+ def list(self, *, customer_id: str | None = None, limit: int | None = None, cursor: str | None = None) -> SubscriptionPage:
44
+ """One page of the project's subscriptions in this key's environment, newest first.
45
+
46
+ ``data`` holds the rows and ``next_cursor`` the mark for the page after this one (``None``
47
+ on the last). Pass it back as ``cursor`` unchanged — it is opaque, and nothing but this API
48
+ reads it. ``customer_id`` narrows the list to one customer.
49
+ """
50
+ result: SubscriptionPage = self._client.request(list_spec(customer_id, limit, cursor))
46
51
  return result
47
52
 
48
53
  def get(self, subscription_id: str) -> SubscriptionResponse:
@@ -68,9 +73,14 @@ class AsyncSubscriptionsResource:
68
73
  result: SubscriptionResponse = await self._client.request(create_spec(body, idempotency_key))
69
74
  return result
70
75
 
71
- async def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
72
- """The project's subscriptions in this key's environment, optionally one customer's."""
73
- result: list[SubscriptionResponse] = await self._client.request(list_spec(customer_id))
76
+ async def list(self, *, customer_id: str | None = None, limit: int | None = None, cursor: str | None = None) -> SubscriptionPage:
77
+ """One page of the project's subscriptions in this key's environment, newest first.
78
+
79
+ ``data`` holds the rows and ``next_cursor`` the mark for the page after this one (``None``
80
+ on the last). Pass it back as ``cursor`` unchanged — it is opaque, and nothing but this API
81
+ reads it. ``customer_id`` narrows the list to one customer.
82
+ """
83
+ result: SubscriptionPage = await self._client.request(list_spec(customer_id, limit, cursor))
74
84
  return result
75
85
 
76
86
  async def get(self, subscription_id: str) -> SubscriptionResponse:
@@ -0,0 +1,75 @@
1
+ """Webhook receipts — read-only through the SDK: endpoints are registered in the dashboard.
2
+
3
+ An endpoint is created, edited and deleted where the signing secret can be shown once and never
4
+ again. What an SDK consumer needs at 3am is the other half: which endpoints exist, and what
5
+ MeterFlow actually sent them. That is this module.
6
+
7
+ Not to be confused with :mod:`meterflow.webhook`, which verifies the signature on a webhook your
8
+ server has just received. This reads what we sent; that proves we sent it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .._http import RequestSpec, encode_path_segment
16
+ from ..types import WebhookDeliveryResponse, WebhookResponse
17
+
18
+ if TYPE_CHECKING:
19
+ from ..client import AsyncMeterFlow, MeterFlow
20
+
21
+ # Spelled as module-level aliases, where `list` is still the builtin. Inside the class bodies below
22
+ # it is not: a method called `list` shadows it for every annotation that follows, so writing
23
+ # `list[WebhookDeliveryResponse]` on `deliveries` resolves to the method and mypy rejects it.
24
+ # `plans.py` never meets this only because its second method returns a bare type.
25
+ WebhookList = list[WebhookResponse]
26
+ DeliveryList = list[WebhookDeliveryResponse]
27
+
28
+
29
+ def list_spec() -> RequestSpec:
30
+ # No project_id: the key's own project is the scope, and a query could not widen it.
31
+ return RequestSpec("GET", "webhooks")
32
+
33
+
34
+ def deliveries_spec(webhook_id: str) -> RequestSpec:
35
+ return RequestSpec("GET", f"webhooks/{encode_path_segment(webhook_id)}/deliveries")
36
+
37
+
38
+ class WebhooksResource:
39
+ def __init__(self, client: MeterFlow) -> None:
40
+ self._client = client
41
+
42
+ def list(self) -> WebhookList:
43
+ """The endpoints registered for this key's project. Never includes a secret."""
44
+ result: WebhookList = self._client.request(list_spec())
45
+ return result
46
+
47
+ def deliveries(self, webhook_id: str) -> DeliveryList:
48
+ """One endpoint's delivery attempts, newest first: what was sent, the HTTP status that came
49
+ back, how many attempts and when the next retry is due.
50
+
51
+ Capped at the most recent 100, and scoped to the key's own environment — a ``mf_test_*``
52
+ key reads test receipts, a ``mf_live_*`` key live ones. Another project's webhook is a 404.
53
+ """
54
+ result: DeliveryList = self._client.request(deliveries_spec(webhook_id))
55
+ return result
56
+
57
+
58
+ class AsyncWebhooksResource:
59
+ def __init__(self, client: AsyncMeterFlow) -> None:
60
+ self._client = client
61
+
62
+ async def list(self) -> WebhookList:
63
+ """The endpoints registered for this key's project. Never includes a secret."""
64
+ result: WebhookList = await self._client.request(list_spec())
65
+ return result
66
+
67
+ async def deliveries(self, webhook_id: str) -> DeliveryList:
68
+ """One endpoint's delivery attempts, newest first: what was sent, the HTTP status that came
69
+ back, how many attempts and when the next retry is due.
70
+
71
+ Capped at the most recent 100, and scoped to the key's own environment — a ``mf_test_*``
72
+ key reads test receipts, a ``mf_live_*`` key live ones. Another project's webhook is a 404.
73
+ """
74
+ result: DeliveryList = await self._client.request(deliveries_spec(webhook_id))
75
+ return result
@@ -6,7 +6,14 @@ from __future__ import annotations
6
6
 
7
7
  from typing import Any, Literal, TypedDict
8
8
 
9
- EntitlementReason = Literal["no_subscription", "not_in_plan", "hard_limit_reached", "soft_limit_exceeded", "insufficient_credits"]
9
+ EntitlementReason = Literal[
10
+ "no_subscription",
11
+ "not_in_plan",
12
+ "hard_limit_reached",
13
+ "soft_limit_exceeded",
14
+ "insufficient_credits",
15
+ "spend_cap_reached",
16
+ ]
10
17
 
11
18
  FeatureType = Literal["boolean", "metered"]
12
19
 
@@ -48,6 +55,19 @@ class CreditGrantRequest(_CreditGrantRequestRequired, total=False):
48
55
  metadata: dict[str, Any]
49
56
 
50
57
 
58
+ class _CreditTransactionPageRequired(TypedDict):
59
+ data: list[CreditTransactionResponse]
60
+
61
+
62
+ class CreditTransactionPage(_CreditTransactionPageRequired, total=False):
63
+ """One page of a customer's credit ledger, newest first. `next_cursor` is the opaque mark for the page after this one, and `null` on the
64
+ last page — pass it back as `cursor` unchanged. The list grows with a customer's traffic, which is why it is paged at all
65
+ (docs/DECISIONS.md, 2026-10-14).
66
+ """
67
+
68
+ next_cursor: str | None
69
+
70
+
51
71
  class CreditTransactionResponse(TypedDict):
52
72
  id: str
53
73
  project_id: str
@@ -108,7 +128,7 @@ class PlanResponse(TypedDict):
108
128
  currency: str
109
129
  trial_days: int
110
130
  is_active: bool
111
- is_public: bool
131
+ is_published: bool
112
132
  metadata_: dict[str, Any]
113
133
  meter_limits: list[PlanMeterLimitResponse]
114
134
  features: list[str]
@@ -116,16 +136,61 @@ class PlanResponse(TypedDict):
116
136
  updated_at: str
117
137
 
118
138
 
139
+ class _PortalLinkRequestRequired(TypedDict):
140
+ customer_external_id: str
141
+
142
+
143
+ class PortalLinkRequest(_PortalLinkRequestRequired, total=False):
144
+ """One link, for one of the caller's customers. The environment is **not** here: it comes from the API key's prefix, like every other
145
+ SDK-facing write.
146
+ """
147
+
148
+ return_url: str | None
149
+ topup_url: str | None
150
+ expires_in_minutes: int | None
151
+
152
+
153
+ class _PortalLinkResponseRequired(TypedDict):
154
+ url: str
155
+ token: str
156
+ expires_at: str
157
+ customer_external_id: str
158
+ environment: str
159
+
160
+
161
+ class PortalLinkResponse(_PortalLinkResponseRequired, total=False):
162
+ """The link to send, and what it is for. The signing material is never part of this."""
163
+
164
+ return_url: str | None
165
+ topup_url: str | None
166
+
167
+
119
168
  class _SubscriptionCreateRequestRequired(TypedDict):
120
169
  plan_id: str
121
170
  customer_external_id: str
122
171
 
123
172
 
124
173
  class SubscriptionCreateRequest(_SubscriptionCreateRequestRequired, total=False):
174
+ spend_cap: float | str | None
175
+ topup_amount: float | str | None
176
+ max_topups_per_window: int | None
125
177
  metadata: dict[str, Any]
126
178
  idempotency_key: str | None
127
179
 
128
180
 
181
+ class _SubscriptionPageRequired(TypedDict):
182
+ data: list[SubscriptionResponse]
183
+
184
+
185
+ class SubscriptionPage(_SubscriptionPageRequired, total=False):
186
+ """One page of a project's subscriptions, newest first. `next_cursor` is the opaque mark for the page after this one, and `null` on the
187
+ last page — pass it back as `cursor` unchanged. The list grows with a customer's traffic, which is why it is paged at all
188
+ (docs/DECISIONS.md, 2026-10-14).
189
+ """
190
+
191
+ next_cursor: str | None
192
+
193
+
129
194
  class SubscriptionResponse(TypedDict):
130
195
  id: str
131
196
  project_id: str
@@ -136,6 +201,10 @@ class SubscriptionResponse(TypedDict):
136
201
  current_period_start: str
137
202
  current_period_end: str | None
138
203
  trial_end_date: str | None
204
+ spend_cap: str | None
205
+ topup_amount: str | None
206
+ max_topups_per_window: int | None
207
+ idempotency_key: str | None
139
208
  canceled_at: str | None
140
209
  metadata_: dict[str, Any]
141
210
  created_at: str
@@ -144,6 +213,9 @@ class SubscriptionResponse(TypedDict):
144
213
 
145
214
  class SubscriptionUpdateRequest(TypedDict, total=False):
146
215
  status: SubscriptionStatus | None
216
+ spend_cap: float | str | None
217
+ topup_amount: float | str | None
218
+ max_topups_per_window: int | None
147
219
  metadata: dict[str, Any] | None
148
220
 
149
221
 
@@ -157,7 +229,7 @@ class _UsageEventRequestRequired(TypedDict):
157
229
 
158
230
 
159
231
  class UsageEventRequest(_UsageEventRequestRequired, total=False):
160
- value: float | str
232
+ value: float | str | None
161
233
  properties: dict[str, Any]
162
234
  idempotency_key: str | None
163
235
  timestamp: str | None
@@ -183,7 +255,7 @@ class UsageEventResponse(_UsageEventResponseRequired, total=False):
183
255
  processed_at: str | None
184
256
 
185
257
 
186
- class UsageSummaryItem(TypedDict):
258
+ class _UsageSummaryItemRequired(TypedDict):
187
259
  meter_id: str
188
260
  meter_name: str
189
261
  event_name: str
@@ -192,6 +264,11 @@ class UsageSummaryItem(TypedDict):
192
264
  event_count: int
193
265
 
194
266
 
267
+ class UsageSummaryItem(_UsageSummaryItemRequired, total=False):
268
+ first_event_at: str | None
269
+ last_event_at: str | None
270
+
271
+
195
272
  class UsageSummaryResponse(TypedDict):
196
273
  project_id: str
197
274
  customer_external_id: str
@@ -200,10 +277,41 @@ class UsageSummaryResponse(TypedDict):
200
277
  meters: list[UsageSummaryItem]
201
278
 
202
279
 
280
+ class WebhookDeliveryResponse(TypedDict):
281
+ id: str
282
+ webhook_id: str
283
+ event_type: str
284
+ payload: dict[str, Any]
285
+ status: str
286
+ response_code: int | None
287
+ response_body: str | None
288
+ attempts: int
289
+ next_retry_at: str | None
290
+ created_at: str
291
+ completed_at: str | None
292
+
293
+
294
+ class WebhookResponse(TypedDict):
295
+ """Returned on all reads — secret is never included."""
296
+
297
+ id: str
298
+ project_id: str
299
+ url: str
300
+ events: list[str]
301
+ low_balance_threshold: str | None
302
+ is_active: bool
303
+ last_triggered_at: str | None
304
+ failure_count: int
305
+ metadata_: dict[str, Any]
306
+ created_at: str
307
+ updated_at: str
308
+
309
+
203
310
  __all__ = [
204
311
  "CreditBalanceResponse",
205
312
  "CreditDeductRequest",
206
313
  "CreditGrantRequest",
314
+ "CreditTransactionPage",
207
315
  "CreditTransactionResponse",
208
316
  "EntitlementReason",
209
317
  "EntitlementResponse",
@@ -211,7 +319,10 @@ __all__ = [
211
319
  "FeatureType",
212
320
  "PlanMeterLimitResponse",
213
321
  "PlanResponse",
322
+ "PortalLinkRequest",
323
+ "PortalLinkResponse",
214
324
  "SubscriptionCreateRequest",
325
+ "SubscriptionPage",
215
326
  "SubscriptionResponse",
216
327
  "SubscriptionStatus",
217
328
  "SubscriptionUpdateRequest",
@@ -220,4 +331,6 @@ __all__ = [
220
331
  "UsageEventResponse",
221
332
  "UsageSummaryItem",
222
333
  "UsageSummaryResponse",
334
+ "WebhookDeliveryResponse",
335
+ "WebhookResponse",
223
336
  ]
@@ -1 +0,0 @@
1
- """The five SDK-facing resources, each in a sync and an async flavour sharing one set of request specs."""
File without changes
File without changes