@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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Receive ClockNext webhooks (wallet.balance.update / wallet.balance.drop).
3
+ * Create the endpoint in the dashboard (Settings → Webhooks), pick the events,
4
+ * and copy the signing secret — it is shown once.
5
+ *
6
+ * CLOCKNEXT_WEBHOOK_SECRET=… npx tsx examples/webhook-receiver.ts
7
+ *
8
+ * Plain `node:http` so the example has no framework to install. The one rule
9
+ * that decides whether any framework works: verify the RAW body. A body parser
10
+ * that parses and re-serialises the JSON changes the bytes, and every signature
11
+ * then fails. In Express, mount `express.raw({ type: "application/json" })` on
12
+ * this route instead of `express.json()` and pass `req.body` (a Buffer).
13
+ */
14
+ import { createServer } from "node:http";
15
+ import { constructWebhookEvent, WebhookSignatureError } from "@clocknext/sdk";
16
+
17
+ const secret = process.env.CLOCKNEXT_WEBHOOK_SECRET!;
18
+
19
+ // Delivery is at-least-once and a retry carries the SAME event id. In
20
+ // production keep these in a database or Redis, not memory.
21
+ const seen = new Set<string>();
22
+
23
+ createServer(async (req, res) => {
24
+ if (req.method !== "POST" || req.url !== "/webhooks/clocknext") {
25
+ res.statusCode = 404;
26
+ return res.end();
27
+ }
28
+
29
+ const chunks: Buffer[] = [];
30
+ for await (const chunk of req) chunks.push(chunk as Buffer);
31
+ const rawBody = Buffer.concat(chunks);
32
+
33
+ let event;
34
+ try {
35
+ event = constructWebhookEvent(
36
+ rawBody,
37
+ req.headers["x-clocknext-signature"] as string | undefined,
38
+ secret,
39
+ );
40
+ } catch (err) {
41
+ if (err instanceof WebhookSignatureError) {
42
+ console.warn("rejected webhook:", err.message);
43
+ res.statusCode = 400;
44
+ return res.end();
45
+ }
46
+ throw err;
47
+ }
48
+
49
+ // Answer fast: ClockNext times out after 5s and retries any non-2xx.
50
+ if (!seen.has(event.id)) {
51
+ seen.add(event.id);
52
+ switch (event.event_type) {
53
+ case "wallet.balance.update":
54
+ console.log(
55
+ `${event.data.customer.name}: $${event.data.previous_balance} → $${event.data.current_balance} (${event.data.source})`,
56
+ );
57
+ break;
58
+ case "wallet.balance.drop":
59
+ console.log(
60
+ `${event.data.customer.name} fell to $${event.data.current_balance}, at or below $${event.data.threshold} — warn them or top up`,
61
+ );
62
+ break;
63
+ default:
64
+ // An event type added after this SDK version: ignore it, still 200.
65
+ break;
66
+ }
67
+ }
68
+ res.statusCode = 200;
69
+ res.end();
70
+ }).listen(3000, () => console.log("listening on :3000"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clocknext/sdk",
3
- "version": "0.19.2",
3
+ "version": "0.20.1",
4
4
  "description": "Official ClockNext SDK — record usage signals and manage plans, purchases, invoices, customers, and billing from your backend.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -14,6 +14,7 @@ import { Signals } from "./resources/signals.js";
14
14
  import { Units } from "./resources/units.js";
15
15
  import { Taxes } from "./resources/taxes.js";
16
16
  import type { ClockNextConfig, ResolvedConfig } from "./types.js";
17
+ import { Webhooks } from "./webhooks.js";
17
18
 
18
19
  /**
19
20
  * The ClockNext client.
@@ -58,6 +59,9 @@ export class ClockNext {
58
59
  /** Mint customer-portal embed tokens. */
59
60
  readonly portal: Portal;
60
61
 
62
+ /** Verify incoming ClockNext webhooks (`wallet.balance.update` / `.drop`). */
63
+ readonly webhooks: Webhooks;
64
+
61
65
  private readonly cfg: ResolvedConfig;
62
66
  private readonly flusher: Flusher;
63
67
 
@@ -84,6 +88,7 @@ export class ClockNext {
84
88
  this.invoices = new Invoices(transport);
85
89
  this.payments = new Payments(transport);
86
90
  this.portal = new Portal(transport);
91
+ this.webhooks = new Webhooks();
87
92
  }
88
93
 
89
94
  /** Number of signals currently buffered (async mode). */
package/src/errors.ts CHANGED
@@ -11,6 +11,7 @@ export type ClockNextErrorCode =
11
11
  | "ALLOWANCE_EXHAUSTED"
12
12
  | "PLAN_REJECTED"
13
13
  | "IDEMPOTENCY_UNRESOLVED"
14
+ | "WEBHOOK_SIGNATURE_INVALID"
14
15
  | (string & {});
15
16
 
16
17
  export class ClockNextError extends Error {
@@ -95,6 +96,15 @@ export class RateLimitError extends ClockNextError {
95
96
  /** 5xx — server-side error. */
96
97
  export class ServerError extends ClockNextError {}
97
98
 
99
+ /** A webhook request failed verification (`webhooks.constructEvent`): missing or
100
+ * malformed signature header, wrong secret or altered body, a timestamp outside
101
+ * the tolerance, or a body that is not a ClockNext event. Answer 400. */
102
+ export class WebhookSignatureError extends ClockNextError {
103
+ constructor(message: string) {
104
+ super(message, { status: 400, retryable: false, code: "WEBHOOK_SIGNATURE_INVALID" });
105
+ }
106
+ }
107
+
98
108
  /** fetch threw / request timed out — request never completed. */
99
109
  export class NetworkError extends ClockNextError {
100
110
  constructor(message: string, opts: { idempotencyKey?: string } = {}) {
package/src/index.ts CHANGED
@@ -19,8 +19,27 @@ export {
19
19
  RateLimitError,
20
20
  ServerError,
21
21
  NetworkError,
22
+ WebhookSignatureError,
22
23
  } from "./errors.js";
23
24
 
25
+ export {
26
+ constructWebhookEvent,
27
+ signWebhookPayload,
28
+ WEBHOOK_SIGNATURE_HEADER,
29
+ } from "./webhooks.js";
30
+
31
+ export type {
32
+ WebhookEvent,
33
+ WebhookEventType,
34
+ WebhookWalletSource,
35
+ WebhookCustomer,
36
+ WalletBalanceUpdateEvent,
37
+ WalletBalanceUpdateData,
38
+ WalletBalanceDropEvent,
39
+ WalletBalanceDropData,
40
+ ConstructEventOptions,
41
+ } from "./webhooks.js";
42
+
24
43
  export type { ClockNextErrorCode } from "./errors.js";
25
44
 
26
45
  export { signalToWire } from "./resources/signals.js";
@@ -71,6 +90,7 @@ export type {
71
90
  CreateCustomerInput,
72
91
  CustomerList,
73
92
  CustomerBalances,
93
+ WalletAlert,
74
94
  WalletLedgerPage,
75
95
  WalletLedgerParams,
76
96
  WalletTransaction,
@@ -23,6 +23,7 @@ import type {
23
23
  UnitBalance,
24
24
  UpdateMemberInput,
25
25
  WalletAdjustInput,
26
+ WalletAlert,
26
27
  WalletLedgerPage,
27
28
  WalletLedgerParams,
28
29
  WalletTransaction,
@@ -401,6 +402,34 @@ export class Customers {
401
402
  return this.adjust(`/api/v1/customers/ext/${encodeURIComponent(id)}/wallet/adjust`, input);
402
403
  }
403
404
 
405
+ /**
406
+ * Set (a number of 0 or more, USD) or clear (`null`) a customer's low-balance
407
+ * alert level. At or below it the `wallet.balance.drop` webhook fires for this
408
+ * customer, whatever the endpoint's own threshold is; a customer without a
409
+ * level falls back to each endpoint's threshold. Returns the saved level.
410
+ * Idempotent (a PUT), so it retries safely. `PUT …/:id/wallet/alert`.
411
+ */
412
+ setWalletAlert(id: string, threshold: number | null): Promise<WalletAlert> {
413
+ return this.walletAlert(`/api/v1/customers/${encodeURIComponent(id)}/wallet/alert`, threshold);
414
+ }
415
+
416
+ /** {@link setWalletAlert}, addressing the customer by its `externalId` (`id`
417
+ * here). `PUT /api/v1/customers/ext/:externalId/wallet/alert`. */
418
+ setWalletAlertByExternalId(id: string, threshold: number | null): Promise<WalletAlert> {
419
+ return this.walletAlert(
420
+ `/api/v1/customers/ext/${encodeURIComponent(id)}/wallet/alert`,
421
+ threshold,
422
+ );
423
+ }
424
+
425
+ private walletAlert(path: string, threshold: number | null): Promise<WalletAlert> {
426
+ return this.transport.request<WalletAlert>({
427
+ method: "PUT",
428
+ path,
429
+ body: { threshold },
430
+ });
431
+ }
432
+
404
433
  /**
405
434
  * The one body behind all ten adjust calls.
406
435
  *
package/src/types.ts CHANGED
@@ -768,11 +768,21 @@ export interface WalletLedgerPage {
768
768
  /** Reserved Wallet balance, USD; `null` when the workspace hasn't enabled
769
769
  * it. */
770
770
  reservedBalanceUsd: number | null;
771
+ /** The customer's own low-balance alert level, USD; `null` when none is set.
772
+ * At or below it the `wallet.balance.drop` webhook fires for this customer.
773
+ * Set with `customers.setWalletAlert()`. */
774
+ lowBalanceAlertUsd: number | null;
771
775
  transactions: WalletTransaction[];
772
776
  /** Pass back as `cursor` for the next page; `null` on the last page. */
773
777
  nextCursor: string | null;
774
778
  }
775
779
 
780
+ /** Result of `customers.setWalletAlert()`. */
781
+ export interface WalletAlert {
782
+ /** The saved low-balance alert level, USD; `null` once cleared. */
783
+ lowBalanceAlertUsd: number | null;
784
+ }
785
+
776
786
  export interface WalletLedgerParams {
777
787
  /** Default 50, max 200. */
778
788
  limit?: number;
@@ -0,0 +1,244 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { WebhookSignatureError } from "./errors.js";
3
+
4
+ /**
5
+ * Receiving ClockNext webhooks.
6
+ *
7
+ * ClockNext POSTs a signed JSON event to each endpoint you register in the
8
+ * dashboard (Settings → Webhooks). Two events exist today:
9
+ *
10
+ * wallet.balance.update a customer's MAIN wallet balance moved (credit or
11
+ * debit). Invoice settlements and the Reserved
12
+ * Wallet do not fire it.
13
+ * wallet.balance.drop a debit took the balance from above a level to at
14
+ * or below it. The level is the customer's own alert
15
+ * level (`customers.setWalletAlert`) when they have
16
+ * one, otherwise the endpoint's threshold. Fires once
17
+ * per drop through the level, not on every debit while
18
+ * the balance stays under it.
19
+ *
20
+ * Verify every request before trusting it — {@link constructWebhookEvent} does
21
+ * that and returns the typed event. It needs the RAW request body: parsing the
22
+ * JSON and re-serialising it changes the bytes and the signature will not match.
23
+ *
24
+ * Delivery is at-least-once: a non-2xx answer (or a timeout past 5s) is retried
25
+ * with the SAME event `id`, so de-duplicate on `id`.
26
+ */
27
+
28
+ /** Events ClockNext sends today. New event types may be added later — ignore
29
+ * any `event_type` you do not recognise (and still answer 2xx). */
30
+ export type WebhookEventType = "wallet.balance.update" | "wallet.balance.drop";
31
+
32
+ /**
33
+ * The `source` a `wallet.balance.update` event carries: the STORED ledger
34
+ * source, which is coarser than `WalletTransactionSource` on the ledger API.
35
+ * Do not compare the two — the ledger API reports `SUBSCRIPTION`, `RENEWAL`,
36
+ * `API`, … where the webhook says `TOPUP` or `ADJUSTMENT`.
37
+ *
38
+ * The ledger also has a `SETTLE` source (an invoice being paid off), but
39
+ * `wallet.balance.update` is never sent for it, so it is not in this type.
40
+ */
41
+ export type WebhookWalletSource = "TOPUP" | "USAGE" | "ADJUSTMENT";
42
+
43
+ /** The customer a wallet event is about. */
44
+ export interface WebhookCustomer {
45
+ id: string;
46
+ name: string;
47
+ }
48
+
49
+ /** Fields on every event. Field names are snake_case, exactly as sent. */
50
+ interface WebhookEventBase<T extends WebhookEventType, D> {
51
+ /** `evt_…`. Identical across retries of one delivery — de-duplicate on it. */
52
+ id: string;
53
+ event_type: T;
54
+ /** ISO-8601 time the event was created. */
55
+ triggered_at: string;
56
+ data: D;
57
+ }
58
+
59
+ /** `data` of `wallet.balance.update`. Balances are USD. */
60
+ export interface WalletBalanceUpdateData {
61
+ customer: WebhookCustomer;
62
+ source: WebhookWalletSource;
63
+ previous_balance: number;
64
+ current_balance: number;
65
+ currency_code: "usd";
66
+ }
67
+
68
+ /** `data` of `wallet.balance.drop`. Balances and `threshold` are USD. */
69
+ export interface WalletBalanceDropData {
70
+ customer: WebhookCustomer;
71
+ /** The level that was crossed: the customer's own alert level, else the
72
+ * endpoint's threshold. */
73
+ threshold: number;
74
+ previous_balance: number;
75
+ current_balance: number;
76
+ currency_code: "usd";
77
+ }
78
+
79
+ export type WalletBalanceUpdateEvent = WebhookEventBase<
80
+ "wallet.balance.update",
81
+ WalletBalanceUpdateData
82
+ >;
83
+ export type WalletBalanceDropEvent = WebhookEventBase<
84
+ "wallet.balance.drop",
85
+ WalletBalanceDropData
86
+ >;
87
+
88
+ /** Any event ClockNext sends. Narrow on `event_type`. */
89
+ export type WebhookEvent = WalletBalanceUpdateEvent | WalletBalanceDropEvent;
90
+
91
+ export interface ConstructEventOptions {
92
+ /** Reject an event whose signed timestamp is further than this from now, in
93
+ * either direction (replay protection). Default 300 (5 minutes). Pass 0 to
94
+ * skip the check — only do that in tests. */
95
+ toleranceSeconds?: number;
96
+ /** Override "now" (unix seconds). For tests. */
97
+ now?: number;
98
+ }
99
+
100
+ /** Header ClockNext puts the signature in. Node lower-cases header names. */
101
+ export const WEBHOOK_SIGNATURE_HEADER = "x-clocknext-signature";
102
+
103
+ const DEFAULT_TOLERANCE_SECONDS = 300;
104
+
105
+ /**
106
+ * Signs a body the way ClockNext does: `t=<unix_seconds>,v1=<hex hmac>`, the
107
+ * HMAC-SHA256 of `${timestamp}.${rawBody}` under the endpoint secret. Exported
108
+ * so you can sign a fixture in your own receiver's tests.
109
+ */
110
+ export function signWebhookPayload(
111
+ secret: string,
112
+ rawBody: string | Buffer,
113
+ timestamp: number,
114
+ ): string {
115
+ const hmac = createHmac("sha256", secret)
116
+ .update(`${timestamp}.`)
117
+ .update(rawBody)
118
+ .digest("hex");
119
+ return `t=${timestamp},v1=${hmac}`;
120
+ }
121
+
122
+ function parseHeader(header: string): { t: number; v1: string[] } {
123
+ let t: number | undefined;
124
+ const v1: string[] = [];
125
+ for (const part of header.split(",")) {
126
+ const eq = part.indexOf("=");
127
+ if (eq === -1) continue;
128
+ const k = part.slice(0, eq).trim();
129
+ const v = part.slice(eq + 1).trim();
130
+ if (k === "t") {
131
+ if (/^\d+$/.test(v)) t = Number(v);
132
+ } else if (k === "v1") {
133
+ v1.push(v);
134
+ }
135
+ }
136
+ if (t === undefined || v1.length === 0) {
137
+ throw new WebhookSignatureError(
138
+ "Malformed x-clocknext-signature header: expected `t=<unix_seconds>,v1=<hex>`.",
139
+ );
140
+ }
141
+ return { t, v1 };
142
+ }
143
+
144
+ function safeEqualHex(expectedHex: string, givenHex: string): boolean {
145
+ const expected = Buffer.from(expectedHex, "hex");
146
+ const given = Buffer.from(givenHex, "hex");
147
+ // timingSafeEqual throws on a length mismatch, so compare lengths first.
148
+ // A non-hex or wrong-length value can never be a valid HMAC-SHA256.
149
+ return given.length === expected.length && timingSafeEqual(expected, given);
150
+ }
151
+
152
+ /**
153
+ * Verifies a ClockNext webhook request and returns the typed event.
154
+ *
155
+ * ```ts
156
+ * app.post("/clocknext", express.raw({ type: "application/json" }), (req, res) => {
157
+ * let event;
158
+ * try {
159
+ * event = constructWebhookEvent(
160
+ * req.body, // the raw Buffer, NOT parsed JSON
161
+ * req.header("x-clocknext-signature"),
162
+ * process.env.CLOCKNEXT_WEBHOOK_SECRET!,
163
+ * );
164
+ * } catch {
165
+ * return res.sendStatus(400);
166
+ * }
167
+ * if (event.event_type === "wallet.balance.drop") { … }
168
+ * res.sendStatus(200);
169
+ * });
170
+ * ```
171
+ *
172
+ * @throws {WebhookSignatureError} header missing or malformed, signature does
173
+ * not match, timestamp outside the tolerance, or the body is not a JSON event.
174
+ */
175
+ export function constructWebhookEvent(
176
+ rawBody: string | Buffer,
177
+ signatureHeader: string | null | undefined,
178
+ secret: string,
179
+ options: ConstructEventOptions = {},
180
+ ): WebhookEvent {
181
+ if (typeof rawBody !== "string" && !Buffer.isBuffer(rawBody)) {
182
+ throw new WebhookSignatureError(
183
+ "Webhook body must be the raw request body (string or Buffer), not parsed JSON. " +
184
+ "With Express use express.raw({ type: 'application/json' }).",
185
+ );
186
+ }
187
+ if (!secret) {
188
+ throw new WebhookSignatureError("A webhook secret is required.");
189
+ }
190
+ if (!signatureHeader) {
191
+ throw new WebhookSignatureError("Missing x-clocknext-signature header.");
192
+ }
193
+
194
+ const { t, v1 } = parseHeader(signatureHeader);
195
+
196
+ // Recompute from the same `${t}.${body}` and accept any listed `v1`, so a
197
+ // future signing-key rotation that sends two signatures keeps working.
198
+ const expected = signWebhookPayload(secret, rawBody, t).split("v1=")[1]!;
199
+ if (!v1.some((candidate) => safeEqualHex(expected, candidate))) {
200
+ throw new WebhookSignatureError(
201
+ "Webhook signature does not match. Check the secret and that the body is the unmodified raw body.",
202
+ );
203
+ }
204
+
205
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
206
+ if (tolerance > 0) {
207
+ const now = options.now ?? Math.floor(Date.now() / 1000);
208
+ if (Math.abs(now - t) > tolerance) {
209
+ throw new WebhookSignatureError(
210
+ `Webhook timestamp is outside the ${tolerance}s tolerance — possible replay.`,
211
+ );
212
+ }
213
+ }
214
+
215
+ let parsed: unknown;
216
+ try {
217
+ parsed = JSON.parse(rawBody.toString());
218
+ } catch {
219
+ throw new WebhookSignatureError("Webhook body is not valid JSON.");
220
+ }
221
+ if (
222
+ typeof parsed !== "object" ||
223
+ parsed === null ||
224
+ typeof (parsed as { id?: unknown }).id !== "string" ||
225
+ typeof (parsed as { event_type?: unknown }).event_type !== "string"
226
+ ) {
227
+ throw new WebhookSignatureError("Webhook body is not a ClockNext event.");
228
+ }
229
+ return parsed as WebhookEvent;
230
+ }
231
+
232
+ /** `cnk.webhooks` — the same verifier as {@link constructWebhookEvent}, for
233
+ * callers who already hold a client. Needs no network and no API key use. */
234
+ export class Webhooks {
235
+ /** See {@link constructWebhookEvent}. */
236
+ constructEvent(
237
+ rawBody: string | Buffer,
238
+ signatureHeader: string | null | undefined,
239
+ secret: string,
240
+ options?: ConstructEventOptions,
241
+ ): WebhookEvent {
242
+ return constructWebhookEvent(rawBody, signatureHeader, secret, options);
243
+ }
244
+ }