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 CHANGED
@@ -1,13 +1,13 @@
1
- # @meterbase/sdk
1
+ # mbase-sdk
2
2
 
3
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
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 @meterbase/sdk
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 "@meterbase/sdk"
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
- Crossings are recorded by the engine; no webhook delivers them yet.
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 "@meterbase/sdk"
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 "@meterbase/sdk"
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 18+."
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