mbase-sdk 0.0.6 → 0.0.8

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
@@ -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
 
@@ -475,6 +475,112 @@ Revoking withdraws what is left without erasing what was consumed, and is
475
475
  idempotent. Grants the engine minted itself from a plan's `one_time` allowance
476
476
  appear here with `source: "plan"`; they cannot be created through `grant`.
477
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
+
478
584
  ## Errors
479
585
 
480
586
  Every failure is a `MeterbaseError`. Catch the specific one you care about, or
@@ -509,6 +615,7 @@ try {
509
615
  | `RateLimitError` | 429 |
510
616
  | `ServerError` | 5xx |
511
617
  | `ConnectionError` / `TimeoutError` | the request never got an answer |
618
+ | `WebhookVerificationError` | `verifyWebhook`: not a delivery Meterbase signed in the last five minutes |
512
619
 
513
620
  `APIError` carries `status`, `code` and the parsed `body`. Branch on `code`,
514
621
  which is stable; `message` is for humans and may change.
@@ -531,6 +638,11 @@ Per-call overrides, including cancellation:
531
638
  await meterbase.meters.list({}, { signal: controller.signal, timeout: 2_000 })
532
639
  ```
533
640
 
641
+ Aborting the signal ends the call where it stands — mid-attempt or in the wait
642
+ before a retry — and rejects with the reason you aborted with. Nothing is sent
643
+ after it: an attempt already on the wire may still land, but it is never
644
+ replayed, so a cancelled `track` cannot record usage twice.
645
+
534
646
  **Retries** apply to `GET`, `DELETE` and `track`, on connection failures, 408,
535
647
  429 and 5xx, with exponential backoff and full jitter. `Retry-After` is honoured
536
648
  when the engine sends it. Every other `POST` and `PATCH` is never replayed,
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";
@@ -146,7 +148,7 @@ var Client = class {
146
148
  const retryable = req.method === "GET" || req.method === "DELETE" || req.idempotent === true;
147
149
  let lastError;
148
150
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
149
- if (attempt > 0) await sleep(backoff(attempt, lastError));
151
+ if (attempt > 0) await sleep(backoff(attempt, lastError), req.signal);
150
152
  try {
151
153
  const response = await this.#send(req);
152
154
  if (response.ok) return await parseBody(response);
@@ -160,6 +162,7 @@ var Client = class {
160
162
  if (cause instanceof MeterbaseError && !(cause instanceof ConnectionError)) {
161
163
  throw cause;
162
164
  }
165
+ if (req.signal?.aborted) throw cause;
163
166
  if (!retryable || attempt >= maxRetries) throw cause;
164
167
  lastError = cause;
165
168
  }
@@ -167,6 +170,7 @@ var Client = class {
167
170
  throw lastError;
168
171
  }
169
172
  async #send(req) {
173
+ if (req.signal?.aborted) throw req.signal.reason;
170
174
  const url = new URL(this.#baseUrl + req.path);
171
175
  for (const [key, value] of Object.entries(req.query ?? {})) {
172
176
  if (value !== void 0) url.searchParams.set(key, String(value));
@@ -215,8 +219,22 @@ function backoff(attempt, lastError) {
215
219
  const ceiling = Math.min(500 * 2 ** (attempt - 1), MAX_BACKOFF);
216
220
  return Math.random() * ceiling;
217
221
  }
218
- function sleep(ms) {
219
- return new Promise((resolve) => setTimeout(resolve, ms));
222
+ function sleep(ms, signal) {
223
+ return new Promise((resolve, reject) => {
224
+ if (signal?.aborted) {
225
+ reject(signal.reason);
226
+ return;
227
+ }
228
+ const onAbort = () => {
229
+ clearTimeout(timer);
230
+ reject(signal?.reason);
231
+ };
232
+ const timer = setTimeout(() => {
233
+ signal?.removeEventListener("abort", onAbort);
234
+ resolve();
235
+ }, ms);
236
+ signal?.addEventListener("abort", onAbort, { once: true });
237
+ });
220
238
  }
221
239
  async function parseBody(response) {
222
240
  if (response.status === 204) return void 0;
@@ -688,6 +706,84 @@ var Plans = class {
688
706
  }
689
707
  };
690
708
 
709
+ // src/webhooks.ts
710
+ var SECRET_PREFIX = "whsec_";
711
+ var DEFAULT_TOLERANCE_SECONDS = 5 * 60;
712
+ async function verifyWebhook(params) {
713
+ const id = header(params.headers, "webhook-id");
714
+ const timestamp = header(params.headers, "webhook-timestamp");
715
+ const signatures = header(params.headers, "webhook-signature");
716
+ if (!id || !timestamp || !signatures) {
717
+ throw new WebhookVerificationError(
718
+ "Missing a webhook-id, webhook-timestamp or webhook-signature header."
719
+ );
720
+ }
721
+ const sentAt = Number(timestamp);
722
+ const tolerance = params.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
723
+ if (!Number.isInteger(sentAt) || Math.abs(Date.now() / 1e3 - sentAt) > tolerance) {
724
+ throw new WebhookVerificationError(
725
+ "The webhook-timestamp is too far from now; this may be a replay."
726
+ );
727
+ }
728
+ const body = typeof params.payload === "string" ? new TextEncoder().encode(params.payload) : params.payload;
729
+ const signed = concat(new TextEncoder().encode(`${id}.${timestamp}.`), body);
730
+ const candidates = signatures.split(" ").filter((entry) => entry.startsWith("v1,")).map((entry) => fromBase64(entry.slice(3)));
731
+ const subtle = webCrypto();
732
+ const secrets = typeof params.secret === "string" ? [params.secret] : params.secret;
733
+ for (const secret of secrets) {
734
+ const raw = secret.startsWith(SECRET_PREFIX) ? fromBase64(secret.slice(SECRET_PREFIX.length)) : void 0;
735
+ if (!raw) {
736
+ throw new WebhookVerificationError(
737
+ "The secret should be whsec_ and base64: copy it from the endpoint's settings."
738
+ );
739
+ }
740
+ const key = await subtle.importKey(
741
+ "raw",
742
+ raw,
743
+ { name: "HMAC", hash: "SHA-256" },
744
+ false,
745
+ ["verify"]
746
+ );
747
+ for (const candidate of candidates) {
748
+ if (candidate && await subtle.verify("HMAC", key, candidate, signed)) {
749
+ return JSON.parse(new TextDecoder().decode(body));
750
+ }
751
+ }
752
+ }
753
+ throw new WebhookVerificationError(
754
+ "No signature matches: the body changed, or this is the wrong secret."
755
+ );
756
+ }
757
+ function header(headers, name) {
758
+ if (typeof headers.get === "function") {
759
+ return headers.get(name) ?? void 0;
760
+ }
761
+ const value = headers[name];
762
+ return Array.isArray(value) ? value[0] : value;
763
+ }
764
+ function webCrypto() {
765
+ const subtle = globalThis.crypto?.subtle;
766
+ if (!subtle) {
767
+ throw new MeterbaseError(
768
+ "No Web Crypto to verify a webhook with: run on Node 20+, Deno, Bun or a Worker."
769
+ );
770
+ }
771
+ return subtle;
772
+ }
773
+ function fromBase64(text) {
774
+ try {
775
+ return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
776
+ } catch {
777
+ return void 0;
778
+ }
779
+ }
780
+ function concat(head, tail) {
781
+ const out = new Uint8Array(head.length + tail.length);
782
+ out.set(head);
783
+ out.set(tail, head.length);
784
+ return out;
785
+ }
786
+
691
787
  // src/index.ts
692
788
  var Meterbase = class {
693
789
  customers;
@@ -924,6 +1020,8 @@ exports.ReservationMismatchError = ReservationMismatchError;
924
1020
  exports.ServerError = ServerError;
925
1021
  exports.TimeoutError = TimeoutError;
926
1022
  exports.TooLateError = TooLateError;
1023
+ exports.WebhookVerificationError = WebhookVerificationError;
927
1024
  exports.errorFromResponse = errorFromResponse;
1025
+ exports.verifyWebhook = verifyWebhook;
928
1026
  //# sourceMappingURL=index.cjs.map
929
1027
  //# sourceMappingURL=index.cjs.map