@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
|
@@ -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
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;
|
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|