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.
- {meterflow-0.1.1 → meterflow-0.2.0}/PKG-INFO +125 -8
- {meterflow-0.1.1 → meterflow-0.2.0}/README.md +124 -7
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_version.py +1 -1
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/client.py +6 -0
- meterflow-0.2.0/src/meterflow/resources/__init__.py +1 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/credits.py +19 -9
- meterflow-0.2.0/src/meterflow/resources/portal.py +52 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/subscriptions.py +19 -9
- meterflow-0.2.0/src/meterflow/resources/webhooks.py +75 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/types.py +117 -4
- meterflow-0.1.1/src/meterflow/resources/__init__.py +0 -1
- {meterflow-0.1.1 → meterflow-0.2.0}/LICENSE +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/pyproject.toml +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/__init__.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_http.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/_retry.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/errors.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/py.typed +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/entitlements.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/plans.py +0 -0
- {meterflow-0.1.1 → meterflow-0.2.0}/src/meterflow/resources/usage.py +0 -0
- {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.
|
|
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
|
|
239
|
+
**Transactions** — the history, newest first, **a page at a time**:
|
|
239
240
|
|
|
240
241
|
```python
|
|
241
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
187
|
+
**Transactions** — the history, newest first, **a page at a time**:
|
|
187
188
|
|
|
188
189
|
```python
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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
|
```
|
|
@@ -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) ->
|
|
50
|
-
"""
|
|
51
|
-
|
|
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) ->
|
|
75
|
-
"""
|
|
76
|
-
|
|
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) ->
|
|
44
|
-
"""
|
|
45
|
-
|
|
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) ->
|
|
72
|
-
"""
|
|
73
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|