@clocknext/sdk 0.19.2 → 0.20.1
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 +73 -1
- package/dist/index.cjs +117 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +150 -2
- package/dist/index.d.ts +150 -2
- package/dist/index.js +114 -2
- package/dist/index.js.map +1 -1
- package/examples/webhook-receiver.ts +70 -0
- package/package.json +1 -1
- package/src/client.ts +5 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +20 -0
- package/src/resources/customers.ts +29 -0
- package/src/types.ts +10 -0
- package/src/webhooks.ts +244 -0
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
|
|
|
@@ -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` 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.
|
|
343
|
+
var SDK_VERSION = "0.20.1" ;
|
|
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
|
*
|
|
@@ -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
|