@clocknext/sdk 0.19.1 → 0.20.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/README.md CHANGED
@@ -9,7 +9,9 @@ Official TypeScript/JavaScript SDK for ClockNext. Use it from a backend to:
9
9
 
10
10
  The SDK targets the ClockNext `/api/v1` API and requires Node.js 18 or newer.
11
11
  It has zero runtime dependencies and works with native `fetch` in Node, Bun,
12
- Deno, and edge runtimes.
12
+ Deno, and edge runtimes. The one exception is webhook signature verification
13
+ ([Webhooks](#webhooks)), which uses `node:crypto` and so needs Node (or a
14
+ runtime that provides it); it is not supported on edge runtimes.
13
15
 
14
16
  ---
15
17
 
@@ -657,7 +659,7 @@ for await (const t of cnk.customers.iterateWallet(customerId)) {
657
659
 
658
660
  ### Look up usage rows by id
659
661
 
660
- `signals.lookupUsageLogs(ids)` returns up to 100 usage-log rows in one call —
662
+ `signals.lookupUsageLogs(ids)` returns up to 10 usage-log rows in one call —
661
663
  for example the row behind each wallet debit's `usageLogId`. It is scoped to
662
664
  your org, not one customer: every row has its own `customerId`. Rows come back
663
665
  in the order of `ids`; ids with no row in your org are listed in `notFound`.
@@ -877,6 +879,76 @@ const one = await cnk.taxes.get(gst.id);
877
879
  await cnk.taxes.update(gst.id, { percentage: 12 }); // only the percentage changes
878
880
  ```
879
881
 
882
+ ## Webhooks
883
+
884
+ ClockNext can POST a signed event to your server when a customer's wallet
885
+ changes. Create the endpoint in the dashboard (**Settings → Webhooks**), choose
886
+ its events, and copy the signing secret — it is shown once. (The SDK verifies
887
+ and types these events; it does not create or list endpoints.)
888
+
889
+ | Event | Fires when |
890
+ | --- | --- |
891
+ | `wallet.balance.update` | A customer's **main** wallet balance moves, credit or debit. Not for invoice settlements, and not for the Reserved Wallet. |
892
+ | `wallet.balance.drop` | A debit takes the balance from above a level to at or below it. Fires **once per drop** through the level, not on every debit while the balance stays under it. |
893
+
894
+ The level for `wallet.balance.drop` is the customer's own alert level if they
895
+ have one, otherwise the endpoint's threshold. Set or clear a customer's own
896
+ level with the SDK:
897
+
898
+ ```ts
899
+ await cnk.customers.setWalletAlert(customerId, 50); // alert at or below $50
900
+ await cnk.customers.setWalletAlert(customerId, null); // clear it: fall back to each endpoint's threshold
901
+ await cnk.customers.setWalletAlertByExternalId("acme-42", 50);
902
+
903
+ (await cnk.customers.wallet(customerId)).lowBalanceAlertUsd; // 50, or null
904
+ ```
905
+
906
+ ### Verify, then handle
907
+
908
+ ```ts
909
+ import { constructWebhookEvent, WebhookSignatureError } from "@clocknext/sdk";
910
+
911
+ // `rawBody` is the UNPARSED request body (string or Buffer).
912
+ const event = constructWebhookEvent(
913
+ rawBody,
914
+ req.headers["x-clocknext-signature"],
915
+ process.env.CLOCKNEXT_WEBHOOK_SECRET!,
916
+ );
917
+ // the same check is available as cnk.webhooks.constructEvent(…)
918
+
919
+ if (event.event_type === "wallet.balance.drop") {
920
+ event.data.customer.id;
921
+ event.data.threshold; // the level that was crossed, USD
922
+ event.data.previous_balance; // USD
923
+ event.data.current_balance; // USD
924
+ }
925
+ ```
926
+
927
+ `constructWebhookEvent` throws `WebhookSignatureError` (a 400, never retryable)
928
+ when the header is missing or malformed, the signature does not match, the
929
+ timestamp is more than 5 minutes from now (`{ toleranceSeconds }` changes
930
+ that), or the body is not a ClockNext event. Answer **400** to those.
931
+
932
+ - **Use the raw body.** The signature is an HMAC-SHA256 of `${t}.${rawBody}`;
933
+ parsing the JSON and re-serialising it changes the bytes and every signature
934
+ fails. In Express mount `express.raw({ type: "application/json" })` on the
935
+ webhook route, not `express.json()`.
936
+ - **Answer 2xx quickly.** ClockNext waits 5 seconds and retries any other
937
+ answer. Do slow work after you respond.
938
+ - **De-duplicate on `event.id`.** Delivery is at-least-once, and a retry
939
+ carries the same `id`.
940
+ - **Ignore event types you do not know.** More may be added; answer 2xx.
941
+ - **Field names are snake_case**, exactly as sent (`event_type`,
942
+ `previous_balance`, …), unlike the camelCase the rest of the SDK returns.
943
+ - **`source` is not the ledger's `source`.** `wallet.balance.update` carries the
944
+ stored source — `TOPUP`, `USAGE`, `SETTLE` or `ADJUSTMENT` — not the finer
945
+ `SUBSCRIPTION` / `RENEWAL` / `API` / … that `customers.wallet()` reports.
946
+ Do not compare the two.
947
+ - To test your receiver, sign a fixture with `signWebhookPayload(secret, body,
948
+ Math.floor(Date.now() / 1000))` and send it as the header.
949
+
950
+ See [`examples/webhook-receiver.ts`](examples/webhook-receiver.ts).
951
+
880
952
  ## Customer portal
881
953
 
882
954
  ```ts
package/dist/index.cjs CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ var crypto = require('crypto');
4
+
3
5
  // src/errors.ts
4
6
  var ClockNextError = class extends Error {
5
7
  /** HTTP status, when the failure came from a server response. */
@@ -56,6 +58,11 @@ var RateLimitError = class extends ClockNextError {
56
58
  };
57
59
  var ServerError = class extends ClockNextError {
58
60
  };
61
+ var WebhookSignatureError = class extends ClockNextError {
62
+ constructor(message) {
63
+ super(message, { status: 400, retryable: false, code: "WEBHOOK_SIGNATURE_INVALID" });
64
+ }
65
+ };
59
66
  var NetworkError = class extends ClockNextError {
60
67
  constructor(message, opts = {}) {
61
68
  super(message, { retryable: true, idempotencyKey: opts.idempotencyKey });
@@ -333,7 +340,7 @@ function sleep(ms) {
333
340
  }
334
341
 
335
342
  // src/http.ts
336
- var SDK_VERSION = "0.19.1" ;
343
+ var SDK_VERSION = "0.20.0" ;
337
344
  var Transport = class {
338
345
  constructor(cfg) {
339
346
  this.cfg = cfg;
@@ -874,6 +881,31 @@ var Customers = class {
874
881
  adjustWalletByExternalId(id, input) {
875
882
  return this.adjust(`/api/v1/customers/ext/${encodeURIComponent(id)}/wallet/adjust`, input);
876
883
  }
884
+ /**
885
+ * Set (a number of 0 or more, USD) or clear (`null`) a customer's low-balance
886
+ * alert level. At or below it the `wallet.balance.drop` webhook fires for this
887
+ * customer, whatever the endpoint's own threshold is; a customer without a
888
+ * level falls back to each endpoint's threshold. Returns the saved level.
889
+ * Idempotent (a PUT), so it retries safely. `PUT …/:id/wallet/alert`.
890
+ */
891
+ setWalletAlert(id, threshold) {
892
+ return this.walletAlert(`/api/v1/customers/${encodeURIComponent(id)}/wallet/alert`, threshold);
893
+ }
894
+ /** {@link setWalletAlert}, addressing the customer by its `externalId` (`id`
895
+ * here). `PUT /api/v1/customers/ext/:externalId/wallet/alert`. */
896
+ setWalletAlertByExternalId(id, threshold) {
897
+ return this.walletAlert(
898
+ `/api/v1/customers/ext/${encodeURIComponent(id)}/wallet/alert`,
899
+ threshold
900
+ );
901
+ }
902
+ walletAlert(path, threshold) {
903
+ return this.transport.request({
904
+ method: "PUT",
905
+ path,
906
+ body: { threshold }
907
+ });
908
+ }
877
909
  /**
878
910
  * The one body behind all ten adjust calls.
879
911
  *
@@ -1492,7 +1524,7 @@ var Signals = class {
1492
1524
  });
1493
1525
  }
1494
1526
  /**
1495
- * Fetches up to 100 usage-log rows by id, in one call.
1527
+ * Fetches up to 10 usage-log rows by id, in one call.
1496
1528
  * `GET /api/v1/signal/usage-log/lookup`.
1497
1529
  *
1498
1530
  * Built for linking a wallet debit to its usage: pass the `usageLogId`s
@@ -1501,7 +1533,7 @@ var Signals = class {
1501
1533
  * carries its own `customerId`.
1502
1534
  *
1503
1535
  * Rows come back in the order of `ids`; duplicates are removed. Ids that
1504
- * match nothing in your org are listed in `notFound`. More than 100
1536
+ * match nothing in your org are listed in `notFound`. More than 10
1505
1537
  * distinct ids is a 400.
1506
1538
  */
1507
1539
  lookupUsageLogs(ids) {
@@ -1624,6 +1656,83 @@ var Taxes = class {
1624
1656
  return res.tax;
1625
1657
  }
1626
1658
  };
1659
+ var WEBHOOK_SIGNATURE_HEADER = "x-clocknext-signature";
1660
+ var DEFAULT_TOLERANCE_SECONDS = 300;
1661
+ function signWebhookPayload(secret, rawBody, timestamp) {
1662
+ const hmac = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
1663
+ return `t=${timestamp},v1=${hmac}`;
1664
+ }
1665
+ function parseHeader(header) {
1666
+ let t;
1667
+ const v1 = [];
1668
+ for (const part of header.split(",")) {
1669
+ const eq = part.indexOf("=");
1670
+ if (eq === -1) continue;
1671
+ const k = part.slice(0, eq).trim();
1672
+ const v = part.slice(eq + 1).trim();
1673
+ if (k === "t") {
1674
+ if (/^\d+$/.test(v)) t = Number(v);
1675
+ } else if (k === "v1") {
1676
+ v1.push(v);
1677
+ }
1678
+ }
1679
+ if (t === void 0 || v1.length === 0) {
1680
+ throw new WebhookSignatureError(
1681
+ "Malformed x-clocknext-signature header: expected `t=<unix_seconds>,v1=<hex>`."
1682
+ );
1683
+ }
1684
+ return { t, v1 };
1685
+ }
1686
+ function safeEqualHex(expectedHex, givenHex) {
1687
+ const expected = Buffer.from(expectedHex, "hex");
1688
+ const given = Buffer.from(givenHex, "hex");
1689
+ return given.length === expected.length && crypto.timingSafeEqual(expected, given);
1690
+ }
1691
+ function constructWebhookEvent(rawBody, signatureHeader, secret, options = {}) {
1692
+ if (typeof rawBody !== "string" && !Buffer.isBuffer(rawBody)) {
1693
+ throw new WebhookSignatureError(
1694
+ "Webhook body must be the raw request body (string or Buffer), not parsed JSON. With Express use express.raw({ type: 'application/json' })."
1695
+ );
1696
+ }
1697
+ if (!secret) {
1698
+ throw new WebhookSignatureError("A webhook secret is required.");
1699
+ }
1700
+ if (!signatureHeader) {
1701
+ throw new WebhookSignatureError("Missing x-clocknext-signature header.");
1702
+ }
1703
+ const { t, v1 } = parseHeader(signatureHeader);
1704
+ const expected = signWebhookPayload(secret, rawBody, t).split("v1=")[1];
1705
+ if (!v1.some((candidate) => safeEqualHex(expected, candidate))) {
1706
+ throw new WebhookSignatureError(
1707
+ "Webhook signature does not match. Check the secret and that the body is the unmodified raw body."
1708
+ );
1709
+ }
1710
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
1711
+ if (tolerance > 0) {
1712
+ const now = options.now ?? Math.floor(Date.now() / 1e3);
1713
+ if (Math.abs(now - t) > tolerance) {
1714
+ throw new WebhookSignatureError(
1715
+ `Webhook timestamp is outside the ${tolerance}s tolerance \u2014 possible replay.`
1716
+ );
1717
+ }
1718
+ }
1719
+ let parsed;
1720
+ try {
1721
+ parsed = JSON.parse(rawBody.toString());
1722
+ } catch {
1723
+ throw new WebhookSignatureError("Webhook body is not valid JSON.");
1724
+ }
1725
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.id !== "string" || typeof parsed.event_type !== "string") {
1726
+ throw new WebhookSignatureError("Webhook body is not a ClockNext event.");
1727
+ }
1728
+ return parsed;
1729
+ }
1730
+ var Webhooks = class {
1731
+ /** See {@link constructWebhookEvent}. */
1732
+ constructEvent(rawBody, signatureHeader, secret, options) {
1733
+ return constructWebhookEvent(rawBody, signatureHeader, secret, options);
1734
+ }
1735
+ };
1627
1736
 
1628
1737
  // src/client.ts
1629
1738
  var ClockNext = class {
@@ -1651,6 +1760,8 @@ var ClockNext = class {
1651
1760
  payments;
1652
1761
  /** Mint customer-portal embed tokens. */
1653
1762
  portal;
1763
+ /** Verify incoming ClockNext webhooks (`wallet.balance.update` / `.drop`). */
1764
+ webhooks;
1654
1765
  cfg;
1655
1766
  flusher;
1656
1767
  constructor(config) {
@@ -1674,6 +1785,7 @@ var ClockNext = class {
1674
1785
  this.invoices = new Invoices(transport);
1675
1786
  this.payments = new Payments(transport);
1676
1787
  this.portal = new Portal(transport);
1788
+ this.webhooks = new Webhooks();
1677
1789
  }
1678
1790
  /** Number of signals currently buffered (async mode). */
1679
1791
  get pending() {
@@ -1701,6 +1813,10 @@ exports.PlanError = PlanError;
1701
1813
  exports.RateLimitError = RateLimitError;
1702
1814
  exports.ServerError = ServerError;
1703
1815
  exports.ValidationError = ValidationError;
1816
+ exports.WEBHOOK_SIGNATURE_HEADER = WEBHOOK_SIGNATURE_HEADER;
1817
+ exports.WebhookSignatureError = WebhookSignatureError;
1818
+ exports.constructWebhookEvent = constructWebhookEvent;
1819
+ exports.signWebhookPayload = signWebhookPayload;
1704
1820
  exports.signalToWire = signalToWire;
1705
1821
  //# sourceMappingURL=index.cjs.map
1706
1822
  //# sourceMappingURL=index.cjs.map