mbase-sdk 0.0.2

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Meterbase
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.
package/README.md ADDED
@@ -0,0 +1,497 @@
1
+ # @meterbase/sdk
2
+
3
+ TypeScript SDK for the [Meterbase](https://github.com/usemeterbase/engine)
4
+ engine. Works on Node 18+, browsers, and edge runtimes — it uses `fetch` and
5
+ nothing else.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @meterbase/sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ Two calls, in this order: **`check` → do the work → `track`.**
16
+
17
+ ```ts
18
+ import { Meterbase } from "@meterbase/sdk"
19
+
20
+ const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
21
+
22
+ const { allowed } = await meterbase.check({
23
+ customer_id: "acct_1", // the tenant's own id, not ours
24
+ meter_id: "ai_tokens", // the meter's key, not ours
25
+ quantity: 50_000, // what you are about to spend; defaults to 1
26
+ })
27
+
28
+ if (!allowed) throw new Error("Out of quota")
29
+
30
+ const completion = await generate(prompt)
31
+
32
+ await meterbase.track({
33
+ customer_id: "acct_1",
34
+ meter_id: "ai_tokens",
35
+ quantity: completion.usage.total_tokens,
36
+ })
37
+ ```
38
+
39
+ An API key names its own workspace, so there is no workspace to pass.
40
+
41
+ `check` is the gate, and the only call that ever refuses. It never throws for a
42
+ denial — that is an answer, not a failure. Absence of entitlement is not
43
+ permission: a customer with no plan and no grant is denied.
44
+
45
+ `available` is the whole capacity — the plan's unused entitlement for the
46
+ current period plus every open grant — and is `null` under `no_cap`, where
47
+ capacity is not a number. It is one figure and not a breakdown: `check` answers
48
+ from a single cached integer, and itemising where the capacity came from would
49
+ cost it that. For the per-grant detail, read a customer's allowances.
50
+
51
+ ### Recording usage
52
+
53
+ `track` counts work that already happened — the tokens were spent, the image
54
+ was generated — so it **never refuses on capacity**. A quantity beyond the
55
+ customer's capacity is recorded, drives `available` to 0, and the next `check`
56
+ says no. The answer is a receipt, with no verdict and nothing to branch on:
57
+
58
+ ```ts
59
+ const { event } = await meterbase.track({
60
+ customer_id: "acct_1",
61
+ meter_id: "ai_tokens",
62
+ quantity: 250_000,
63
+ idempotency_key: "req_9f3a", // generated when you omit it
64
+ })
65
+
66
+ event.replayed // false the first time, true for a retry of the same call
67
+ ```
68
+
69
+ **The engine dates the event, not you.** There is no `occurred_at` to send: an
70
+ event is stamped by the database clock the write runs under, which is what
71
+ chooses the period it counts against. `occurred_at` and `recorded_at` come back
72
+ on the receipt as the same instant, both on the wire so a reader of either
73
+ keeps working. Nothing can be dated into a period that has already closed, so a
74
+ bill drawn from a period is never moved by a report that arrives after it.
75
+
76
+ #### Idempotency
77
+
78
+ `customer_id` + `idempotency_key` identifies one logical event.
79
+
80
+ | The retry sends | The engine answers |
81
+ | -------------------------------- | --------------------------------------------------------- |
82
+ | the same key, meter and quantity | the original event with `replayed: true`, writing nothing |
83
+ | the same key, a different event | `409 idempotency_conflict`, naming the original |
84
+
85
+ The timestamp is deliberately not compared: every call is stamped with its own
86
+ clock, so a retry carries a later one, and refusing that would punish the
87
+ ordinary retry. The original's timestamp comes back unchanged.
88
+
89
+ **The SDK generates a key when you omit one, and reuses it across that call's
90
+ retries** — including its own, since `track` is the one `POST` it will replay.
91
+ Supply your own when the caller already has an id for the work — a job id, a
92
+ request id — so that a retry from further out than this SDK replays too. Keys
93
+ are retained 35 days; a retry after that records a second event, which is not a
94
+ case this API sets out to serve.
95
+
96
+ ### Customers
97
+
98
+ ```ts
99
+ const customer = await meterbase.customers.create({
100
+ external_id: "acct_1",
101
+ name: "Acme",
102
+ metadata: { tier: "pro" },
103
+ })
104
+
105
+ const found = await meterbase.customers.retrieveByExternalId("acct_1") // or null
106
+ const { data } = await meterbase.customers.list({ include_deleted: true })
107
+
108
+ await meterbase.customers.update(customer.id, { name: "Acme Inc" })
109
+ await meterbase.customers.delete(customer.id) // soft delete, idempotent
110
+ ```
111
+
112
+ **Reading one customer tells you their plan.** `retrieve` and
113
+ `retrieveByExternalId` carry `plan` — the one in force now — and
114
+ `pending_plan`, the change waiting to land, each `null` when there is none:
115
+
116
+ ```ts
117
+ const acme = await meterbase.customers.retrieve(customer.id)
118
+
119
+ acme.plan?.key // "pro"
120
+ acme.plan?.cycle // "monthly" — what their period is measured in
121
+ acme.plan?.anchor_at // what it is measured from
122
+ acme.pending_plan // null, or the plan they move to and when
123
+ ```
124
+
125
+ `plan.id` is the **plan's** id, not the assignment's: what you want from an
126
+ embed is which plan, and `customers.plan.retrieve` is where instructions are
127
+ identified. `null` means they hold no plan, which is a default deny — a
128
+ customer with no plan and no grant is denied.
129
+
130
+ `customers.list` stays lean and does not embed, along with `create`, `update`
131
+ and `delete`; the same read would run per row for an answer most callers of a
132
+ list do not want.
133
+
134
+ ### Alert thresholds
135
+
136
+ `thresholds` is a list of whole percents — 1–1000, at most 32, and a mark over
137
+ 100 is an overage alert — and it hangs off both meters and customers. A
138
+ customer's list beats the meter's, which beats the workspace's defaults.
139
+
140
+ ```ts
141
+ await meterbase.meters.create({
142
+ key: "ai_tokens",
143
+ name: "Tokens",
144
+ thresholds: [80, 100],
145
+ })
146
+ await meterbase.customers.update(customer.id, { thresholds: [50, 90] }) // this customer only
147
+ ```
148
+
149
+ It is the one field where `null` is a value rather than silence:
150
+
151
+ | You send | It means |
152
+ | ------------------ | ------------------------------------------------- |
153
+ | nothing | keep the stored list |
154
+ | `thresholds: null` | inherit again — the meter's, then the workspace's |
155
+ | `thresholds: []` | alerts off for this scope alone |
156
+ | `thresholds: [80]` | these marks |
157
+
158
+ Crossings are recorded by the engine; no webhook delivers them yet.
159
+
160
+ ### Meters
161
+
162
+ ```ts
163
+ await meterbase.meters.create({ key: "api_calls", name: "API calls" }) // plus optional thresholds
164
+ const { data } = await meterbase.meters.list()
165
+ await meterbase.meters.archive(meterId) // soft delete, idempotent
166
+ ```
167
+
168
+ ### Plans
169
+
170
+ A plan has one billing period, fixed when the plan is created. Everything it
171
+ meters resets on that period, measured from each customer's own anchor — which
172
+ is why two customers on the same monthly plan renew on different days.
173
+
174
+ ```ts
175
+ const plan = await meterbase.plans.create({
176
+ key: "pro",
177
+ name: "Pro",
178
+ cycle: "monthly", // daily · weekly · monthly · quarterly · half_yearly · yearly
179
+ })
180
+
181
+ await meterbase.plans.update(plan.id, { name: "Pro Plus" })
182
+ await meterbase.plans.archive(plan.id) // stops new assignments, keeps existing ones
183
+ ```
184
+
185
+ `cycle` is not patchable. Changing it would re-date every period the plan's
186
+ customers were already measured against, so a different cadence is a different
187
+ plan — move a customer with `customers.plan.assign`.
188
+
189
+ ### Plan allowances
190
+
191
+ What a plan grants, one meter at a time. An allowance says _how much_, never
192
+ _how often_: the period is the plan's cycle. There is no update and no delete —
193
+ an edit appends the next version, so which amount applied when stays derivable.
194
+
195
+ ```ts
196
+ // The recurring cap, refreshed every period.
197
+ await meterbase.plans.allowances.set(plan.id, {
198
+ meter_id: meter.id,
199
+ amount: 1_000_000, // or no_cap: true
200
+ })
201
+
202
+ // A signup bonus beside it, minted once when the plan is assigned.
203
+ await meterbase.plans.allowances.set(plan.id, {
204
+ meter_id: meter.id,
205
+ amount: 500,
206
+ kind: "one_time",
207
+ valid_for: { months: 3, days: 0 },
208
+ })
209
+
210
+ const { data } = await meterbase.plans.allowances.list(plan.id)
211
+ ```
212
+
213
+ Each `(plan, meter)` pair carries up to two lineages, `recurring` and
214
+ `one_time`, numbering themselves separately. `kind` defaults to `recurring`.
215
+
216
+ ### A customer's plan
217
+
218
+ A customer's plan is an append-only history, not a field: assigning is the only
219
+ write, and it serves both a first plan and every later change. What differs
220
+ between the changes is **when** the new plan starts and **what happens to the
221
+ period the change lands in** — so each of those is its own call.
222
+
223
+ #### Putting them on a plan
224
+
225
+ ```ts
226
+ await meterbase.customers.plan.assign(customer.id, { plan_id: plan.id })
227
+ ```
228
+
229
+ A first assignment is always recorded as `immediate`: there is no period to
230
+ wait out. It is the one call that takes `cycle_anchor`, which puts their
231
+ billing periods on a date they already have — migrating them from another
232
+ system, say. Their renewal day is fixed from here and does not move again
233
+ unless you `restart` them.
234
+
235
+ #### Moving them to another plan
236
+
237
+ | Call | The new plan starts | This period's cap |
238
+ | ------------------------- | ------------------- | ------------------------------------------------ |
239
+ | `changeAtNextCycle` | at their renewal | untouched — they keep what they paid for |
240
+ | `changeNow` | now | the new plan's amount, for the whole period |
241
+ | `changeNow` + `"prorate"` | now | the two plans weighted by how long each was held |
242
+ | `restart` | now | a **fresh period** starts today |
243
+
244
+ ```ts
245
+ // Upgrade them at their renewal. Nothing is split, so nothing is pro-rated.
246
+ await meterbase.customers.plan.changeAtNextCycle(customer.id, { plan: pro.id })
247
+
248
+ // Move them now. Usage already spent still counts, so a downgrade can deny
249
+ // until the period ends.
250
+ await meterbase.customers.plan.changeNow(customer.id, { plan: starter.id })
251
+
252
+ // Move them now and split the period fairly: 1,000/mo held for half a month
253
+ // then 400/mo for the other half is a cap of 700.
254
+ await meterbase.customers.plan.changeNow(customer.id, {
255
+ plan: starter.id,
256
+ reconciliation: "prorate",
257
+ })
258
+
259
+ // Start over today: the running period closes where the change lands and
260
+ // keeps its usage, and the new plan's full amount opens at once.
261
+ await meterbase.customers.plan.restart(customer.id, { plan: pro.id })
262
+ ```
263
+
264
+ **`restart` is the one that moves their renewal day**, because it re-anchors
265
+ them to today. The other three leave it exactly where it was.
266
+
267
+ `change(customerId, { plan, effective, reconciliation })` is the same call with
268
+ both knobs in the open, for when they are chosen at runtime. `assign` is the
269
+ wire shape underneath all four, and still takes `plan_id`.
270
+
271
+ #### Two plans on different cycles
272
+
273
+ A weekly plan and a monthly one share no boundary to wait for and no period to
274
+ weight, so `changeAtNextCycle` and `changeNow` + `"prorate"` both answer
275
+ `CycleChangeRequiresResetError`. `restart` is the move that works, and plain
276
+ `changeNow` is the other way out — it keeps the period that is running.
277
+
278
+ ```ts
279
+ import { CycleChangeRequiresResetError } from "@meterbase/sdk"
280
+
281
+ try {
282
+ await meterbase.customers.plan.changeAtNextCycle(customer.id, {
283
+ plan: weekly.id,
284
+ })
285
+ } catch (error) {
286
+ if (error instanceof CycleChangeRequiresResetError) {
287
+ await meterbase.customers.plan.restart(customer.id, { plan: weekly.id })
288
+ } else throw error
289
+ }
290
+ ```
291
+
292
+ #### Reading where they stand
293
+
294
+ ```ts
295
+ const current = await meterbase.customers.plan.retrieve(customer.id) // or null
296
+ const scheduled = await meterbase.customers.plan.pending(customer.id) // or null
297
+ const { data } = await meterbase.customers.plan.history(customer.id)
298
+ ```
299
+
300
+ `retrieve` is the plan **in force now**, which is not always the newest
301
+ instruction — a change dated ahead does not govern yet — and is `null` for a
302
+ customer holding no plan. That is a default deny rather than a failure, so it
303
+ is an answer here rather than a thrown `NotFoundError`; a customer who does not
304
+ exist at all still throws one.
305
+
306
+ `pending` is the change waiting to land, and at most one ever is: a newer
307
+ instruction supersedes the one before it. `history` is the whole trail,
308
+ superseded rows included, newest first — it is the audit log of what was asked
309
+ for, not only of what happened.
310
+
311
+ To call off a change that has not landed:
312
+
313
+ ```ts
314
+ await meterbase.customers.plan.cancelScheduledChange(customer.id)
315
+ ```
316
+
317
+ Naming the plan a customer already holds is what supersedes a pending
318
+ instruction, and writes no new row of its own — so this reads the plan in force
319
+ and names it back. It returns the assignment still in force, or `null` for a
320
+ customer holding no plan, who can have nothing pending.
321
+
322
+ ### A customer's allowances
323
+
324
+ Capacity on top of whatever the plan gives.
325
+
326
+ ```ts
327
+ const grant = await meterbase.customers.allowances.grant(customer.id, {
328
+ meter_id: meter.id,
329
+ amount: 500,
330
+ source: "purchased", // or "bonus" · "manual"
331
+ })
332
+
333
+ const { data } = await meterbase.customers.allowances.list(customer.id, {
334
+ include_closed: true,
335
+ })
336
+
337
+ await meterbase.customers.allowances.revoke(customer.id, grant.id)
338
+ ```
339
+
340
+ Revoking withdraws what is left without erasing what was consumed, and is
341
+ idempotent. Grants the engine minted itself from a plan's `one_time` allowance
342
+ appear here with `source: "plan"`; they cannot be created through `grant`.
343
+
344
+ ## Errors
345
+
346
+ Every failure is a `MeterbaseError`. Catch the specific one you care about, or
347
+ the base class for all of them.
348
+
349
+ ```ts
350
+ import { ConflictError, RateLimitError } from "@meterbase/sdk"
351
+
352
+ try {
353
+ await meterbase.customers.create({ external_id: "acct_1" })
354
+ } catch (error) {
355
+ if (error instanceof ConflictError) {
356
+ // error.code === "customer_external_id_taken"
357
+ }
358
+ if (error instanceof RateLimitError) {
359
+ // error.retryAfter, in seconds
360
+ }
361
+ throw error
362
+ }
363
+ ```
364
+
365
+ | Class | When |
366
+ | ---------------------------------- | ---------------------------------------------------------------------------------------- |
367
+ | `AuthenticationError` | 401 — unknown, revoked or malformed key |
368
+ | `PermissionDeniedError` | 403 — the key may not use this route |
369
+ | `NotFoundError` | 404 |
370
+ | `ConflictError` | 409 — a key or external id is taken |
371
+ | `IdempotencyConflictError` | 409 — a `ConflictError` naming the event that key already recorded, on `existingEventId` |
372
+ | `InvalidRequestError` | 422 — a field failed validation |
373
+ | `CycleChangeRequiresResetError` | 422 — an `InvalidRequestError`: the two plans' cycles differ, so use `restart` |
374
+ | `RateLimitError` | 429 |
375
+ | `ServerError` | 5xx |
376
+ | `ConnectionError` / `TimeoutError` | the request never got an answer |
377
+
378
+ `APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
379
+ which is stable; `message` is for humans and may change.
380
+
381
+ ## Options
382
+
383
+ ```ts
384
+ new Meterbase({
385
+ apiKey: "mb_sk_live_…",
386
+ baseUrl: "https://api.meterbase.dev",
387
+ timeout: 10_000, // per attempt
388
+ maxRetries: 2,
389
+ fetch: customFetch,
390
+ })
391
+ ```
392
+
393
+ Per-call overrides, including cancellation:
394
+
395
+ ```ts
396
+ await meterbase.meters.list({}, { signal: controller.signal, timeout: 2_000 })
397
+ ```
398
+
399
+ **Retries** apply to `GET`, `DELETE` and `track`, on connection failures, 408,
400
+ 429 and 5xx, with exponential backoff and full jitter. `Retry-After` is honoured
401
+ when the engine sends it. Every other `POST` and `PATCH` is never replayed,
402
+ because a retried create could produce a second row.
403
+
404
+ `track` is the exception because it carries an idempotency key, and because the
405
+ SDK fixes that key **before** the retry loop starts: every attempt of one call
406
+ names the same logical event, so the engine replays it instead of counting the
407
+ work twice. If you retry `track` yourself, pass your own `idempotency_key` and
408
+ keep it the same across attempts for exactly that reason.
409
+
410
+ ## Types
411
+
412
+ Types mirror the wire exactly, `snake_case` included, so this SDK reads the
413
+ same as the API reference and cannot drift from it through a translation layer.
414
+
415
+ ## Versioning
416
+
417
+ **0.3.0 is a breaking change**, following the engine's own.
418
+
419
+ - `track` no longer takes `occurred_at`. The engine dates every event by the
420
+ database clock the write runs under, and refuses the field outright. The
421
+ receipt still carries `occurred_at` and `recorded_at`, now the same instant.
422
+ `occurred_at_out_of_window` no longer exists.
423
+ - `Reconciliation` is `none | prorate | reset`. `rebalance` is gone from what
424
+ you can send; it survives on historical assignment rows, which read as
425
+ `RecordedReconciliation`. `reconciliation_already_applied` and
426
+ `reconciliation_unsupported` no longer exist.
427
+ - Plan allowances have no `reconciliation`, on the way in or out.
428
+ - `customers.plan.retrieve` returns `Assignment | null` rather than throwing a
429
+ `NotFoundError` for a customer holding no plan.
430
+ - `customers.retrieve` and `retrieveByExternalId` return a `CustomerWithPlan`:
431
+ the same customer with `plan` and `pending_plan` embedded. `customers.list`,
432
+ `create`, `update` and `delete` still answer a plain `Customer`.
433
+
434
+ Additive in the same release: the plan-change helpers — `change`, `changeNow`,
435
+ `changeAtNextCycle`, `restart`, `cancelScheduledChange` and `pending` — and
436
+ `CycleChangeRequiresResetError`.
437
+
438
+ **0.2.0 was a breaking change.**
439
+ `CheckResult` lost `remaining` and `breakdown`
440
+ and gained `available: number | null`, following the engine's `check`, which
441
+ now answers from one cached integer and returns a single figure. Read
442
+ `available`, and take `null` under `no_cap` as "capacity is not a number here"
443
+ rather than as a 0. The per-grant detail moved to
444
+ `customers.allowances.list()`.
445
+
446
+ Additive in the same release: `thresholds` on `Meter` and `Customer`, and on
447
+ their create and update params.
448
+
449
+ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
450
+ change that will not compile against your code cannot arrive as a patch.
451
+
452
+ ## Development
453
+
454
+ ```bash
455
+ npm install
456
+ npm test # vitest, against a local test double
457
+ npm run check # typecheck, lint, format, test, build, publint, attw
458
+ ```
459
+
460
+ ### Two layers of tests
461
+
462
+ **`npm test`** runs against a real `node:http` server on an ephemeral port
463
+ rather than a stubbed `fetch`, so request building, headers, status handling
464
+ and the retry loop are exercised for real. It can make the server drop a
465
+ socket or stall, which is the only way to test the retry and timeout paths.
466
+
467
+ What it cannot check is whether those scripted responses match the engine: a
468
+ route that does not exist, a renamed field or a changed error code all pass
469
+ here and fail in production.
470
+
471
+ **`npm run test:contract`** closes that gap by running against a live engine.
472
+
473
+ ```bash
474
+ cp .env.example .env # then fill in a key, or export the two variables
475
+ METERBASE_BASE_URL=http://localhost:8080 \
476
+ METERBASE_API_KEY=mb_sk_live_… \
477
+ npm run test:contract
478
+ ```
479
+
480
+ It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
481
+ allowances become real entitlement a `check` can see, with the recurring cap
482
+ and the minted bonus summed into `available`; that tracked usage takes exactly
483
+ that much away, that a replay takes nothing, and that a reused key naming a
484
+ different event is refused; that a scheduled plan change is dated ahead and
485
+ reads as `pending`, that cancelling it supersedes it, that a `restart`
486
+ re-anchors the customer, and that two plans on different cycles refuse a
487
+ scheduled change; that soft deletes and archiving behave as
488
+ documented; that duplicates really answer
489
+ `409 customer_external_id_taken` / `409 meter_key_taken`; and — via
490
+ `Record<keyof T, true>` field maps — that the engine's fields still match the
491
+ SDK's types exactly, in both directions.
492
+
493
+ > **It creates and deletes real data.** Point it at a test workspace. It only
494
+ > touches resources it created, tagging each with a per-run id, and cleans up
495
+ > afterwards; soft-deleted customers and archived meters and plans remain, as
496
+ > the engine intends. It is excluded from `npm test` on purpose — a mutating suite should
497
+ > only run because someone asked for it.