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 +21 -0
- package/README.md +361 -0
- package/dist/index.cjs +589 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +557 -0
- package/dist/index.d.ts +557 -0
- package/dist/index.js +574 -0
- package/dist/index.js.map +1 -0
- package/package.json +76 -0
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.
|