mbase-sdk 0.0.5 → 0.0.7
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/README.md +150 -7
- package/dist/index.cjs +122 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -1
- package/dist/index.d.ts +137 -1
- package/dist/index.js +121 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# mbase-sdk
|
|
2
2
|
|
|
3
3
|
TypeScript SDK for the [Meterbase](https://github.com/usemeterbase/engine)
|
|
4
|
-
engine. Works on Node
|
|
4
|
+
engine. Works on Node 20+, browsers, and edge runtimes — it uses `fetch` and
|
|
5
5
|
nothing else.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install
|
|
10
|
+
npm install mbase-sdk
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
## Usage
|
|
@@ -16,7 +16,7 @@ Two calls, in this order: **`check` → do the work → `track`** — or, for wo
|
|
|
16
16
|
that must not be done twice, [`reserve`](#work-you-cannot-do-twice).
|
|
17
17
|
|
|
18
18
|
```ts
|
|
19
|
-
import { Meterbase } from "
|
|
19
|
+
import { Meterbase } from "mbase-sdk"
|
|
20
20
|
|
|
21
21
|
const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
|
|
22
22
|
|
|
@@ -253,7 +253,7 @@ It is the one field where `null` is a value rather than silence:
|
|
|
253
253
|
| `thresholds: []` | alerts off for this scope alone |
|
|
254
254
|
| `thresholds: [80]` | these marks |
|
|
255
255
|
|
|
256
|
-
|
|
256
|
+
Each crossing is delivered as a webhook; see [Webhooks](#webhooks).
|
|
257
257
|
|
|
258
258
|
### Meters
|
|
259
259
|
|
|
@@ -374,7 +374,7 @@ weight, so `changeAtNextCycle` and `changeNow` + `"prorate"` both answer
|
|
|
374
374
|
`changeNow` is the other way out — it keeps the period that is running.
|
|
375
375
|
|
|
376
376
|
```ts
|
|
377
|
-
import { CycleChangeRequiresResetError } from "
|
|
377
|
+
import { CycleChangeRequiresResetError } from "mbase-sdk"
|
|
378
378
|
|
|
379
379
|
try {
|
|
380
380
|
await meterbase.customers.plan.changeAtNextCycle(customer.id, {
|
|
@@ -417,6 +417,42 @@ instruction, and writes no new row of its own — so this reads the plan in forc
|
|
|
417
417
|
and names it back. It returns the assignment still in force, or `null` for a
|
|
418
418
|
customer holding no plan, who can have nothing pending.
|
|
419
419
|
|
|
420
|
+
#### How much they have used
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const entitlement = await meterbase.customers.entitlement(customer.id) // or null
|
|
424
|
+
|
|
425
|
+
for (const meter of entitlement?.meters ?? []) {
|
|
426
|
+
console.log(meter.meter_id, `${meter.used} of ${meter.amount ?? "unlimited"}`)
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`entitlement` is the plan's side of the current period, one entry per meter the
|
|
431
|
+
plan meters: the read behind a usage bar in your own app. `used` is what
|
|
432
|
+
`amount` is measured against, `total` adds what grants paid for, and
|
|
433
|
+
`available` is what `check` would answer. Meters are named by id, and like
|
|
434
|
+
`retrieve` it is `null` for a customer holding no plan.
|
|
435
|
+
|
|
436
|
+
Unlike `check`, it is not answered from a cache, so read it where usage is
|
|
437
|
+
shown rather than on every request.
|
|
438
|
+
|
|
439
|
+
#### Usage by day
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
const week = await meterbase.customers.usage(customer.id, { days: 7 })
|
|
443
|
+
const period = await meterbase.customers.usage(customer.id, {
|
|
444
|
+
period: "current",
|
|
445
|
+
}) // or null
|
|
446
|
+
const meter = await meterbase.meters.usage(meterId, { days: 30 })
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Usage per UTC day: `days` is 1–90 days ending today, and a customer can also
|
|
450
|
+
be read by `period`, `current` or `last`. Each meter lists every day that has
|
|
451
|
+
begun, zeros included; a customer's meters with no usage in the range are left
|
|
452
|
+
out. A period the customer never had is `null`.
|
|
453
|
+
|
|
454
|
+
Figures can be up to five minutes old. `check` and `entitlement` are live.
|
|
455
|
+
|
|
420
456
|
### A customer's allowances
|
|
421
457
|
|
|
422
458
|
Capacity on top of whatever the plan gives.
|
|
@@ -439,13 +475,119 @@ Revoking withdraws what is left without erasing what was consumed, and is
|
|
|
439
475
|
idempotent. Grants the engine minted itself from a plan's `one_time` allowance
|
|
440
476
|
appear here with `source: "plan"`; they cannot be created through `grant`.
|
|
441
477
|
|
|
478
|
+
## Webhooks
|
|
479
|
+
|
|
480
|
+
Meterbase posts to your endpoints when a customer crosses a usage threshold.
|
|
481
|
+
Add an endpoint in the dashboard under **Webhooks**; each has its own signing
|
|
482
|
+
secret, shown once when you add it and revealable from its settings.
|
|
483
|
+
|
|
484
|
+
| Event | When |
|
|
485
|
+
| --------------------- | -------------------------------------------------------------------------------------- |
|
|
486
|
+
| `threshold.crossed` | a customer's usage of a meter crosses one of their thresholds |
|
|
487
|
+
| `entitlement.reached` | that crossing at 100% of their plan's entitlement, sent as well as `threshold.crossed` |
|
|
488
|
+
|
|
489
|
+
```json
|
|
490
|
+
{
|
|
491
|
+
"id": "0199c3b1-3f05-7a90-b6e2-49c817d05fa3",
|
|
492
|
+
"type": "threshold.crossed",
|
|
493
|
+
"timestamp": "2026-09-18T14:08:12Z",
|
|
494
|
+
"data": {
|
|
495
|
+
"customer_id": "acct_1",
|
|
496
|
+
"meter_id": "ai_tokens",
|
|
497
|
+
"plan": { "id": "01993c40-…", "key": "pro", "name": "Pro" },
|
|
498
|
+
"threshold": 80,
|
|
499
|
+
"used": 801400,
|
|
500
|
+
"entitlement": 1000000,
|
|
501
|
+
"period": {
|
|
502
|
+
"start": "2026-09-01T00:00:00Z",
|
|
503
|
+
"end": "2026-10-01T00:00:00Z"
|
|
504
|
+
},
|
|
505
|
+
"usage_event_id": "0199c3b0-9e41-7d2a-8c55-2f6e1a3bc738"
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`used` and `entitlement` are the plan's figures at the crossing; neither counts
|
|
511
|
+
grants, so ask `check` for capacity. `entitlement.reached` marks the plan's
|
|
512
|
+
entitlement, not spent capacity: a grant can still have balance.
|
|
513
|
+
|
|
514
|
+
### Verifying a delivery
|
|
515
|
+
|
|
516
|
+
Verify every request before you trust it. `verifyWebhook` checks the signature
|
|
517
|
+
and the timestamp, then returns the event:
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
import { verifyWebhook, WebhookVerificationError } from "mbase-sdk"
|
|
521
|
+
|
|
522
|
+
app.post(
|
|
523
|
+
"/meterbase",
|
|
524
|
+
express.raw({ type: "application/json" }),
|
|
525
|
+
async (req, res) => {
|
|
526
|
+
let event
|
|
527
|
+
try {
|
|
528
|
+
event = await verifyWebhook({
|
|
529
|
+
payload: req.body, // the raw body: re-serialized JSON will not verify
|
|
530
|
+
headers: req.headers,
|
|
531
|
+
secret: process.env.METERBASE_WEBHOOK_SECRET!,
|
|
532
|
+
})
|
|
533
|
+
} catch (error) {
|
|
534
|
+
if (error instanceof WebhookVerificationError) return res.sendStatus(400)
|
|
535
|
+
throw error
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (event.type === "entitlement.reached") {
|
|
539
|
+
// event.data.customer_id has used their plan's allowance for event.data.meter_id
|
|
540
|
+
}
|
|
541
|
+
res.sendStatus(204)
|
|
542
|
+
},
|
|
543
|
+
)
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Without the SDK, it is [Standard Webhooks](https://www.standardwebhooks.com):
|
|
547
|
+
|
|
548
|
+
1. Each request carries `webhook-id`, `webhook-timestamp` (Unix seconds) and
|
|
549
|
+
`webhook-signature`.
|
|
550
|
+
2. Compute HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed
|
|
551
|
+
with the base64-decoded part of the secret after `whsec_`.
|
|
552
|
+
3. `webhook-signature` is a space-separated list of `v1,{base64}`. Accept the
|
|
553
|
+
request if any of them equals yours, compared in constant time.
|
|
554
|
+
4. Refuse a timestamp more than five minutes from your clock.
|
|
555
|
+
|
|
556
|
+
### Responding and retries
|
|
557
|
+
|
|
558
|
+
Answer with a 2xx within 10 seconds. Anything else is a failure, a redirect
|
|
559
|
+
included: Meterbase does not follow them.
|
|
560
|
+
|
|
561
|
+
| Attempt | After the one before |
|
|
562
|
+
| ------- | ------------------------------- |
|
|
563
|
+
| 1 | within a minute of the crossing |
|
|
564
|
+
| 2 | 1 minute |
|
|
565
|
+
| 3 | 6 minutes |
|
|
566
|
+
| 4 | 36 minutes |
|
|
567
|
+
| 5 | 3 hours 36 minutes |
|
|
568
|
+
| 6 | 21 hours 36 minutes |
|
|
569
|
+
|
|
570
|
+
Each wait varies by up to 10%. An event keeps its `id` on every attempt and
|
|
571
|
+
every endpoint, so dedupe on it, and do not rely on order. The dashboard logs
|
|
572
|
+
every attempt for 30 days and can retry a delivery by hand.
|
|
573
|
+
|
|
574
|
+
An answer of `410 Gone` pauses the endpoint, as does a failure a day into a
|
|
575
|
+
streak of them. A paused endpoint holds new deliveries until you resume it,
|
|
576
|
+
with or without replaying them.
|
|
577
|
+
|
|
578
|
+
### Rotating a secret
|
|
579
|
+
|
|
580
|
+
Rotate from the endpoint's settings. For the next 24 hours both secrets sign
|
|
581
|
+
every request, so deploy the new one at your own pace; while you switch over,
|
|
582
|
+
`secret` takes both.
|
|
583
|
+
|
|
442
584
|
## Errors
|
|
443
585
|
|
|
444
586
|
Every failure is a `MeterbaseError`. Catch the specific one you care about, or
|
|
445
587
|
the base class for all of them.
|
|
446
588
|
|
|
447
589
|
```ts
|
|
448
|
-
import { ConflictError, RateLimitError } from "
|
|
590
|
+
import { ConflictError, RateLimitError } from "mbase-sdk"
|
|
449
591
|
|
|
450
592
|
try {
|
|
451
593
|
await meterbase.customers.create({ external_id: "acct_1" })
|
|
@@ -473,6 +615,7 @@ try {
|
|
|
473
615
|
| `RateLimitError` | 429 |
|
|
474
616
|
| `ServerError` | 5xx |
|
|
475
617
|
| `ConnectionError` / `TimeoutError` | the request never got an answer |
|
|
618
|
+
| `WebhookVerificationError` | `verifyWebhook`: not a delivery Meterbase signed in the last five minutes |
|
|
476
619
|
|
|
477
620
|
`APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
|
|
478
621
|
which is stable; `message` is for humans and may change.
|
package/dist/index.cjs
CHANGED
|
@@ -97,6 +97,8 @@ function serverTime(body) {
|
|
|
97
97
|
const parsed = new Date(at);
|
|
98
98
|
return Number.isNaN(parsed.getTime()) ? void 0 : parsed;
|
|
99
99
|
}
|
|
100
|
+
var WebhookVerificationError = class extends MeterbaseError {
|
|
101
|
+
};
|
|
100
102
|
|
|
101
103
|
// src/client.ts
|
|
102
104
|
var DEFAULT_BASE_URL = "https://api.meterbase.tech";
|
|
@@ -137,7 +139,7 @@ var Client = class {
|
|
|
137
139
|
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
138
140
|
if (typeof this.#fetch !== "function") {
|
|
139
141
|
throw new MeterbaseError(
|
|
140
|
-
"No fetch implementation: pass one as `fetch`, or run on Node
|
|
142
|
+
"No fetch implementation: pass one as `fetch`, or run on Node 20+."
|
|
141
143
|
);
|
|
142
144
|
}
|
|
143
145
|
}
|
|
@@ -508,6 +510,36 @@ var Customers = class {
|
|
|
508
510
|
});
|
|
509
511
|
return data[0] ?? null;
|
|
510
512
|
}
|
|
513
|
+
/** `null` for a customer holding no plan, as `plan.retrieve` answers. */
|
|
514
|
+
async entitlement(id, options) {
|
|
515
|
+
try {
|
|
516
|
+
return await this.#client.request({
|
|
517
|
+
...options,
|
|
518
|
+
method: "GET",
|
|
519
|
+
path: `/v1/customers/${encodeURIComponent(id)}/entitlement`
|
|
520
|
+
});
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (error instanceof NotFoundError && error.code === "no_plan_assigned") {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async usage(id, params, options) {
|
|
529
|
+
try {
|
|
530
|
+
return await this.#client.request({
|
|
531
|
+
...options,
|
|
532
|
+
method: "GET",
|
|
533
|
+
path: `/v1/customers/${encodeURIComponent(id)}/usage`,
|
|
534
|
+
query: { days: params.days, period: params.period }
|
|
535
|
+
});
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof NotFoundError && (error.code === "no_plan_assigned" || error.code === "no_previous_period")) {
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
511
543
|
update(id, params, options) {
|
|
512
544
|
return this.#client.request({
|
|
513
545
|
...options,
|
|
@@ -563,6 +595,15 @@ var Meters = class {
|
|
|
563
595
|
body: params
|
|
564
596
|
});
|
|
565
597
|
}
|
|
598
|
+
/** Every customer's usage of the meter per UTC day. */
|
|
599
|
+
usage(id, params, options) {
|
|
600
|
+
return this.#client.request({
|
|
601
|
+
...options,
|
|
602
|
+
method: "GET",
|
|
603
|
+
path: `/v1/meters/${encodeURIComponent(id)}/usage`,
|
|
604
|
+
query: { days: params.days }
|
|
605
|
+
});
|
|
606
|
+
}
|
|
566
607
|
/** Soft delete: plans and usage keep referencing the meter. Idempotent. */
|
|
567
608
|
archive(id, options) {
|
|
568
609
|
return this.#client.request({
|
|
@@ -649,6 +690,84 @@ var Plans = class {
|
|
|
649
690
|
}
|
|
650
691
|
};
|
|
651
692
|
|
|
693
|
+
// src/webhooks.ts
|
|
694
|
+
var SECRET_PREFIX = "whsec_";
|
|
695
|
+
var DEFAULT_TOLERANCE_SECONDS = 5 * 60;
|
|
696
|
+
async function verifyWebhook(params) {
|
|
697
|
+
const id = header(params.headers, "webhook-id");
|
|
698
|
+
const timestamp = header(params.headers, "webhook-timestamp");
|
|
699
|
+
const signatures = header(params.headers, "webhook-signature");
|
|
700
|
+
if (!id || !timestamp || !signatures) {
|
|
701
|
+
throw new WebhookVerificationError(
|
|
702
|
+
"Missing a webhook-id, webhook-timestamp or webhook-signature header."
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
const sentAt = Number(timestamp);
|
|
706
|
+
const tolerance = params.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
|
|
707
|
+
if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1e3 - sentAt) > tolerance) {
|
|
708
|
+
throw new WebhookVerificationError(
|
|
709
|
+
"The webhook-timestamp is too far from now; this may be a replay."
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
const body = typeof params.payload === "string" ? new TextEncoder().encode(params.payload) : params.payload;
|
|
713
|
+
const signed = concat(new TextEncoder().encode(`${id}.${timestamp}.`), body);
|
|
714
|
+
const candidates = signatures.split(" ").filter((entry) => entry.startsWith("v1,")).map((entry) => fromBase64(entry.slice(3)));
|
|
715
|
+
const subtle = webCrypto();
|
|
716
|
+
const secrets = typeof params.secret === "string" ? [params.secret] : params.secret;
|
|
717
|
+
for (const secret of secrets) {
|
|
718
|
+
const raw = secret.startsWith(SECRET_PREFIX) ? fromBase64(secret.slice(SECRET_PREFIX.length)) : void 0;
|
|
719
|
+
if (!raw) {
|
|
720
|
+
throw new WebhookVerificationError(
|
|
721
|
+
"The secret should be whsec_ and base64: copy it from the endpoint's settings."
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
const key = await subtle.importKey(
|
|
725
|
+
"raw",
|
|
726
|
+
raw,
|
|
727
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
728
|
+
false,
|
|
729
|
+
["verify"]
|
|
730
|
+
);
|
|
731
|
+
for (const candidate of candidates) {
|
|
732
|
+
if (candidate && await subtle.verify("HMAC", key, candidate, signed)) {
|
|
733
|
+
return JSON.parse(new TextDecoder().decode(body));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
throw new WebhookVerificationError(
|
|
738
|
+
"No signature matches: the body changed, or this is the wrong secret."
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
function header(headers, name) {
|
|
742
|
+
if (typeof headers.get === "function") {
|
|
743
|
+
return headers.get(name) ?? void 0;
|
|
744
|
+
}
|
|
745
|
+
const value = headers[name];
|
|
746
|
+
return Array.isArray(value) ? value[0] : value;
|
|
747
|
+
}
|
|
748
|
+
function webCrypto() {
|
|
749
|
+
const subtle = globalThis.crypto?.subtle;
|
|
750
|
+
if (!subtle) {
|
|
751
|
+
throw new MeterbaseError(
|
|
752
|
+
"No Web Crypto to verify a webhook with: run on Node 20+, Deno, Bun or a Worker."
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
return subtle;
|
|
756
|
+
}
|
|
757
|
+
function fromBase64(text) {
|
|
758
|
+
try {
|
|
759
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
760
|
+
} catch {
|
|
761
|
+
return void 0;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function concat(head, tail) {
|
|
765
|
+
const out = new Uint8Array(head.length + tail.length);
|
|
766
|
+
out.set(head);
|
|
767
|
+
out.set(tail, head.length);
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
|
|
652
771
|
// src/index.ts
|
|
653
772
|
var Meterbase = class {
|
|
654
773
|
customers;
|
|
@@ -885,6 +1004,8 @@ exports.ReservationMismatchError = ReservationMismatchError;
|
|
|
885
1004
|
exports.ServerError = ServerError;
|
|
886
1005
|
exports.TimeoutError = TimeoutError;
|
|
887
1006
|
exports.TooLateError = TooLateError;
|
|
1007
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
|
888
1008
|
exports.errorFromResponse = errorFromResponse;
|
|
1009
|
+
exports.verifyWebhook = verifyWebhook;
|
|
889
1010
|
//# sourceMappingURL=index.cjs.map
|
|
890
1011
|
//# sourceMappingURL=index.cjs.map
|