mbase-sdk 0.1.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.
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,361 @@
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
+ occurred_at: "2026-03-22T10:15:02Z", // defaults to now
64
+ idempotency_key: "req_9f3a", // generated when you omit it
65
+ })
66
+
67
+ event.replayed // false the first time, true for a retry of the same call
68
+ ```
69
+
70
+ `occurred_at` chooses the period the event counts against and must fall inside
71
+ the workspace's window — by default 7 days back and 5 minutes ahead — which is
72
+ what lets usage be reported late. `recorded_at` is when Meterbase stored it.
73
+
74
+ #### Idempotency
75
+
76
+ `customer_id` + `idempotency_key` identifies one logical event.
77
+
78
+ | The retry sends | The engine answers |
79
+ | -------------------------------- | --------------------------------------------------------- |
80
+ | the same key, meter and quantity | the original event with `replayed: true`, writing nothing |
81
+ | the same key, a different event | `409 idempotency_conflict`, naming the original |
82
+
83
+ `occurred_at` is deliberately not compared, so omitting it and getting a fresh
84
+ server timestamp on the retry still replays rather than conflicts.
85
+
86
+ **The SDK generates a key when you omit one, and reuses it across that call's
87
+ retries** — including its own, since `track` is the one `POST` it will replay.
88
+ Supply your own when the caller already has an id for the work — a job id, a
89
+ request id — so that a retry from further out than this SDK replays too. Keys
90
+ are retained 35 days, longer than any `occurred_at` window, so a retry of a
91
+ retired key is refused by the window rather than recorded twice.
92
+
93
+ ### Customers
94
+
95
+ ```ts
96
+ const customer = await meterbase.customers.create({
97
+ external_id: "acct_1",
98
+ name: "Acme",
99
+ metadata: { tier: "pro" },
100
+ })
101
+
102
+ const found = await meterbase.customers.retrieveByExternalId("acct_1") // or null
103
+ const { data } = await meterbase.customers.list({ include_deleted: true })
104
+
105
+ await meterbase.customers.update(customer.id, { name: "Acme Inc" })
106
+ await meterbase.customers.delete(customer.id) // soft delete, idempotent
107
+ ```
108
+
109
+ ### Alert thresholds
110
+
111
+ `thresholds` is a list of whole percents — 1–1000, at most 32, and a mark over
112
+ 100 is an overage alert — and it hangs off both meters and customers. A
113
+ customer's list beats the meter's, which beats the workspace's defaults.
114
+
115
+ ```ts
116
+ await meterbase.meters.create({
117
+ key: "ai_tokens",
118
+ name: "Tokens",
119
+ thresholds: [80, 100],
120
+ })
121
+ await meterbase.customers.update(customer.id, { thresholds: [50, 90] }) // this customer only
122
+ ```
123
+
124
+ It is the one field where `null` is a value rather than silence:
125
+
126
+ | You send | It means |
127
+ | ------------------ | ------------------------------------------------- |
128
+ | nothing | keep the stored list |
129
+ | `thresholds: null` | inherit again — the meter's, then the workspace's |
130
+ | `thresholds: []` | alerts off for this scope alone |
131
+ | `thresholds: [80]` | these marks |
132
+
133
+ Crossings are recorded by the engine; no webhook delivers them yet.
134
+
135
+ ### Meters
136
+
137
+ ```ts
138
+ await meterbase.meters.create({ key: "api_calls", name: "API calls" }) // plus optional thresholds
139
+ const { data } = await meterbase.meters.list()
140
+ await meterbase.meters.archive(meterId) // soft delete, idempotent
141
+ ```
142
+
143
+ ### Plans
144
+
145
+ A plan has one billing period, fixed when the plan is created. Everything it
146
+ meters resets on that period, measured from each customer's own anchor — which
147
+ is why two customers on the same monthly plan renew on different days.
148
+
149
+ ```ts
150
+ const plan = await meterbase.plans.create({
151
+ key: "pro",
152
+ name: "Pro",
153
+ cycle: "monthly", // daily · weekly · monthly · quarterly · half_yearly · yearly
154
+ })
155
+
156
+ await meterbase.plans.update(plan.id, { name: "Pro Plus" })
157
+ await meterbase.plans.archive(plan.id) // stops new assignments, keeps existing ones
158
+ ```
159
+
160
+ `cycle` is not patchable. Changing it would re-date every period the plan's
161
+ customers were already measured against, so a different cadence is a different
162
+ plan — move a customer with `customers.plan.assign`.
163
+
164
+ ### Plan allowances
165
+
166
+ What a plan grants, one meter at a time. An allowance says _how much_, never
167
+ _how often_: the period is the plan's cycle. There is no update and no delete —
168
+ an edit appends the next version, so which amount applied when stays derivable.
169
+
170
+ ```ts
171
+ // The recurring cap, refreshed every period.
172
+ await meterbase.plans.allowances.set(plan.id, {
173
+ meter_id: meter.id,
174
+ amount: 1_000_000, // or no_cap: true
175
+ })
176
+
177
+ // A signup bonus beside it, minted once when the plan is assigned.
178
+ await meterbase.plans.allowances.set(plan.id, {
179
+ meter_id: meter.id,
180
+ amount: 500,
181
+ kind: "one_time",
182
+ valid_for: { months: 3, days: 0 },
183
+ })
184
+
185
+ const { data } = await meterbase.plans.allowances.list(plan.id)
186
+ ```
187
+
188
+ Each `(plan, meter)` pair carries up to two lineages, `recurring` and
189
+ `one_time`, numbering themselves separately. `kind` defaults to `recurring`.
190
+
191
+ ### A customer's plan
192
+
193
+ Append-only: assigning is the only write, and it serves both a first plan and
194
+ a later change.
195
+
196
+ ```ts
197
+ await meterbase.customers.plan.assign(customer.id, {
198
+ plan_id: plan.id,
199
+ effective: "next_cycle", // or "immediate"
200
+ })
201
+
202
+ const current = await meterbase.customers.plan.retrieve(customer.id)
203
+ const { data } = await meterbase.customers.plan.history(customer.id)
204
+ ```
205
+
206
+ A first assignment is always recorded as `immediate` — there is no period to
207
+ wait out — so read `effective` on the response rather than assuming it took
208
+ what you asked for. `next_cycle` lands on the first boundary the two plans'
209
+ cycles share; a weekly plan meeting a month-based one shares none and answers
210
+ `422 cycle_change_requires_reset`.
211
+
212
+ ### A customer's allowances
213
+
214
+ Capacity on top of whatever the plan gives.
215
+
216
+ ```ts
217
+ const grant = await meterbase.customers.allowances.grant(customer.id, {
218
+ meter_id: meter.id,
219
+ amount: 500,
220
+ source: "purchased", // or "bonus" · "manual"
221
+ })
222
+
223
+ const { data } = await meterbase.customers.allowances.list(customer.id, {
224
+ include_closed: true,
225
+ })
226
+
227
+ await meterbase.customers.allowances.revoke(customer.id, grant.id)
228
+ ```
229
+
230
+ Revoking withdraws what is left without erasing what was consumed, and is
231
+ idempotent. Grants the engine minted itself from a plan's `one_time` allowance
232
+ appear here with `source: "plan"`; they cannot be created through `grant`.
233
+
234
+ ## Errors
235
+
236
+ Every failure is a `MeterbaseError`. Catch the specific one you care about, or
237
+ the base class for all of them.
238
+
239
+ ```ts
240
+ import { ConflictError, RateLimitError } from "@meterbase/sdk"
241
+
242
+ try {
243
+ await meterbase.customers.create({ external_id: "acct_1" })
244
+ } catch (error) {
245
+ if (error instanceof ConflictError) {
246
+ // error.code === "customer_external_id_taken"
247
+ }
248
+ if (error instanceof RateLimitError) {
249
+ // error.retryAfter, in seconds
250
+ }
251
+ throw error
252
+ }
253
+ ```
254
+
255
+ | Class | When |
256
+ | ---------------------------------- | ---------------------------------------------------------------------------------------- |
257
+ | `AuthenticationError` | 401 — unknown, revoked or malformed key |
258
+ | `PermissionDeniedError` | 403 — the key may not use this route |
259
+ | `NotFoundError` | 404 |
260
+ | `ConflictError` | 409 — a key or external id is taken |
261
+ | `IdempotencyConflictError` | 409 — a `ConflictError` naming the event that key already recorded, on `existingEventId` |
262
+ | `InvalidRequestError` | 422 — a field failed validation |
263
+ | `RateLimitError` | 429 |
264
+ | `ServerError` | 5xx |
265
+ | `ConnectionError` / `TimeoutError` | the request never got an answer |
266
+
267
+ `APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
268
+ which is stable; `message` is for humans and may change.
269
+
270
+ ## Options
271
+
272
+ ```ts
273
+ new Meterbase({
274
+ apiKey: "mb_sk_live_…",
275
+ baseUrl: "https://api.meterbase.dev",
276
+ timeout: 10_000, // per attempt
277
+ maxRetries: 2,
278
+ fetch: customFetch,
279
+ })
280
+ ```
281
+
282
+ Per-call overrides, including cancellation:
283
+
284
+ ```ts
285
+ await meterbase.meters.list({}, { signal: controller.signal, timeout: 2_000 })
286
+ ```
287
+
288
+ **Retries** apply to `GET`, `DELETE` and `track`, on connection failures, 408,
289
+ 429 and 5xx, with exponential backoff and full jitter. `Retry-After` is honoured
290
+ when the engine sends it. Every other `POST` and `PATCH` is never replayed,
291
+ because a retried create could produce a second row.
292
+
293
+ `track` is the exception because it carries an idempotency key, and because the
294
+ SDK fixes that key **before** the retry loop starts: every attempt of one call
295
+ names the same logical event, so the engine replays it instead of counting the
296
+ work twice. If you retry `track` yourself, pass your own `idempotency_key` and
297
+ keep it the same across attempts for exactly that reason.
298
+
299
+ ## Types
300
+
301
+ Types mirror the wire exactly, `snake_case` included, so this SDK reads the
302
+ same as the API reference and cannot drift from it through a translation layer.
303
+
304
+ ## Versioning
305
+
306
+ **0.2.0 is a breaking change.** `CheckResult` lost `remaining` and `breakdown`
307
+ and gained `available: number | null`, following the engine's `check`, which
308
+ now answers from one cached integer and returns a single figure. Read
309
+ `available`, and take `null` under `no_cap` as "capacity is not a number here"
310
+ rather than as a 0. The per-grant detail moved to
311
+ `customers.allowances.list()`.
312
+
313
+ Additive in the same release: `thresholds` on `Meter` and `Customer`, and on
314
+ their create and update params.
315
+
316
+ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
317
+ change that will not compile against your code cannot arrive as a patch.
318
+
319
+ ## Development
320
+
321
+ ```bash
322
+ npm install
323
+ npm test # vitest, against a local test double
324
+ npm run check # typecheck, lint, format, test, build, publint, attw
325
+ ```
326
+
327
+ ### Two layers of tests
328
+
329
+ **`npm test`** runs against a real `node:http` server on an ephemeral port
330
+ rather than a stubbed `fetch`, so request building, headers, status handling
331
+ and the retry loop are exercised for real. It can make the server drop a
332
+ socket or stall, which is the only way to test the retry and timeout paths.
333
+
334
+ What it cannot check is whether those scripted responses match the engine: a
335
+ route that does not exist, a renamed field or a changed error code all pass
336
+ here and fail in production.
337
+
338
+ **`npm run test:contract`** closes that gap by running against a live engine.
339
+
340
+ ```bash
341
+ cp .env.example .env # then fill in a key, or export the two variables
342
+ METERBASE_BASE_URL=http://localhost:8080 \
343
+ METERBASE_API_KEY=mb_sk_live_… \
344
+ npm run test:contract
345
+ ```
346
+
347
+ It asserts the whole lifecycle of a customer, a meter and a plan; that a plan's
348
+ allowances become real entitlement a `check` can see, with the recurring cap
349
+ and the minted bonus summed into `available`; that tracked usage takes exactly
350
+ that much away, that a replay takes nothing, and that a reused key naming a
351
+ different event is refused; that soft deletes and archiving behave as
352
+ documented; that duplicates really answer
353
+ `409 customer_external_id_taken` / `409 meter_key_taken`; and — via
354
+ `Record<keyof T, true>` field maps — that the engine's fields still match the
355
+ SDK's types exactly, in both directions.
356
+
357
+ > **It creates and deletes real data.** Point it at a test workspace. It only
358
+ > touches resources it created, tagging each with a per-run id, and cleans up
359
+ > afterwards; soft-deleted customers and archived meters and plans remain, as
360
+ > the engine intends. It is excluded from `npm test` on purpose — a mutating suite should
361
+ > only run because someone asked for it.