meterflow 0.1.2__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.2 → meterflow-0.2.0}/PKG-INFO +76 -10
- {meterflow-0.1.2 → meterflow-0.2.0}/README.md +75 -9
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/_version.py +1 -1
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/client.py +6 -0
- meterflow-0.2.0/src/meterflow/resources/__init__.py +1 -0
- {meterflow-0.1.2 → 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.2 → 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.2 → meterflow-0.2.0}/src/meterflow/types.py +110 -3
- meterflow-0.1.2/src/meterflow/resources/__init__.py +0 -1
- {meterflow-0.1.2 → meterflow-0.2.0}/LICENSE +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/pyproject.toml +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/__init__.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/_http.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/_retry.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/errors.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/py.typed +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/resources/entitlements.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/resources/plans.py +0 -0
- {meterflow-0.1.2 → meterflow-0.2.0}/src/meterflow/resources/usage.py +0 -0
- {meterflow-0.1.2 → 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
|
})
|
|
@@ -312,7 +326,8 @@ This is usually the difference between fitting a plan and outgrowing it: 1,000 u
|
|
|
312
326
|
|
|
313
327
|
Two things to keep in mind:
|
|
314
328
|
|
|
315
|
-
- **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
|
|
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`.
|
|
316
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.
|
|
317
332
|
|
|
318
333
|
**Summary** — a customer's usage, broken down by meter:
|
|
@@ -353,8 +368,12 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
|
|
|
353
368
|
**List / get:**
|
|
354
369
|
|
|
355
370
|
```python
|
|
356
|
-
|
|
371
|
+
page = client.subscriptions.list() # whole project (in your key's environment)
|
|
357
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"])
|
|
358
377
|
one = client.subscriptions.get(sub["id"])
|
|
359
378
|
```
|
|
360
379
|
|
|
@@ -391,6 +410,27 @@ plan["features"] # ["sso", "priority-support"] — the on/off keys check(
|
|
|
391
410
|
|
|
392
411
|
Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
|
|
393
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
|
+
|
|
394
434
|
### Entitlements — ask before you act
|
|
395
435
|
|
|
396
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.
|
|
@@ -399,7 +439,7 @@ The one call to make **before** doing work for a customer: "may they use this fe
|
|
|
399
439
|
gate = client.entitlements.check("customer_123", "images.generated")
|
|
400
440
|
|
|
401
441
|
if not gate["allowed"]:
|
|
402
|
-
# 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"
|
|
403
443
|
return show_upgrade_prompt(gate) # gate["used"] / gate["included"] / gate["period_end"] are there for the copy
|
|
404
444
|
|
|
405
445
|
generate_image()
|
|
@@ -424,6 +464,30 @@ result["entitlements"]
|
|
|
424
464
|
|
|
425
465
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
426
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
|
+
|
|
427
491
|
## Idempotency — safe retries for writes
|
|
428
492
|
|
|
429
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:
|
|
@@ -538,10 +602,12 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
538
602
|
| `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
|
|
539
603
|
| `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
|
|
540
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` |
|
|
541
|
-
| `balance.low` | a
|
|
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` |
|
|
542
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 |
|
|
543
|
-
| `subscription.renewed` | a billing period
|
|
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` |
|
|
544
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` |
|
|
545
611
|
|
|
546
612
|
For example, a `credit.granted` delivery:
|
|
547
613
|
|
|
@@ -605,7 +671,7 @@ with MeterFlow(api_key=os.environ["METERFLOW_API_KEY"]) as client:
|
|
|
605
671
|
|
|
606
672
|
# Support asks: "what's Ana's situation?"
|
|
607
673
|
balance = client.credits.balance("ana")
|
|
608
|
-
history = client.credits.transactions("ana")
|
|
674
|
+
history = client.credits.transactions("ana")["data"]
|
|
609
675
|
usage = client.usage.summary("ana")
|
|
610
676
|
entitlements = client.entitlements.get("ana")
|
|
611
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
|
})
|
|
@@ -260,7 +274,8 @@ This is usually the difference between fitting a plan and outgrowing it: 1,000 u
|
|
|
260
274
|
|
|
261
275
|
Two things to keep in mind:
|
|
262
276
|
|
|
263
|
-
- **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
|
|
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`.
|
|
264
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.
|
|
265
280
|
|
|
266
281
|
**Summary** — a customer's usage, broken down by meter:
|
|
@@ -301,8 +316,12 @@ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
|
|
|
301
316
|
**List / get:**
|
|
302
317
|
|
|
303
318
|
```python
|
|
304
|
-
|
|
319
|
+
page = client.subscriptions.list() # whole project (in your key's environment)
|
|
305
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"])
|
|
306
325
|
one = client.subscriptions.get(sub["id"])
|
|
307
326
|
```
|
|
308
327
|
|
|
@@ -339,6 +358,27 @@ plan["features"] # ["sso", "priority-support"] — the on/off keys check(
|
|
|
339
358
|
|
|
340
359
|
Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
|
|
341
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
|
+
|
|
342
382
|
### Entitlements — ask before you act
|
|
343
383
|
|
|
344
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.
|
|
@@ -347,7 +387,7 @@ The one call to make **before** doing work for a customer: "may they use this fe
|
|
|
347
387
|
gate = client.entitlements.check("customer_123", "images.generated")
|
|
348
388
|
|
|
349
389
|
if not gate["allowed"]:
|
|
350
|
-
# 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"
|
|
351
391
|
return show_upgrade_prompt(gate) # gate["used"] / gate["included"] / gate["period_end"] are there for the copy
|
|
352
392
|
|
|
353
393
|
generate_image()
|
|
@@ -372,6 +412,30 @@ result["entitlements"]
|
|
|
372
412
|
|
|
373
413
|
Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
|
|
374
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
|
+
|
|
375
439
|
## Idempotency — safe retries for writes
|
|
376
440
|
|
|
377
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:
|
|
@@ -486,10 +550,12 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
|
|
|
486
550
|
| `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
|
|
487
551
|
| `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
|
|
488
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` |
|
|
489
|
-
| `balance.low` | a
|
|
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` |
|
|
490
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 |
|
|
491
|
-
| `subscription.renewed` | a billing period
|
|
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` |
|
|
492
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` |
|
|
493
559
|
|
|
494
560
|
For example, a `credit.granted` delivery:
|
|
495
561
|
|
|
@@ -553,7 +619,7 @@ with MeterFlow(api_key=os.environ["METERFLOW_API_KEY"]) as client:
|
|
|
553
619
|
|
|
554
620
|
# Support asks: "what's Ana's situation?"
|
|
555
621
|
balance = client.credits.balance("ana")
|
|
556
|
-
history = client.credits.transactions("ana")
|
|
622
|
+
history = client.credits.transactions("ana")["data"]
|
|
557
623
|
usage = client.usage.summary("ana")
|
|
558
624
|
entitlements = client.entitlements.get("ana")
|
|
559
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,6 @@ class PlanResponse(TypedDict):
|
|
|
108
128
|
currency: str
|
|
109
129
|
trial_days: int
|
|
110
130
|
is_active: bool
|
|
111
|
-
is_public: bool
|
|
112
131
|
is_published: bool
|
|
113
132
|
metadata_: dict[str, Any]
|
|
114
133
|
meter_limits: list[PlanMeterLimitResponse]
|
|
@@ -117,16 +136,61 @@ class PlanResponse(TypedDict):
|
|
|
117
136
|
updated_at: str
|
|
118
137
|
|
|
119
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
|
+
|
|
120
168
|
class _SubscriptionCreateRequestRequired(TypedDict):
|
|
121
169
|
plan_id: str
|
|
122
170
|
customer_external_id: str
|
|
123
171
|
|
|
124
172
|
|
|
125
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
|
|
126
177
|
metadata: dict[str, Any]
|
|
127
178
|
idempotency_key: str | None
|
|
128
179
|
|
|
129
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
|
+
|
|
130
194
|
class SubscriptionResponse(TypedDict):
|
|
131
195
|
id: str
|
|
132
196
|
project_id: str
|
|
@@ -137,6 +201,10 @@ class SubscriptionResponse(TypedDict):
|
|
|
137
201
|
current_period_start: str
|
|
138
202
|
current_period_end: str | None
|
|
139
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
|
|
140
208
|
canceled_at: str | None
|
|
141
209
|
metadata_: dict[str, Any]
|
|
142
210
|
created_at: str
|
|
@@ -145,6 +213,9 @@ class SubscriptionResponse(TypedDict):
|
|
|
145
213
|
|
|
146
214
|
class SubscriptionUpdateRequest(TypedDict, total=False):
|
|
147
215
|
status: SubscriptionStatus | None
|
|
216
|
+
spend_cap: float | str | None
|
|
217
|
+
topup_amount: float | str | None
|
|
218
|
+
max_topups_per_window: int | None
|
|
148
219
|
metadata: dict[str, Any] | None
|
|
149
220
|
|
|
150
221
|
|
|
@@ -158,7 +229,7 @@ class _UsageEventRequestRequired(TypedDict):
|
|
|
158
229
|
|
|
159
230
|
|
|
160
231
|
class UsageEventRequest(_UsageEventRequestRequired, total=False):
|
|
161
|
-
value: float | str
|
|
232
|
+
value: float | str | None
|
|
162
233
|
properties: dict[str, Any]
|
|
163
234
|
idempotency_key: str | None
|
|
164
235
|
timestamp: str | None
|
|
@@ -206,10 +277,41 @@ class UsageSummaryResponse(TypedDict):
|
|
|
206
277
|
meters: list[UsageSummaryItem]
|
|
207
278
|
|
|
208
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
|
+
|
|
209
310
|
__all__ = [
|
|
210
311
|
"CreditBalanceResponse",
|
|
211
312
|
"CreditDeductRequest",
|
|
212
313
|
"CreditGrantRequest",
|
|
314
|
+
"CreditTransactionPage",
|
|
213
315
|
"CreditTransactionResponse",
|
|
214
316
|
"EntitlementReason",
|
|
215
317
|
"EntitlementResponse",
|
|
@@ -217,7 +319,10 @@ __all__ = [
|
|
|
217
319
|
"FeatureType",
|
|
218
320
|
"PlanMeterLimitResponse",
|
|
219
321
|
"PlanResponse",
|
|
322
|
+
"PortalLinkRequest",
|
|
323
|
+
"PortalLinkResponse",
|
|
220
324
|
"SubscriptionCreateRequest",
|
|
325
|
+
"SubscriptionPage",
|
|
221
326
|
"SubscriptionResponse",
|
|
222
327
|
"SubscriptionStatus",
|
|
223
328
|
"SubscriptionUpdateRequest",
|
|
@@ -226,4 +331,6 @@ __all__ = [
|
|
|
226
331
|
"UsageEventResponse",
|
|
227
332
|
"UsageSummaryItem",
|
|
228
333
|
"UsageSummaryResponse",
|
|
334
|
+
"WebhookDeliveryResponse",
|
|
335
|
+
"WebhookResponse",
|
|
229
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
|