meterflow 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +371 -28
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -1,10 +1,44 @@
1
1
  # meterflow
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/meterflow)](https://www.npmjs.com/package/meterflow)
4
+ [![node](https://img.shields.io/node/v/meterflow)](https://nodejs.org)
5
+ [![license](https://img.shields.io/npm/l/meterflow)](./LICENSE)
6
+
3
7
  Official Node.js SDK for [MeterFlow](https://meterflow.com) — usage-based billing, credit management, and metering.
4
8
 
9
+ Track what your customers use, enforce credit balances, and manage subscriptions with a few lines of code. TypeScript-first, zero runtime dependencies, built on native `fetch`.
10
+
11
+ ---
12
+
13
+ ## Contents
14
+
15
+ - [Requirements](#requirements)
16
+ - [Installation](#installation)
17
+ - [Quick start](#quick-start)
18
+ - [Authentication](#authentication)
19
+ - [Core concepts](#core-concepts)
20
+ - [Configuration](#configuration)
21
+ - [Usage guide](#usage-guide)
22
+ - [Credits](#credits)
23
+ - [Usage events](#usage-events)
24
+ - [Subscriptions](#subscriptions)
25
+ - [Plans](#plans)
26
+ - [Idempotency — safe retries for writes](#idempotency--safe-retries-for-writes)
27
+ - [Automatic retries](#automatic-retries)
28
+ - [Error handling](#error-handling)
29
+ - [Verifying webhooks](#verifying-webhooks)
30
+ - [Using the SDK in a browser](#using-the-sdk-in-a-browser)
31
+ - [TypeScript notes](#typescript-notes)
32
+ - [Versioning & support](#versioning--support)
33
+
34
+ ---
35
+
5
36
  ## Requirements
6
37
 
7
- - Node.js ≥ 18
38
+ - **Node.js ≥ 20** (the SDK uses the built-in `fetch` — no HTTP library is installed)
39
+ - An API key from your [MeterFlow dashboard](https://meterflow.com) (Project → API Keys)
40
+
41
+ > Need Node 18? It reached end-of-life in April 2025 — please upgrade. The last SDK line supporting it is `meterflow@0.2.x`.
8
42
 
9
43
  ## Installation
10
44
 
@@ -12,78 +46,387 @@ Official Node.js SDK for [MeterFlow](https://meterflow.com) — usage-based bill
12
46
  npm install meterflow
13
47
  ```
14
48
 
49
+ Ships dual **CJS + ESM** with bundled TypeScript types — `require` and `import` both work out of the box.
50
+
15
51
  ## Quick start
16
52
 
17
53
  ```typescript
18
54
  import { MeterFlow } from "meterflow";
19
55
 
20
- const client = new MeterFlow({ apiKey: "mf_live_your_api_key" });
56
+ const client = new MeterFlow({ apiKey: process.env.METERFLOW_API_KEY! });
21
57
 
22
- // Grant credits to a customer
58
+ // 1. Grant credits to a customer
23
59
  await client.credits.grant({
24
60
  customer_external_id: "customer_123",
25
- amount: 100,
26
- description: "Welcome credits",
61
+ amount: 5000,
62
+ description: "Starter plan — monthly credit grant",
63
+ metadata: {},
27
64
  });
28
65
 
29
- // Record a usage event
66
+ // 2. Report what happened, wherever your product does the billable thing
30
67
  await client.usage.record({
68
+ event_name: "images.generated",
31
69
  customer_external_id: "customer_123",
32
- meter_name: "api_calls",
33
- quantity: 1,
70
+ value: 1,
71
+ properties: {},
34
72
  });
35
73
 
36
- // Check credit balance
74
+ // 3. Check what they have left
37
75
  const balance = await client.credits.balance("customer_123");
38
- console.log(balance.balance);
76
+ console.log(balance.balance); // "4999.000000"
77
+ ```
78
+
79
+ That's the whole integration loop: grant → record → check. Everything else in this guide is detail.
80
+
81
+ ## Authentication
82
+
83
+ Every request authenticates with the API key you pass to the constructor:
84
+
85
+ | Key prefix | Environment | Use for |
86
+ |---|---|---|
87
+ | `mf_live_…` | Live | Real customers, real balances |
88
+ | `mf_test_…` | Test | Development, CI, experiments |
89
+
90
+ 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.
91
+
92
+ A key belongs to **one project** and can only see that project's data. Keep live and test traffic in separate projects and the two can never mix.
93
+
94
+ ```typescript
95
+ const client = new MeterFlow({ apiKey: "mf_test_..." }); // throws immediately if the prefix is neither mf_live_ nor mf_test_
39
96
  ```
40
97
 
98
+ > Treat API keys like passwords: read them from environment variables or a secret manager, never commit them.
99
+
100
+ ## Core concepts
101
+
102
+ Four words explain the whole product:
103
+
104
+ | Concept | What it is | Example |
105
+ |---|---|---|
106
+ | **Meter** | One countable thing, identified by its `event_name` | `images.generated`, `minutes.transcribed` |
107
+ | **Plan** | What you sell: price, billing period, and how much of each meter is included | Starter — $29/month, 5,000 images |
108
+ | **Subscription** | One customer on one plan from a date; renews itself | `customer_123` → Starter |
109
+ | **Credits** | The customer's balance — granted up, spent down, never edited in place | `+5000` granted, `−1340` consumed |
110
+
111
+ Meters and plans are defined in the dashboard. Your app, through this SDK, does the day-to-day work: creates subscriptions, grants/deducts credits, and records usage events (each event lands on the meter whose `event_name` matches).
112
+
41
113
  ## Configuration
42
114
 
43
115
  ```typescript
44
116
  const client = new MeterFlow({
45
- apiKey: "mf_live_your_api_key", // required; mf_live_* or mf_test_*
46
- baseUrl: "https://api.meterflow.com/api/v1", // optional override
47
- timeout: 30_000, // ms; default 30 s
48
- retries: 3, // default 3; 0 to disable
117
+ apiKey: "mf_live_...", // required — mf_live_* or mf_test_*
118
+ baseUrl: "https://api.meterflow.com/api/v1", // optional — override for self-hosted / local dev
119
+ timeout: 30_000, // optional — per-request timeout in ms (default 30 s)
120
+ retries: 3, // optional — automatic retries (default 3, 0 disables)
121
+ fetch: customFetch, // optional — inject your own fetch (tests, proxies)
49
122
  });
50
123
  ```
51
124
 
52
- ## Resources
125
+ Running against a local MeterFlow stack? `baseUrl: "http://localhost:8000/api/v1"`.
53
126
 
54
- | Resource | Methods |
55
- |---|---|
56
- | `client.credits` | `grant`, `deduct`, `balance`, `transactions` |
57
- | `client.usage` | `record`, `recordBatch`, `summary` |
58
- | `client.subscriptions` | `create`, `list`, `get`, `update`, `delete` |
59
- | `client.plans` | `list`, `get` |
127
+ All configuration lives on the client instance — there is no global state, so you can create multiple clients (e.g. one per project) in the same process.
128
+
129
+ ## Usage guide
130
+
131
+ ### Credits
132
+
133
+ 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.
134
+
135
+ **Grant** — add credits (plan renewals, top-ups, goodwill):
60
136
 
61
- ## Webhook verification
137
+ ```typescript
138
+ const txn = await client.credits.grant({
139
+ customer_external_id: "customer_123",
140
+ amount: 5000,
141
+ description: "Monthly plan grant",
142
+ metadata: {},
143
+ });
144
+ console.log(txn.balance_after); // "5000.000000"
145
+ ```
146
+
147
+ **Deduct** — remove credits. Throws `InsufficientCreditsError` (HTTP 402) if the balance can't cover it — the customer is never taken below zero:
62
148
 
63
149
  ```typescript
64
- import { verifyWebhook } from "meterflow/webhook";
150
+ import { InsufficientCreditsError } from "meterflow";
65
151
 
66
- const isValid = verifyWebhook(rawBody, req.headers["x-meterflow-signature"], webhookSecret);
152
+ try {
153
+ await client.credits.deduct({
154
+ customer_external_id: "customer_123",
155
+ amount: 25,
156
+ description: "images.generated ×25",
157
+ metadata: {},
158
+ });
159
+ } catch (err) {
160
+ if (err instanceof InsufficientCreditsError) {
161
+ // Show your "out of credits — top up" screen. This is an upgrade prompt, not an error page.
162
+ } else {
163
+ throw err;
164
+ }
165
+ }
166
+ ```
167
+
168
+ **Balance** — the current position:
169
+
170
+ ```typescript
171
+ const bal = await client.credits.balance("customer_123");
172
+ bal.balance; // "3545.000000" ← decimal string, see note below
173
+ bal.total_granted; // "5250.000000"
174
+ bal.total_consumed; // "1705.000000"
175
+ ```
176
+
177
+ **Transactions** — the full history, newest first, paginated:
178
+
179
+ ```typescript
180
+ const txns = await client.credits.transactions("customer_123", { page: 1, limit: 50 });
181
+ for (const t of txns) {
182
+ console.log(t.transaction_type, t.amount, t.balance_after, t.description, t.created_at);
183
+ }
67
184
  ```
68
185
 
186
+ > **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`Number(...)` or a decimal library) when you need arithmetic.
187
+
188
+ ### Usage events
189
+
190
+ 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).
191
+
192
+ **Record one event:**
193
+
194
+ ```typescript
195
+ await client.usage.record({
196
+ event_name: "images.generated",
197
+ customer_external_id: "customer_123",
198
+ value: 1, // what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
199
+ properties: {}, // free-form context; {} when unused
200
+ timestamp: new Date().toISOString(), // optional — defaults to arrival time on the server
201
+ });
202
+ ```
203
+
204
+ **Record a batch** — for high-throughput paths, flush events in groups instead of one request each:
205
+
206
+ ```typescript
207
+ await client.usage.recordBatch([
208
+ { event_name: "images.generated", customer_external_id: "customer_123", value: 1, properties: {} },
209
+ { event_name: "video.seconds_rendered", customer_external_id: "customer_123", value: 42, properties: {} },
210
+ ]);
211
+ ```
212
+
213
+ **Summary** — a customer's usage, broken down by meter:
214
+
215
+ ```typescript
216
+ const summary = await client.usage.summary("customer_123", {
217
+ // meter_id: "…", // optional — narrow to one meter
218
+ from_: "2026-09-01T00:00:00Z", // optional — note the trailing underscore
219
+ to: "2026-09-30T23:59:59Z", // optional
220
+ });
221
+ for (const m of summary.meters) {
222
+ console.log(m); // per-meter aggregation and event counts
223
+ }
224
+ ```
225
+
226
+ > The `from_` filter has a trailing underscore — it mirrors the API's query parameter exactly.
227
+
228
+ 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.
229
+
230
+ ### Subscriptions
231
+
232
+ A subscription puts one customer on one plan and renews itself. Typically your app creates it in the signup flow:
233
+
234
+ ```typescript
235
+ // Pick a plan (defined in the dashboard)…
236
+ const plans = await client.plans.list();
237
+ const starter = plans.find((p) => p.slug === "starter")!;
238
+
239
+ // …and put the new customer on it
240
+ const sub = await client.subscriptions.create({
241
+ plan_id: starter.id,
242
+ customer_external_id: "customer_123",
243
+ metadata: {},
244
+ });
245
+ console.log(sub.status); // "trialing" if the plan has trial days, else "active"
246
+ ```
247
+
248
+ **List / get:**
249
+
250
+ ```typescript
251
+ const all = await client.subscriptions.list(); // whole project
252
+ const theirs = await client.subscriptions.list({ customer_id: "customer_123" }); // one customer
253
+ const one = await client.subscriptions.get(sub.id);
254
+ ```
255
+
256
+ **Update** — change status or metadata. Statuses: `active`, `trialing`, `past_due`, `paused`, `canceled`, `expired`:
257
+
258
+ ```typescript
259
+ await client.subscriptions.update(sub.id, { status: "paused" });
260
+ await client.subscriptions.update(sub.id, { status: "active" }); // reactivate
261
+ ```
262
+
263
+ **Cancel:**
264
+
265
+ ```typescript
266
+ await client.subscriptions.delete(sub.id); // marks it canceled; history is preserved
267
+ ```
268
+
269
+ ### Plans
270
+
271
+ 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.
272
+
273
+ ```typescript
274
+ const plans = await client.plans.list(); // the project's plans
275
+ const plan = await client.plans.get(plan_id); // one plan, including its per-meter limits
276
+ plan.price; // "29.00" — decimal string
277
+ plan.billing_period; // "monthly" | "yearly" | "weekly" | "one_time"
278
+ plan.meter_limits; // included units + overage rate per meter
279
+ ```
280
+
281
+ Typical use: render your pricing page or signup flow from `plans.list()` so it can never drift from what billing actually enforces.
282
+
283
+ ## Idempotency — safe retries for writes
284
+
285
+ 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:
286
+
287
+ ```typescript
288
+ await client.credits.deduct(
289
+ { customer_external_id: "customer_123", amount: 1, description: "ticket A7", metadata: {} },
290
+ { idempotencyKey: "deduct-ticket-A7" }, // forwarded as the Idempotency-Key header
291
+ );
292
+ // Sending this twice deducts exactly once and returns the same transaction both times.
293
+ ```
294
+
295
+ Every write method accepts the option: `credits.grant/deduct`, `usage.record/recordBatch`, `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.
296
+
297
+ ## Automatic retries
298
+
299
+ The SDK retries transient failures for you — exponential backoff with jitter, 3 attempts by default:
300
+
301
+ | Situation | Behaviour |
302
+ |---|---|
303
+ | Network error / connection dropped | Retried |
304
+ | `5xx` server errors | Retried |
305
+ | `429 Too Many Requests` | Waits for the server's `Retry-After`, then retries |
306
+ | Any other `4xx` (auth, validation, not-found, insufficient credits…) | **Never retried** — it would fail identically |
307
+
308
+ 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.
309
+
69
310
  ## Error handling
70
311
 
312
+ Every non-2xx response is thrown as a typed error. All of them extend `MeterFlowError`:
313
+
314
+ | Class | HTTP | `errorType` | Retried by the SDK |
315
+ |---|---|---|---|
316
+ | `AuthError` | 401 / 403 | `auth_error` | no |
317
+ | `InsufficientCreditsError` | 402 | `insufficient_credits` | no |
318
+ | `NotFoundError` | 404 | `not_found` | no |
319
+ | `ConflictError` | 409 | `conflict` | no |
320
+ | `ValidationError` | 422 | `validation_error` | no |
321
+ | `RateLimitError` | 429 | `rate_limit` | yes (honours `Retry-After`, exposed as `.retryAfter`) |
322
+ | `ServerError` | 5xx | `server_error` | yes |
323
+
324
+ Every error carries:
325
+
326
+ - **`requestId`** — the API's `X-Request-ID` for that call. Include it when contacting support; it pinpoints the exact request in our logs.
327
+ - **`statusCode`**, **`errorType`**, and **`retryable`** — for programmatic handling and structured logging.
328
+
71
329
  ```typescript
72
330
  import { MeterFlowError, InsufficientCreditsError, RateLimitError } from "meterflow";
73
331
 
74
332
  try {
75
- await client.credits.deduct({ customer_external_id: "cust_1", amount: 999 });
333
+ await client.credits.deduct({ customer_external_id: "c1", amount: 999, metadata: {} });
76
334
  } catch (err) {
77
335
  if (err instanceof InsufficientCreditsError) {
78
- console.log("Not enough credits");
336
+ // expected business outcome — prompt a top-up
79
337
  } else if (err instanceof RateLimitError) {
80
- console.log(`Rate limited, retry after ${err.retryAfter}s`);
338
+ console.warn(`rate limited; server asked to wait ${err.retryAfter}s`); // only seen if retries are exhausted/disabled
81
339
  } else if (err instanceof MeterFlowError) {
82
- console.log(`Request ID for support: ${err.requestId}`);
340
+ console.error(`MeterFlow error [${err.errorType}] status=${err.statusCode} requestId=${err.requestId}`);
341
+ throw err;
342
+ } else {
343
+ throw err; // not from MeterFlow
83
344
  }
84
345
  }
85
346
  ```
86
347
 
348
+ ## Verifying webhooks
349
+
350
+ MeterFlow notifies your app of events you'd otherwise poll for (low balances, renewals, …). Every delivery is signed with **HMAC-SHA256** in the `X-MeterFlow-Signature` header, using the webhook's secret — verify before trusting:
351
+
352
+ ```typescript
353
+ import express from "express";
354
+ import { verifyWebhook } from "meterflow/webhook"; // note: separate entry point
355
+
356
+ const app = express();
357
+
358
+ // The signature covers the RAW body — capture it before any JSON parsing.
359
+ app.post("/meterflow-webhook", express.raw({ type: "application/json" }), (req, res) => {
360
+ const signature = req.header("X-MeterFlow-Signature") ?? "";
361
+
362
+ if (!verifyWebhook(req.body, signature, process.env.METERFLOW_WEBHOOK_SECRET!)) {
363
+ return res.status(401).send("invalid signature"); // forged or corrupted — discard
364
+ }
365
+
366
+ const event = JSON.parse(req.body.toString("utf8"));
367
+ // handle the event…
368
+ res.sendStatus(200);
369
+ });
370
+ ```
371
+
372
+ Details that matter:
373
+
374
+ - **Import from `meterflow/webhook`**, not the root package. The verifier uses Node's `crypto` and lives in its own entry point so the main client stays browser-safe.
375
+ - **Verify the raw bytes.** If you `JSON.parse` first and re-stringify, key ordering/whitespace changes and the signature won't match. `express.json()` users: configure it with a `verify` callback to keep `rawBody`, or use `express.raw` on the webhook route as above.
376
+ - The comparison is **timing-safe** (`crypto.timingSafeEqual`) and returns `false` on any mismatch — it never throws on bad input.
377
+
378
+ Webhooks are registered per-project in the dashboard, which is also where you'll find the secret and each delivery attempt's status.
379
+
380
+ ## Using the SDK in a browser
381
+
382
+ The root entry point is browser-safe: no Node built-ins, and `fetch` is bound correctly for browser environments. Two rules:
383
+
384
+ 1. **Never ship an `mf_live_` key to a browser.** Anyone can read it in DevTools. Browser usage is for trusted, short-lived contexts (internal tools, dashboards) with `mf_test_` or ephemeral keys — your product's customers should always go through **your** backend, which holds the key.
385
+ 2. **Don't import `meterflow/webhook` in browser code** — it needs Node's `crypto` (and verifying webhooks in a browser makes no sense anyway: the secret must stay server-side).
386
+
387
+ ## TypeScript notes
388
+
389
+ - All request/response types are **generated from MeterFlow's OpenAPI contract**, so anything the API accepts or returns is statically typed — your editor autocompletes every field above.
390
+ - 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.
391
+ - `properties` (usage events) and `metadata` (credits/subscriptions) are required by the generated types — pass `{}` when you have nothing to attach.
392
+ - Public exports: `MeterFlow` and the error classes from `"meterflow"`; `verifyWebhook` from `"meterflow/webhook"`. Response types are inferred from method return values.
393
+
394
+ ## Versioning & support
395
+
396
+ - Semantic versioning on the `0.x` line: breaking changes bump the minor, fixes bump the patch.
397
+ - `0.3.0` raised the Node floor to **≥ 20** (Node 18 is end-of-life). `0.2.x` remains available for Node 18.
398
+ - Tested in CI on Node **20, 22 and 24**.
399
+ - Issues and source: [github.com/meterflowio/meterflow-node](https://github.com/meterflowio/meterflow-node). Include the `requestId` from any `MeterFlowError` when reporting API issues.
400
+
87
401
  ## License
88
402
 
89
403
  MIT
404
+
405
+ ## The full loop, end to end
406
+
407
+ ```typescript
408
+ import { MeterFlow } from "meterflow";
409
+
410
+ const client = new MeterFlow({ apiKey: process.env.METERFLOW_API_KEY! });
411
+
412
+ // Signup: put the customer on a plan and give them their credits
413
+ const [starter] = await client.plans.list();
414
+ await client.subscriptions.create({ plan_id: starter.id, customer_external_id: "ana", metadata: {} });
415
+ await client.credits.grant(
416
+ { customer_external_id: "ana", amount: 5000, description: "Starter grant", metadata: {} },
417
+ { idempotencyKey: "signup-ana-2026-09" },
418
+ );
419
+
420
+ // Every time Ana uses the product
421
+ await client.usage.record({
422
+ event_name: "images.generated",
423
+ customer_external_id: "ana",
424
+ value: 1,
425
+ properties: { model: "sdxl", resolution: "1024x1024" },
426
+ });
427
+
428
+ // Support asks: "what's Ana's situation?"
429
+ const balance = await client.credits.balance("ana");
430
+ const history = await client.credits.transactions("ana", { limit: 20 });
431
+ const usage = await client.usage.summary("ana");
432
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meterflow",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Official Node.js SDK for MeterFlow — usage-based billing, credit management, and metering.",
5
5
  "license": "MIT",
6
6
  "author": "MeterFlow",
@@ -10,7 +10,7 @@
10
10
  "url": "https://github.com/meterflowio/meterflow-node.git"
11
11
  },
12
12
  "engines": {
13
- "node": ">=18"
13
+ "node": ">=20"
14
14
  },
15
15
  "main": "dist/index.cjs",
16
16
  "module": "dist/index.mjs",