meterflow 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MeterFlow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,560 @@
1
+ Metadata-Version: 2.5
2
+ Name: meterflow
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
5
+ Project-URL: Homepage, https://meter-flow.com
6
+ Project-URL: Documentation, https://meter-flow.com/guide
7
+ Project-URL: Source, https://github.com/meterflowio/meterflow-python
8
+ Project-URL: Issues, https://github.com/meterflowio/meterflow-python/issues
9
+ Author-email: MeterFlow <support@meter-flow.com>
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 MeterFlow
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: billing,credits,entitlements,meterflow,metering,usage-based
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Programming Language :: Python :: 3.13
41
+ Classifier: Typing :: Typed
42
+ Requires-Python: >=3.10
43
+ Requires-Dist: httpx<1,>=0.27
44
+ Provides-Extra: dev
45
+ Requires-Dist: build>=1.2; extra == 'dev'
46
+ Requires-Dist: mypy>=1.11; extra == 'dev'
47
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
48
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
49
+ Requires-Dist: pytest>=8; extra == 'dev'
50
+ Requires-Dist: ruff>=0.6; extra == 'dev'
51
+ Description-Content-Type: text/markdown
52
+
53
+ # meterflow
54
+
55
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
56
+ [![python](https://img.shields.io/badge/python-%E2%89%A5%203.10-blue)](https://www.python.org)
57
+
58
+ Official Python SDK for [MeterFlow](https://meter-flow.com) — usage-based billing, credit management, and metering.
59
+
60
+ Track what your customers use, enforce credit balances, gate features, and manage subscriptions with a few lines of code. Fully typed, sync **and** async, one dependency (`httpx`).
61
+
62
+ ---
63
+
64
+ ## Contents
65
+
66
+ - [Requirements](#requirements)
67
+ - [Installation](#installation)
68
+ - [Quick start](#quick-start)
69
+ - [Sync or async — same SDK](#sync-or-async--same-sdk)
70
+ - [Authentication](#authentication)
71
+ - [Core concepts](#core-concepts)
72
+ - [Configuration](#configuration)
73
+ - [Usage guide](#usage-guide)
74
+ - [Credits](#credits)
75
+ - [Usage events](#usage-events)
76
+ - [Subscriptions](#subscriptions)
77
+ - [Plans](#plans)
78
+ - [Entitlements — ask before you act](#entitlements--ask-before-you-act)
79
+ - [Idempotency — safe retries for writes](#idempotency--safe-retries-for-writes)
80
+ - [Automatic retries](#automatic-retries)
81
+ - [Error handling](#error-handling)
82
+ - [Verifying webhooks](#verifying-webhooks)
83
+ - [Typing notes](#typing-notes)
84
+ - [Versioning & support](#versioning--support)
85
+
86
+ ---
87
+
88
+ ## Requirements
89
+
90
+ - **Python ≥ 3.10**
91
+ - An API key from your [MeterFlow dashboard](https://meter-flow.com) (Project → API Keys)
92
+
93
+ ## Installation
94
+
95
+ ```bash
96
+ pip install meterflow
97
+ ```
98
+
99
+ The only runtime dependency is [`httpx`](https://www.python-httpx.org). The package ships a `py.typed` marker — mypy and pyright see every field.
100
+
101
+ ## Quick start
102
+
103
+ ```python
104
+ import os
105
+ from meterflow import MeterFlow
106
+
107
+ client = MeterFlow(api_key=os.environ["METERFLOW_API_KEY"])
108
+
109
+ # 1. Grant credits to a customer
110
+ client.credits.grant({
111
+ "customer_external_id": "customer_123",
112
+ "amount": 5000,
113
+ "description": "Starter plan — monthly credit grant",
114
+ "metadata": {},
115
+ })
116
+
117
+ # 2. Report what happened, wherever your product does the billable thing
118
+ client.usage.record({
119
+ "event_name": "images.generated",
120
+ "customer_external_id": "customer_123",
121
+ "value": 1,
122
+ "properties": {},
123
+ })
124
+
125
+ # 3. Check what they have left
126
+ balance = client.credits.balance("customer_123")
127
+ print(balance["balance"]) # "4999.000000"
128
+ ```
129
+
130
+ That's the whole integration loop: grant → record → check. Everything else in this guide is detail.
131
+
132
+ ## Sync or async — same SDK
133
+
134
+ Two clients, one surface: every method of `MeterFlow` exists on `AsyncMeterFlow` with the same name, arguments and return shape — the only difference is `await`.
135
+
136
+ ```python
137
+ from meterflow import AsyncMeterFlow
138
+
139
+ async with AsyncMeterFlow(api_key="mf_test_...") as client:
140
+ gate = await client.entitlements.check("customer_123", "images.generated")
141
+ ```
142
+
143
+ Both clients hold an `httpx` connection pool: use them as context managers (`with` / `async with`) or call `close()` / `aclose()` when you are done. Create one per process (or per project) and reuse it.
144
+
145
+ ## Authentication
146
+
147
+ Every request authenticates with the API key you pass to the constructor:
148
+
149
+ | Key prefix | Environment | Use for |
150
+ |---|---|---|
151
+ | `mf_live_…` | Live | Real customers, real balances |
152
+ | `mf_test_…` | Test | Development, CI, experiments |
153
+
154
+ The key's environment is a **real data boundary, not a label**: inside the same project, `mf_test_` keys read and write a fully separate dataset from `mf_live_` keys — subscriptions, credits, and usage events created with a test key are invisible to live keys (and vice versa), while your meters and plans are shared, so tests always run against your real billing configuration. The same customer id can hold an independent balance and subscription in each environment, and idempotency keys are namespaced per environment. Point your staging/CI at a test key and production at a live key — same project, zero risk of cross-contamination.
155
+
156
+ Keys are created in the dashboard (Project → API Keys) and **shown once** at creation — MeterFlow stores only a fingerprint. If a key leaks, revoke it in the dashboard and mint a new one; revocation is immediate.
157
+
158
+ A key belongs to **one project** and can only see that project's data.
159
+
160
+ ```python
161
+ client = MeterFlow(api_key="mf_test_...") # raises ValueError immediately if the prefix is neither mf_live_ nor mf_test_
162
+ ```
163
+
164
+ > Treat API keys like passwords: read them from environment variables or a secret manager, never commit them.
165
+
166
+ ## Core concepts
167
+
168
+ Four words explain the whole product:
169
+
170
+ | Concept | What it is | Example |
171
+ |---|---|---|
172
+ | **Meter** | One countable thing, identified by its `event_name` | `images.generated`, `minutes.transcribed` |
173
+ | **Plan** | What you sell: price, billing period, how much of each meter is included, and which on/off features | Starter — $29/month, 5,000 images, `sso` |
174
+ | **Subscription** | One customer on one plan from a date; renews itself | `customer_123` → Starter |
175
+ | **Credits** | The customer's balance — granted up, spent down, never edited in place | `+5000` granted, `−1340` consumed |
176
+
177
+ Meters and plans are defined in the dashboard. Your app, through this SDK, does the day-to-day work: creates subscriptions, grants/deducts credits, records usage events (each event lands on the meter whose `event_name` matches), and asks whether a customer may use a feature.
178
+
179
+ ## Configuration
180
+
181
+ ```python
182
+ client = MeterFlow(
183
+ api_key="mf_live_...", # required — mf_live_* or mf_test_*
184
+ base_url="https://api.meter-flow.com/api/v1", # optional — override for self-hosted / local dev
185
+ timeout=30.0, # optional — per-request timeout in seconds (default 30)
186
+ retries=3, # optional — automatic retries (default 3, 0 disables)
187
+ transport=None, # optional — an httpx transport (tests, proxies)
188
+ )
189
+ ```
190
+
191
+ `base_url` is for pointing at a **different MeterFlow server** — a self-hosted deployment, or a locally running stack if you develop MeterFlow itself (`base_url="http://localhost:8000/api/v1"`). If you use the hosted service, leave it at the default; to test your integration safely, use an `mf_test_` key instead (see [Authentication](#authentication)) — no URL change needed.
192
+
193
+ All configuration lives on the client instance (`client.options`) — there is no global state, no environment-variable sniffing, so you can create multiple clients (e.g. one per project) in the same process.
194
+
195
+ ## Usage guide
196
+
197
+ ### Credits
198
+
199
+ Credits are an append-only ledger: every grant and deduction is a permanent transaction, and the balance is the sum. Nothing is ever edited in place — which is why a customer's history is always auditable.
200
+
201
+ **Grant** — add credits (plan renewals, top-ups, goodwill):
202
+
203
+ ```python
204
+ txn = client.credits.grant({
205
+ "customer_external_id": "customer_123",
206
+ "amount": 5000,
207
+ "description": "Monthly plan grant",
208
+ "metadata": {},
209
+ })
210
+ print(txn["balance_after"]) # "5000.000000"
211
+ ```
212
+
213
+ **Deduct** — remove credits. Raises `InsufficientCreditsError` (HTTP 402) if the balance can't cover it — the customer is never taken below zero:
214
+
215
+ ```python
216
+ from meterflow import InsufficientCreditsError
217
+
218
+ try:
219
+ client.credits.deduct({
220
+ "customer_external_id": "customer_123",
221
+ "amount": 25,
222
+ "description": "images.generated ×25",
223
+ "metadata": {},
224
+ })
225
+ except InsufficientCreditsError:
226
+ ... # Show your "out of credits — top up" screen. This is an upgrade prompt, not an error page.
227
+ ```
228
+
229
+ **Balance** — the current position:
230
+
231
+ ```python
232
+ bal = client.credits.balance("customer_123")
233
+ bal["balance"] # "3545.000000" ← decimal string, see note below
234
+ bal["total_granted"] # "5250.000000"
235
+ bal["total_consumed"] # "1705.000000"
236
+ ```
237
+
238
+ **Transactions** — the full history, newest first:
239
+
240
+ ```python
241
+ for t in client.credits.transactions("customer_123"):
242
+ print(t["transaction_type"], t["amount"], t["balance_after"], t["description"], t["created_at"])
243
+ ```
244
+
245
+ > **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
+
247
+ ### Usage events
248
+
249
+ Record an event every time a customer does the thing you charge for. Events are matched to a meter by `event_name` and processed asynchronously into totals (and, if the meter is metered on a plan, into credit deductions).
250
+
251
+ **Record one event:**
252
+
253
+ ```python
254
+ client.usage.record({
255
+ "event_name": "images.generated",
256
+ "customer_external_id": "customer_123",
257
+ "value": 1, # what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
258
+ "properties": {}, # free-form context; {} when unused
259
+ "timestamp": "2026-09-30T10:00:00Z", # optional — defaults to arrival time on the server
260
+ })
261
+ ```
262
+
263
+ **Record a batch** — for high-throughput paths, flush events in groups instead of one request each:
264
+
265
+ ```python
266
+ client.usage.record_batch([
267
+ {"event_name": "images.generated", "customer_external_id": "customer_123", "value": 1, "properties": {}},
268
+ {"event_name": "video.seconds_rendered", "customer_external_id": "customer_123", "value": 42, "properties": {}},
269
+ ])
270
+ ```
271
+
272
+ A batch holds at most **500 events** (exported as `MAX_BATCH_EVENTS`). A larger list is rejected locally with `PayloadTooLargeError` before anything is sent — the SDK deliberately does **not** split it for you, because one call is one request with one idempotency key, and turning it into several would make a partial failure indistinguishable from success. Chunk at the call site and give each chunk its own key:
273
+
274
+ ```python
275
+ from meterflow import MAX_BATCH_EVENTS
276
+
277
+ for start in range(0, len(events), MAX_BATCH_EVENTS):
278
+ chunk = events[start : start + MAX_BATCH_EVENTS]
279
+ client.usage.record_batch(chunk, idempotency_key=f"job-42:chunk-{start // MAX_BATCH_EVENTS}")
280
+ ```
281
+
282
+ **Summary** — a customer's usage, broken down by meter:
283
+
284
+ ```python
285
+ summary = client.usage.summary(
286
+ "customer_123",
287
+ # meter_id="…", # optional — narrow to one meter
288
+ from_="2026-09-01T00:00:00Z", # optional — note the trailing underscore
289
+ to="2026-09-30T23:59:59Z", # optional
290
+ )
291
+ for m in summary["meters"]:
292
+ print(m) # per-meter aggregation and event counts
293
+ ```
294
+
295
+ > The `from_` filter has a trailing underscore — it mirrors the API's query parameter exactly (and `from` is a Python keyword anyway).
296
+
297
+ Recording an event returns immediately (`"processed": False`); totals and any credit deductions materialise moments later. Don't read a balance in the same millisecond and expect the event to be reflected.
298
+
299
+ ### Subscriptions
300
+
301
+ A subscription puts one customer on one plan and renews itself. Typically your app creates it in the signup flow:
302
+
303
+ ```python
304
+ # Pick a plan (defined in the dashboard)…
305
+ plans = client.plans.list()
306
+ starter = next(p for p in plans if p["slug"] == "starter")
307
+
308
+ # …and put the new customer on it
309
+ sub = client.subscriptions.create({
310
+ "plan_id": starter["id"],
311
+ "customer_external_id": "customer_123",
312
+ "metadata": {},
313
+ })
314
+ print(sub["status"]) # "trialing" if the plan has trial days, else "active"
315
+ ```
316
+
317
+ **List / get:**
318
+
319
+ ```python
320
+ everyone = client.subscriptions.list() # whole project (in your key's environment)
321
+ theirs = client.subscriptions.list(customer_id="customer_123") # one customer
322
+ one = client.subscriptions.get(sub["id"])
323
+ ```
324
+
325
+ > Like all reads, these are scoped to the key's environment: a live key lists live subscriptions only, a test key test ones only.
326
+
327
+ **Update** — change status or metadata. Statuses: `active`, `trialing`, `past_due`, `paused`, `canceled`, `expired`:
328
+
329
+ ```python
330
+ client.subscriptions.update(sub["id"], {"status": "paused"})
331
+ client.subscriptions.update(sub["id"], {"status": "active"}) # reactivate
332
+ ```
333
+
334
+ **Cancel vs delete** — two different operations:
335
+
336
+ ```python
337
+ client.subscriptions.update(sub["id"], {"status": "canceled"}) # cancel: the record stays for history
338
+ client.subscriptions.delete(sub["id"]) # delete: removes the subscription record entirely
339
+ ```
340
+
341
+ Prefer cancelling: it preserves the subscription's history (a canceled subscription can't be reactivated). Reach for `delete` only when you truly want the record gone — e.g. cleaning up test data.
342
+
343
+ ### Plans
344
+
345
+ Plans are **read-only** through the SDK — pricing is managed by humans in the dashboard, so a leaked API key can never rewrite your prices.
346
+
347
+ ```python
348
+ plans = client.plans.list() # the project's plans
349
+ plan = client.plans.get(plan_id) # one plan, including its per-meter limits and feature keys
350
+ plan["price"] # "29.00" — decimal string
351
+ plan["billing_period"] # "monthly" | "yearly" | "weekly" | "one_time"
352
+ plan["meter_limits"] # included units + overage rate per meter
353
+ plan["features"] # ["sso", "priority-support"] — the on/off keys check() answers yes to
354
+ ```
355
+
356
+ Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
357
+
358
+ ### Entitlements — ask before you act
359
+
360
+ 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.
361
+
362
+ ```python
363
+ gate = client.entitlements.check("customer_123", "images.generated")
364
+
365
+ if not gate["allowed"]:
366
+ # gate["reason"]: "hard_limit_reached" | "no_subscription" | "not_in_plan" | "insufficient_credits"
367
+ return show_upgrade_prompt(gate) # gate["used"] / gate["included"] / gate["period_end"] are there for the copy
368
+
369
+ generate_image()
370
+ client.usage.record({"event_name": "images.generated", "customer_external_id": "customer_123", "value": 1, "properties": {}})
371
+ ```
372
+
373
+ - `feature` is a meter's `event_name` (metered — `included`, `used`, `remaining` filled in) or a plan feature key like `"sso"` (boolean).
374
+ - `quantity=5` asks "may they do **5** more?" — one call instead of one per item for batch work.
375
+ - A **soft** limit answers `allowed: True` with `reason: "soft_limit_exceeded"` — let it through, nudge the upgrade.
376
+ - A **metered** limit stays allowed while the customer's credits cover the overage; otherwise `insufficient_credits`.
377
+ - `allowed: False` is a normal returned answer, never an exception. A feature key the project does not know at all is a `NotFoundError` — that is a typo, not a plan.
378
+ - The answer carries `Cache-Control: private, max-age=15`; on hot paths cache it per customer for a few seconds rather than calling on every request.
379
+
380
+ **All of them at once** — for a settings or pricing screen:
381
+
382
+ ```python
383
+ result = client.entitlements.get("customer_123")
384
+ result["entitlements"]
385
+ # [{"feature": "sso", "feature_type": "boolean", "allowed": True, ...},
386
+ # {"feature": "images.generated", "feature_type": "metered", "used": "120", "included": 500, "remaining": "380", ...}]
387
+ ```
388
+
389
+ Prefer to be told rather than to ask? Subscribe a webhook to `limit.reached` (see below).
390
+
391
+ ## Idempotency — safe retries for writes
392
+
393
+ 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:
394
+
395
+ ```python
396
+ client.credits.deduct(
397
+ {"customer_external_id": "customer_123", "amount": 1, "description": "ticket A7", "metadata": {}},
398
+ idempotency_key="deduct-ticket-A7", # forwarded as the Idempotency-Key header
399
+ )
400
+ # Sending this twice deducts exactly once and returns the same transaction both times.
401
+ ```
402
+
403
+ 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
+
405
+ ## Automatic retries
406
+
407
+ The SDK retries transient failures for you — exponential backoff with jitter, 3 attempts by default:
408
+
409
+ | Situation | Behaviour |
410
+ |---|---|
411
+ | Network error / connection dropped / timeout | Retried |
412
+ | `5xx` server errors | Retried |
413
+ | `429 Too Many Requests` | Waits for the server's `Retry-After`, then retries |
414
+ | Any other `4xx` (auth, validation, not-found, insufficient credits…) | **Never retried** — it would fail identically |
415
+
416
+ Configure with `retries=` in the constructor (`0` disables). Combine retries with idempotency keys on writes and a flaky network costs you nothing: the SDK re-sends, the server deduplicates. If a transport failure survives every retry, the underlying `httpx` exception (`httpx.ConnectError`, `httpx.ReadTimeout`, …) is raised as-is.
417
+
418
+ ## Error handling
419
+
420
+ Every non-2xx response is raised as a typed exception. All of them extend `MeterFlowError`:
421
+
422
+ | Class | HTTP | `error_type` | Retried by the SDK |
423
+ |---|---|---|---|
424
+ | `AuthError` | 401 / 403 | `auth_error` | no |
425
+ | `InsufficientCreditsError` | 402 | `insufficient_credits` | no |
426
+ | `NotFoundError` | 404 | `not_found` | no |
427
+ | `ConflictError` | 409 | `conflict` | no |
428
+ | `PayloadTooLargeError` | 413 | `payload_too_large` | no — split the batch (see `MAX_BATCH_EVENTS`) |
429
+ | `ValidationError` | 422 | `validation_error` | no — per-field detail in `.fields` |
430
+ | `RateLimitError` | 429 | `rate_limit` | yes (honours `Retry-After`, exposed as `.retry_after`) |
431
+ | `ServerError` | 5xx | `server_error` | yes |
432
+
433
+ Every error carries:
434
+
435
+ - **`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
+ - **`status_code`**, **`error_type`**, and **`retryable`** — for programmatic handling and structured logging.
437
+ - **`message`** (also `str(err)`) — the API's own sentence (e.g. `Plan limit reached: the Drip plan includes 5,000 usage events per month…`). A `ValidationError` appends its field detail (`Validation failed: amount: must be greater than 0`) and also exposes it structured as **`.fields`** (`[{"field": ..., "message": ...}]`).
438
+
439
+ ```python
440
+ from meterflow import MeterFlowError, InsufficientCreditsError, RateLimitError
441
+
442
+ try:
443
+ client.credits.deduct({"customer_external_id": "c1", "amount": 999, "metadata": {}})
444
+ except InsufficientCreditsError:
445
+ ... # expected business outcome — prompt a top-up
446
+ except RateLimitError as err:
447
+ log.warning("rate limited; server asked to wait %ss", err.retry_after) # only seen if retries are exhausted/disabled
448
+ except MeterFlowError as err:
449
+ log.error("MeterFlow error [%s] status=%s request_id=%s", err.error_type, err.status_code, err.request_id)
450
+ raise
451
+ ```
452
+
453
+ ## Verifying webhooks
454
+
455
+ MeterFlow notifies your app of activity you'd otherwise poll for — credits granted or deducted, usage recorded, subscriptions created or updated, a customer reaching a plan limit. Every delivery is signed with **HMAC-SHA256** in the `X-MeterFlow-Signature` header, using the webhook's secret — verify before trusting:
456
+
457
+ ```python
458
+ # FastAPI shown; the same three lines work in Flask, Django, or a bare WSGI handler.
459
+ from fastapi import FastAPI, Header, HTTPException, Request
460
+ from meterflow import verify_webhook
461
+
462
+ app = FastAPI()
463
+
464
+ @app.post("/meterflow-webhook")
465
+ async def meterflow_webhook(request: Request, x_meterflow_signature: str = Header("")):
466
+ raw_body = await request.body() # the signature covers the RAW bytes — read them before any JSON parsing
467
+
468
+ if not verify_webhook(raw_body, x_meterflow_signature, os.environ["METERFLOW_WEBHOOK_SECRET"]):
469
+ raise HTTPException(status_code=401, detail="invalid signature") # forged or corrupted — discard
470
+
471
+ event = json.loads(raw_body)
472
+ ... # handle the event
473
+ return {"ok": True}
474
+ ```
475
+
476
+ Details that matter:
477
+
478
+ - **Verify the raw bytes.** If you parse the JSON first and re-serialise, key ordering/whitespace change and the signature won't match.
479
+ - The comparison is **timing-safe** (`hmac.compare_digest`) and returns `False` on any mismatch — it never raises on bad input.
480
+ - The verifier is pure standard library (`hmac`, `hashlib`) — no network, no client needed; it is also importable as `meterflow.webhook.verify_webhook`.
481
+
482
+ ### Payload shape & event types
483
+
484
+ Every delivery is a flat JSON object (`Content-Type: application/json`) with two envelope fields — `event` (the type) and `environment` (`"live"` or `"test"`, matching the mode of the API key that caused the activity) — plus type-specific fields:
485
+
486
+ | `event` | Fired when | Extra fields |
487
+ |---|---|---|
488
+ | `usage.recorded` | a usage event is ingested (single or batch) | `customer_id`, `event_name`, `value`, `event_id` |
489
+ | `credit.granted` | credits are granted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
490
+ | `credit.deducted` | credits are deducted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
491
+ | `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
492
+ | `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
493
+ | `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` |
494
+
495
+ For example, a `credit.granted` delivery:
496
+
497
+ ```json
498
+ {
499
+ "event": "credit.granted",
500
+ "environment": "live",
501
+ "customer_id": "cust_123",
502
+ "amount": 500.0,
503
+ "balance_after": 1250.0,
504
+ "transaction_id": "9b2f6c1e-…"
505
+ }
506
+ ```
507
+
508
+ When registering a webhook you pick which of these event types it should receive. Deliveries are retried with backoff on non-2xx responses — respond `200` quickly and do the heavy work asynchronously.
509
+
510
+ Webhooks are registered per-project in the dashboard, which is also where you'll find the secret and each delivery attempt's status.
511
+
512
+ ## Typing notes
513
+
514
+ - All request/response shapes are **generated from MeterFlow's OpenAPI contract** as `TypedDict`s in `meterflow.types` (`CreditGrantRequest`, `EntitlementResponse`, `PlanResponse`, …). Responses are plain dicts at runtime — no model classes to learn, nothing to serialise — and mypy/pyright check every key you read or write.
515
+ - Request fields are `snake_case`, matching the HTTP API one-to-one (`customer_external_id`, not `customerExternalId`). What you see in the docs and dashboard is exactly what you type.
516
+ - `properties` (usage events) and `metadata` (credits/subscriptions) default to `{}` on the server; pass `{}` explicitly if your type checker asks for it.
517
+ - Public exports: `MeterFlow`, `AsyncMeterFlow`, the error classes, `MAX_BATCH_EVENTS` and `verify_webhook` from `meterflow`; the shapes from `meterflow.types`.
518
+
519
+ ## Versioning & support
520
+
521
+ - Semantic versioning on the `0.x` line: breaking changes bump the minor, fixes bump the patch.
522
+ - Tested in CI on Python **3.10, 3.11, 3.12 and 3.13**.
523
+ - The Node.js SDK ([`meterflow` on npm](https://www.npmjs.com/package/meterflow)) exposes the same resources, method names (camelCase there, snake_case here) and error hierarchy — switching languages costs nothing but syntax.
524
+ - Issues and source: [github.com/meterflowio/meterflow-python](https://github.com/meterflowio/meterflow-python). Include the `request_id` from any `MeterFlowError` when reporting API issues.
525
+
526
+ ## License
527
+
528
+ MIT
529
+
530
+ ## The full loop, end to end
531
+
532
+ ```python
533
+ import os
534
+ from meterflow import MeterFlow
535
+
536
+ with MeterFlow(api_key=os.environ["METERFLOW_API_KEY"]) as client:
537
+ # Signup: put the customer on a plan and give them their credits
538
+ starter = client.plans.list()[0]
539
+ client.subscriptions.create({"plan_id": starter["id"], "customer_external_id": "ana", "metadata": {}})
540
+ client.credits.grant(
541
+ {"customer_external_id": "ana", "amount": 5000, "description": "Starter grant", "metadata": {}},
542
+ idempotency_key="signup-ana-2026-09",
543
+ )
544
+
545
+ # Before every billable action: may she?
546
+ if client.entitlements.check("ana", "images.generated")["allowed"]:
547
+ # …do the work, then report it
548
+ client.usage.record({
549
+ "event_name": "images.generated",
550
+ "customer_external_id": "ana",
551
+ "value": 1,
552
+ "properties": {"model": "sdxl", "resolution": "1024x1024"},
553
+ })
554
+
555
+ # Support asks: "what's Ana's situation?"
556
+ balance = client.credits.balance("ana")
557
+ history = client.credits.transactions("ana")
558
+ usage = client.usage.summary("ana")
559
+ entitlements = client.entitlements.get("ana")
560
+ ```