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 +113 -1
- package/dist/index.cjs +101 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +100 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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) =>
|
|
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
|