@webhooks-cc/sdk 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,157 @@
1
+ # @webhooks-cc/sdk
2
+
3
+ TypeScript SDK for [webhooks.cc](https://webhooks.cc). Create temporary webhook endpoints, capture requests, and assert on their contents in your test suite.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @webhooks-cc/sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { WebhooksCC } from "@webhooks-cc/sdk";
15
+
16
+ const client = new WebhooksCC({ apiKey: "whcc_..." });
17
+
18
+ // Create a temporary endpoint
19
+ const endpoint = await client.endpoints.create({ name: "My Test" });
20
+ console.log(endpoint.url); // https://go.webhooks.cc/w/abc123
21
+
22
+ // Point your service at endpoint.url, then wait for the webhook
23
+ const request = await client.requests.waitFor(endpoint.slug, {
24
+ timeout: 10000,
25
+ match: (r) => r.method === "POST",
26
+ });
27
+
28
+ console.log(request.body); // '{"event":"order.created"}'
29
+ console.log(request.headers); // { 'content-type': 'application/json', ... }
30
+
31
+ // Clean up
32
+ await client.endpoints.delete(endpoint.slug);
33
+ ```
34
+
35
+ ## API
36
+
37
+ ### `new WebhooksCC(options)`
38
+
39
+ | Option | Type | Default | Description |
40
+ | --------- | -------- | --------------------- | -------------------- |
41
+ | `apiKey` | `string` | _required_ | API key (`whcc_...`) |
42
+ | `baseUrl` | `string` | `https://webhooks.cc` | API base URL |
43
+ | `timeout` | `number` | `30000` | Request timeout (ms) |
44
+
45
+ ### Endpoints
46
+
47
+ ```typescript
48
+ // Create
49
+ const endpoint = await client.endpoints.create({ name: "optional name" });
50
+
51
+ // List all
52
+ const endpoints = await client.endpoints.list();
53
+
54
+ // Get by slug
55
+ const endpoint = await client.endpoints.get("abc123");
56
+
57
+ // Delete
58
+ await client.endpoints.delete("abc123");
59
+ ```
60
+
61
+ ### Requests
62
+
63
+ ```typescript
64
+ // List captured requests for an endpoint
65
+ const requests = await client.requests.list("endpoint-slug", {
66
+ limit: 50, // default: 50, max: 1000
67
+ since: Date.now() - 60000, // only after this timestamp (ms)
68
+ });
69
+
70
+ // Get a single request by ID
71
+ const request = await client.requests.get("request-id");
72
+
73
+ // Poll until a matching request arrives
74
+ const request = await client.requests.waitFor("endpoint-slug", {
75
+ timeout: 30000, // max wait (ms), default: 30000
76
+ pollInterval: 500, // poll interval (ms), default: 500
77
+ match: (r) => r.method === "POST" && r.body?.includes("order"),
78
+ });
79
+ ```
80
+
81
+ ### Errors
82
+
83
+ ```typescript
84
+ import { WebhooksCC, ApiError } from "@webhooks-cc/sdk";
85
+
86
+ try {
87
+ await client.endpoints.get("nonexistent");
88
+ } catch (error) {
89
+ if (error instanceof ApiError) {
90
+ console.log(error.statusCode); // 404
91
+ console.log(error.message); // "API error (404): ..."
92
+ }
93
+ }
94
+ ```
95
+
96
+ ## GitHub Actions
97
+
98
+ Add your API key as a repository secret named `WHK_API_KEY`:
99
+
100
+ ```yaml
101
+ - name: Run webhook tests
102
+ env:
103
+ WHK_API_KEY: ${{ secrets.WHK_API_KEY }}
104
+ run: npx vitest run
105
+ ```
106
+
107
+ ```typescript
108
+ // webhook.test.ts
109
+ import { describe, it, expect, afterAll } from "vitest";
110
+ import { WebhooksCC } from "@webhooks-cc/sdk";
111
+
112
+ const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
113
+
114
+ describe("webhook integration", () => {
115
+ let slug: string;
116
+
117
+ it("receives order webhook", async () => {
118
+ const endpoint = await client.endpoints.create({ name: "CI Test" });
119
+ slug = endpoint.slug;
120
+
121
+ // Trigger your service to send a webhook to endpoint.url
122
+ await yourService.registerWebhook(endpoint.url!);
123
+ await yourService.createOrder();
124
+
125
+ const req = await client.requests.waitFor(slug, {
126
+ timeout: 15000,
127
+ match: (r) => r.body?.includes("order.created"),
128
+ });
129
+
130
+ const body = JSON.parse(req.body!);
131
+ expect(body.event).toBe("order.created");
132
+ });
133
+
134
+ afterAll(async () => {
135
+ if (slug) await client.endpoints.delete(slug);
136
+ });
137
+ });
138
+ ```
139
+
140
+ ## Types
141
+
142
+ All types are exported:
143
+
144
+ ```typescript
145
+ import type {
146
+ ClientOptions,
147
+ Endpoint,
148
+ Request,
149
+ CreateEndpointOptions,
150
+ ListRequestsOptions,
151
+ WaitForOptions,
152
+ } from "@webhooks-cc/sdk";
153
+ ```
154
+
155
+ ## License
156
+
157
+ MIT
package/dist/index.d.mts CHANGED
@@ -69,6 +69,34 @@ interface WaitForOptions {
69
69
  /** Filter function to match specific requests */
70
70
  match?: (request: Request) => boolean;
71
71
  }
72
+ /** Info passed to the onRequest hook before a request is sent. */
73
+ interface RequestHookInfo {
74
+ method: string;
75
+ url: string;
76
+ }
77
+ /** Info passed to the onResponse hook after a successful response. */
78
+ interface ResponseHookInfo {
79
+ method: string;
80
+ url: string;
81
+ status: number;
82
+ durationMs: number;
83
+ }
84
+ /** Info passed to the onError hook when a request fails. */
85
+ interface ErrorHookInfo {
86
+ method: string;
87
+ url: string;
88
+ error: Error;
89
+ durationMs: number;
90
+ }
91
+ /**
92
+ * Lifecycle hooks for observability and telemetry integration.
93
+ * All hooks are optional and are called synchronously (fire-and-forget).
94
+ */
95
+ interface ClientHooks {
96
+ onRequest?: (info: RequestHookInfo) => void;
97
+ onResponse?: (info: ResponseHookInfo) => void;
98
+ onError?: (info: ErrorHookInfo) => void;
99
+ }
72
100
  /**
73
101
  * Configuration options for the WebhooksCC client.
74
102
  */
@@ -79,6 +107,35 @@ interface ClientOptions {
79
107
  baseUrl?: string;
80
108
  /** Request timeout in milliseconds (default: 30000) */
81
109
  timeout?: number;
110
+ /** Lifecycle hooks for observability */
111
+ hooks?: ClientHooks;
112
+ }
113
+
114
+ /**
115
+ * Base error class for all webhooks.cc SDK errors.
116
+ * Extends the standard Error with an HTTP status code.
117
+ */
118
+ declare class WebhooksCCError extends Error {
119
+ readonly statusCode: number;
120
+ constructor(statusCode: number, message: string);
121
+ }
122
+ /** Thrown when the API key is invalid or missing (401). */
123
+ declare class UnauthorizedError extends WebhooksCCError {
124
+ constructor(message?: string);
125
+ }
126
+ /** Thrown when the requested resource does not exist (404). */
127
+ declare class NotFoundError extends WebhooksCCError {
128
+ constructor(message?: string);
129
+ }
130
+ /** Thrown when the request times out. */
131
+ declare class TimeoutError extends WebhooksCCError {
132
+ constructor(timeoutMs: number);
133
+ }
134
+ /** Thrown when the API returns 429 Too Many Requests. */
135
+ declare class RateLimitError extends WebhooksCCError {
136
+ /** Seconds until the rate limit resets, if provided by the server. */
137
+ readonly retryAfter?: number;
138
+ constructor(retryAfter?: number);
82
139
  }
83
140
 
84
141
  /**
@@ -96,13 +153,9 @@ interface ClientOptions {
96
153
  */
97
154
 
98
155
  /**
99
- * Error thrown when an API request fails with a specific HTTP status code.
100
- * Allows callers to distinguish between different error types.
156
+ * @deprecated Use {@link WebhooksCCError} instead. Kept for backward compatibility.
101
157
  */
102
- declare class ApiError extends Error {
103
- readonly statusCode: number;
104
- constructor(statusCode: number, message: string);
105
- }
158
+ declare const ApiError: typeof WebhooksCCError;
106
159
  /**
107
160
  * Client for the webhooks.cc API.
108
161
  *
@@ -114,6 +167,7 @@ declare class WebhooksCC {
114
167
  private readonly apiKey;
115
168
  private readonly baseUrl;
116
169
  private readonly timeout;
170
+ private readonly hooks;
117
171
  constructor(options: ClientOptions);
118
172
  private request;
119
173
  endpoints: {
@@ -141,4 +195,32 @@ declare class WebhooksCC {
141
195
  };
142
196
  }
143
197
 
144
- export { ApiError, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ListRequestsOptions, type Request, type WaitForOptions, WebhooksCC };
198
+ /**
199
+ * Safely parse a JSON request body.
200
+ * Returns undefined if the body is empty or not valid JSON.
201
+ */
202
+ declare function parseJsonBody(request: Request): unknown | undefined;
203
+ /**
204
+ * Check if a request looks like a Stripe webhook.
205
+ * Matches on the `stripe-signature` header being present.
206
+ */
207
+ declare function isStripeWebhook(request: Request): boolean;
208
+ /**
209
+ * Check if a request looks like a GitHub webhook.
210
+ * Matches on the `x-github-event` header being present.
211
+ */
212
+ declare function isGitHubWebhook(request: Request): boolean;
213
+ /**
214
+ * Returns a match function that checks whether a JSON field in the
215
+ * request body equals the expected value.
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * const req = await client.requests.waitFor(slug, {
220
+ * match: matchJsonField("type", "checkout.session.completed"),
221
+ * });
222
+ * ```
223
+ */
224
+ declare function matchJsonField(field: string, value: unknown): (request: Request) => boolean;
225
+
226
+ export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, TimeoutError, UnauthorizedError, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isStripeWebhook, matchJsonField, parseJsonBody };
package/dist/index.d.ts CHANGED
@@ -69,6 +69,34 @@ interface WaitForOptions {
69
69
  /** Filter function to match specific requests */
70
70
  match?: (request: Request) => boolean;
71
71
  }
72
+ /** Info passed to the onRequest hook before a request is sent. */
73
+ interface RequestHookInfo {
74
+ method: string;
75
+ url: string;
76
+ }
77
+ /** Info passed to the onResponse hook after a successful response. */
78
+ interface ResponseHookInfo {
79
+ method: string;
80
+ url: string;
81
+ status: number;
82
+ durationMs: number;
83
+ }
84
+ /** Info passed to the onError hook when a request fails. */
85
+ interface ErrorHookInfo {
86
+ method: string;
87
+ url: string;
88
+ error: Error;
89
+ durationMs: number;
90
+ }
91
+ /**
92
+ * Lifecycle hooks for observability and telemetry integration.
93
+ * All hooks are optional and are called synchronously (fire-and-forget).
94
+ */
95
+ interface ClientHooks {
96
+ onRequest?: (info: RequestHookInfo) => void;
97
+ onResponse?: (info: ResponseHookInfo) => void;
98
+ onError?: (info: ErrorHookInfo) => void;
99
+ }
72
100
  /**
73
101
  * Configuration options for the WebhooksCC client.
74
102
  */
@@ -79,6 +107,35 @@ interface ClientOptions {
79
107
  baseUrl?: string;
80
108
  /** Request timeout in milliseconds (default: 30000) */
81
109
  timeout?: number;
110
+ /** Lifecycle hooks for observability */
111
+ hooks?: ClientHooks;
112
+ }
113
+
114
+ /**
115
+ * Base error class for all webhooks.cc SDK errors.
116
+ * Extends the standard Error with an HTTP status code.
117
+ */
118
+ declare class WebhooksCCError extends Error {
119
+ readonly statusCode: number;
120
+ constructor(statusCode: number, message: string);
121
+ }
122
+ /** Thrown when the API key is invalid or missing (401). */
123
+ declare class UnauthorizedError extends WebhooksCCError {
124
+ constructor(message?: string);
125
+ }
126
+ /** Thrown when the requested resource does not exist (404). */
127
+ declare class NotFoundError extends WebhooksCCError {
128
+ constructor(message?: string);
129
+ }
130
+ /** Thrown when the request times out. */
131
+ declare class TimeoutError extends WebhooksCCError {
132
+ constructor(timeoutMs: number);
133
+ }
134
+ /** Thrown when the API returns 429 Too Many Requests. */
135
+ declare class RateLimitError extends WebhooksCCError {
136
+ /** Seconds until the rate limit resets, if provided by the server. */
137
+ readonly retryAfter?: number;
138
+ constructor(retryAfter?: number);
82
139
  }
83
140
 
84
141
  /**
@@ -96,13 +153,9 @@ interface ClientOptions {
96
153
  */
97
154
 
98
155
  /**
99
- * Error thrown when an API request fails with a specific HTTP status code.
100
- * Allows callers to distinguish between different error types.
156
+ * @deprecated Use {@link WebhooksCCError} instead. Kept for backward compatibility.
101
157
  */
102
- declare class ApiError extends Error {
103
- readonly statusCode: number;
104
- constructor(statusCode: number, message: string);
105
- }
158
+ declare const ApiError: typeof WebhooksCCError;
106
159
  /**
107
160
  * Client for the webhooks.cc API.
108
161
  *
@@ -114,6 +167,7 @@ declare class WebhooksCC {
114
167
  private readonly apiKey;
115
168
  private readonly baseUrl;
116
169
  private readonly timeout;
170
+ private readonly hooks;
117
171
  constructor(options: ClientOptions);
118
172
  private request;
119
173
  endpoints: {
@@ -141,4 +195,32 @@ declare class WebhooksCC {
141
195
  };
142
196
  }
143
197
 
144
- export { ApiError, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ListRequestsOptions, type Request, type WaitForOptions, WebhooksCC };
198
+ /**
199
+ * Safely parse a JSON request body.
200
+ * Returns undefined if the body is empty or not valid JSON.
201
+ */
202
+ declare function parseJsonBody(request: Request): unknown | undefined;
203
+ /**
204
+ * Check if a request looks like a Stripe webhook.
205
+ * Matches on the `stripe-signature` header being present.
206
+ */
207
+ declare function isStripeWebhook(request: Request): boolean;
208
+ /**
209
+ * Check if a request looks like a GitHub webhook.
210
+ * Matches on the `x-github-event` header being present.
211
+ */
212
+ declare function isGitHubWebhook(request: Request): boolean;
213
+ /**
214
+ * Returns a match function that checks whether a JSON field in the
215
+ * request body equals the expected value.
216
+ *
217
+ * @example
218
+ * ```ts
219
+ * const req = await client.requests.waitFor(slug, {
220
+ * match: matchJsonField("type", "checkout.session.completed"),
221
+ * });
222
+ * ```
223
+ */
224
+ declare function matchJsonField(field: string, value: unknown): (request: Request) => boolean;
225
+
226
+ export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, TimeoutError, UnauthorizedError, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isStripeWebhook, matchJsonField, parseJsonBody };
package/dist/index.js CHANGED
@@ -21,22 +21,80 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  ApiError: () => ApiError,
24
- WebhooksCC: () => WebhooksCC
24
+ NotFoundError: () => NotFoundError,
25
+ RateLimitError: () => RateLimitError,
26
+ TimeoutError: () => TimeoutError,
27
+ UnauthorizedError: () => UnauthorizedError,
28
+ WebhooksCC: () => WebhooksCC,
29
+ WebhooksCCError: () => WebhooksCCError,
30
+ isGitHubWebhook: () => isGitHubWebhook,
31
+ isStripeWebhook: () => isStripeWebhook,
32
+ matchJsonField: () => matchJsonField,
33
+ parseJsonBody: () => parseJsonBody
25
34
  });
26
35
  module.exports = __toCommonJS(index_exports);
27
36
 
37
+ // src/errors.ts
38
+ var WebhooksCCError = class extends Error {
39
+ constructor(statusCode, message) {
40
+ super(message);
41
+ this.statusCode = statusCode;
42
+ this.name = "WebhooksCCError";
43
+ Object.setPrototypeOf(this, new.target.prototype);
44
+ }
45
+ };
46
+ var UnauthorizedError = class extends WebhooksCCError {
47
+ constructor(message = "Invalid or missing API key") {
48
+ super(401, message);
49
+ this.name = "UnauthorizedError";
50
+ }
51
+ };
52
+ var NotFoundError = class extends WebhooksCCError {
53
+ constructor(message = "Resource not found") {
54
+ super(404, message);
55
+ this.name = "NotFoundError";
56
+ }
57
+ };
58
+ var TimeoutError = class extends WebhooksCCError {
59
+ constructor(timeoutMs) {
60
+ super(0, `Request timed out after ${timeoutMs}ms`);
61
+ this.name = "TimeoutError";
62
+ }
63
+ };
64
+ var RateLimitError = class extends WebhooksCCError {
65
+ constructor(retryAfter) {
66
+ const message = retryAfter ? `Rate limited, retry after ${retryAfter}s` : "Rate limited";
67
+ super(429, message);
68
+ this.name = "RateLimitError";
69
+ this.retryAfter = retryAfter;
70
+ }
71
+ };
72
+
28
73
  // src/client.ts
29
74
  var DEFAULT_BASE_URL = "https://webhooks.cc";
30
75
  var DEFAULT_TIMEOUT = 3e4;
31
76
  var MIN_POLL_INTERVAL = 10;
32
77
  var MAX_POLL_INTERVAL = 6e4;
33
- var ApiError = class extends Error {
34
- constructor(statusCode, message) {
35
- super(`API error (${statusCode}): ${message}`);
36
- this.statusCode = statusCode;
37
- this.name = "ApiError";
78
+ var ApiError = WebhooksCCError;
79
+ function mapStatusToError(status, message, response) {
80
+ switch (status) {
81
+ case 401:
82
+ return new UnauthorizedError(message);
83
+ case 404:
84
+ return new NotFoundError(message);
85
+ case 429: {
86
+ const retryAfterHeader = response.headers.get("retry-after");
87
+ let retryAfter;
88
+ if (retryAfterHeader) {
89
+ const parsed = parseInt(retryAfterHeader, 10);
90
+ retryAfter = Number.isNaN(parsed) ? void 0 : parsed;
91
+ }
92
+ return new RateLimitError(retryAfter);
93
+ }
94
+ default:
95
+ return new WebhooksCCError(status, message);
38
96
  }
39
- };
97
+ }
40
98
  var SAFE_PATH_SEGMENT_REGEX = /^[a-zA-Z0-9_-]+$/;
41
99
  function validatePathSegment(segment, name) {
42
100
  if (!SAFE_PATH_SEGMENT_REGEX.test(segment)) {
@@ -116,38 +174,39 @@ var WebhooksCC = class {
116
174
  return matched;
117
175
  }
118
176
  } catch (error) {
119
- if (error instanceof ApiError) {
120
- if (error.statusCode === 401) {
121
- throw new Error("Authentication failed: invalid or expired API key");
122
- }
123
- if (error.statusCode === 403) {
124
- throw new Error("Access denied: insufficient permissions for this endpoint");
177
+ if (error instanceof WebhooksCCError) {
178
+ if (error instanceof UnauthorizedError) {
179
+ throw error;
125
180
  }
126
- if (error.statusCode === 404) {
127
- throw new Error(`Endpoint "${endpointSlug}" not found`);
181
+ if (error instanceof NotFoundError) {
182
+ throw error;
128
183
  }
129
- if (error.statusCode < 500) {
184
+ if (error.statusCode < 500 && !(error instanceof RateLimitError)) {
130
185
  throw error;
131
186
  }
132
187
  }
133
188
  }
134
189
  await sleep(safePollInterval);
135
190
  }
136
- if (iterations >= MAX_ITERATIONS) {
137
- throw new Error(`Max iterations (${MAX_ITERATIONS}) reached while waiting for request`);
138
- }
139
- throw new Error(`Timeout waiting for request after ${timeout}ms`);
191
+ throw new TimeoutError(timeout);
140
192
  }
141
193
  };
142
194
  this.apiKey = options.apiKey;
143
195
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
144
196
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
197
+ this.hooks = options.hooks ?? {};
145
198
  }
146
199
  async request(method, path, body) {
200
+ const url = `${this.baseUrl}/api${path}`;
147
201
  const controller = new AbortController();
148
202
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
203
+ const start = Date.now();
204
+ try {
205
+ this.hooks.onRequest?.({ method, url });
206
+ } catch {
207
+ }
149
208
  try {
150
- const response = await fetch(`${this.baseUrl}/api${path}`, {
209
+ const response = await fetch(url, {
151
210
  method,
152
211
  headers: {
153
212
  Authorization: `Bearer ${this.apiKey}`,
@@ -156,10 +215,20 @@ var WebhooksCC = class {
156
215
  body: body ? JSON.stringify(body) : void 0,
157
216
  signal: controller.signal
158
217
  });
218
+ const durationMs = Date.now() - start;
159
219
  if (!response.ok) {
160
- const error = await response.text();
161
- const sanitizedError = error.length > 200 ? error.slice(0, 200) + "..." : error;
162
- throw new ApiError(response.status, sanitizedError);
220
+ const errorText = await response.text();
221
+ const sanitizedError = errorText.length > 200 ? errorText.slice(0, 200) + "..." : errorText;
222
+ const error = mapStatusToError(response.status, sanitizedError, response);
223
+ try {
224
+ this.hooks.onError?.({ method, url, error, durationMs });
225
+ } catch {
226
+ }
227
+ throw error;
228
+ }
229
+ try {
230
+ this.hooks.onResponse?.({ method, url, status: response.status, durationMs });
231
+ } catch {
163
232
  }
164
233
  if (response.status === 204 || response.headers.get("content-length") === "0") {
165
234
  return void 0;
@@ -171,7 +240,17 @@ var WebhooksCC = class {
171
240
  return response.json();
172
241
  } catch (error) {
173
242
  if (error instanceof Error && error.name === "AbortError") {
174
- throw new Error(`Request timed out after ${this.timeout}ms`);
243
+ const timeoutError = new TimeoutError(this.timeout);
244
+ try {
245
+ this.hooks.onError?.({
246
+ method,
247
+ url,
248
+ error: timeoutError,
249
+ durationMs: Date.now() - start
250
+ });
251
+ } catch {
252
+ }
253
+ throw timeoutError;
175
254
  }
176
255
  throw error;
177
256
  } finally {
@@ -182,8 +261,41 @@ var WebhooksCC = class {
182
261
  function sleep(ms) {
183
262
  return new Promise((resolve) => setTimeout(resolve, ms));
184
263
  }
264
+
265
+ // src/helpers.ts
266
+ function parseJsonBody(request) {
267
+ if (!request.body) return void 0;
268
+ try {
269
+ return JSON.parse(request.body);
270
+ } catch {
271
+ return void 0;
272
+ }
273
+ }
274
+ function isStripeWebhook(request) {
275
+ return Object.keys(request.headers).some((k) => k.toLowerCase() === "stripe-signature");
276
+ }
277
+ function isGitHubWebhook(request) {
278
+ return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-github-event");
279
+ }
280
+ function matchJsonField(field, value) {
281
+ return (request) => {
282
+ const body = parseJsonBody(request);
283
+ if (typeof body !== "object" || body === null) return false;
284
+ if (!Object.prototype.hasOwnProperty.call(body, field)) return false;
285
+ return body[field] === value;
286
+ };
287
+ }
185
288
  // Annotate the CommonJS export names for ESM import in node:
186
289
  0 && (module.exports = {
187
290
  ApiError,
188
- WebhooksCC
291
+ NotFoundError,
292
+ RateLimitError,
293
+ TimeoutError,
294
+ UnauthorizedError,
295
+ WebhooksCC,
296
+ WebhooksCCError,
297
+ isGitHubWebhook,
298
+ isStripeWebhook,
299
+ matchJsonField,
300
+ parseJsonBody
189
301
  });
package/dist/index.mjs CHANGED
@@ -1,15 +1,64 @@
1
+ // src/errors.ts
2
+ var WebhooksCCError = class extends Error {
3
+ constructor(statusCode, message) {
4
+ super(message);
5
+ this.statusCode = statusCode;
6
+ this.name = "WebhooksCCError";
7
+ Object.setPrototypeOf(this, new.target.prototype);
8
+ }
9
+ };
10
+ var UnauthorizedError = class extends WebhooksCCError {
11
+ constructor(message = "Invalid or missing API key") {
12
+ super(401, message);
13
+ this.name = "UnauthorizedError";
14
+ }
15
+ };
16
+ var NotFoundError = class extends WebhooksCCError {
17
+ constructor(message = "Resource not found") {
18
+ super(404, message);
19
+ this.name = "NotFoundError";
20
+ }
21
+ };
22
+ var TimeoutError = class extends WebhooksCCError {
23
+ constructor(timeoutMs) {
24
+ super(0, `Request timed out after ${timeoutMs}ms`);
25
+ this.name = "TimeoutError";
26
+ }
27
+ };
28
+ var RateLimitError = class extends WebhooksCCError {
29
+ constructor(retryAfter) {
30
+ const message = retryAfter ? `Rate limited, retry after ${retryAfter}s` : "Rate limited";
31
+ super(429, message);
32
+ this.name = "RateLimitError";
33
+ this.retryAfter = retryAfter;
34
+ }
35
+ };
36
+
1
37
  // src/client.ts
2
38
  var DEFAULT_BASE_URL = "https://webhooks.cc";
3
39
  var DEFAULT_TIMEOUT = 3e4;
4
40
  var MIN_POLL_INTERVAL = 10;
5
41
  var MAX_POLL_INTERVAL = 6e4;
6
- var ApiError = class extends Error {
7
- constructor(statusCode, message) {
8
- super(`API error (${statusCode}): ${message}`);
9
- this.statusCode = statusCode;
10
- this.name = "ApiError";
42
+ var ApiError = WebhooksCCError;
43
+ function mapStatusToError(status, message, response) {
44
+ switch (status) {
45
+ case 401:
46
+ return new UnauthorizedError(message);
47
+ case 404:
48
+ return new NotFoundError(message);
49
+ case 429: {
50
+ const retryAfterHeader = response.headers.get("retry-after");
51
+ let retryAfter;
52
+ if (retryAfterHeader) {
53
+ const parsed = parseInt(retryAfterHeader, 10);
54
+ retryAfter = Number.isNaN(parsed) ? void 0 : parsed;
55
+ }
56
+ return new RateLimitError(retryAfter);
57
+ }
58
+ default:
59
+ return new WebhooksCCError(status, message);
11
60
  }
12
- };
61
+ }
13
62
  var SAFE_PATH_SEGMENT_REGEX = /^[a-zA-Z0-9_-]+$/;
14
63
  function validatePathSegment(segment, name) {
15
64
  if (!SAFE_PATH_SEGMENT_REGEX.test(segment)) {
@@ -89,38 +138,39 @@ var WebhooksCC = class {
89
138
  return matched;
90
139
  }
91
140
  } catch (error) {
92
- if (error instanceof ApiError) {
93
- if (error.statusCode === 401) {
94
- throw new Error("Authentication failed: invalid or expired API key");
95
- }
96
- if (error.statusCode === 403) {
97
- throw new Error("Access denied: insufficient permissions for this endpoint");
141
+ if (error instanceof WebhooksCCError) {
142
+ if (error instanceof UnauthorizedError) {
143
+ throw error;
98
144
  }
99
- if (error.statusCode === 404) {
100
- throw new Error(`Endpoint "${endpointSlug}" not found`);
145
+ if (error instanceof NotFoundError) {
146
+ throw error;
101
147
  }
102
- if (error.statusCode < 500) {
148
+ if (error.statusCode < 500 && !(error instanceof RateLimitError)) {
103
149
  throw error;
104
150
  }
105
151
  }
106
152
  }
107
153
  await sleep(safePollInterval);
108
154
  }
109
- if (iterations >= MAX_ITERATIONS) {
110
- throw new Error(`Max iterations (${MAX_ITERATIONS}) reached while waiting for request`);
111
- }
112
- throw new Error(`Timeout waiting for request after ${timeout}ms`);
155
+ throw new TimeoutError(timeout);
113
156
  }
114
157
  };
115
158
  this.apiKey = options.apiKey;
116
159
  this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
117
160
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
161
+ this.hooks = options.hooks ?? {};
118
162
  }
119
163
  async request(method, path, body) {
164
+ const url = `${this.baseUrl}/api${path}`;
120
165
  const controller = new AbortController();
121
166
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
167
+ const start = Date.now();
168
+ try {
169
+ this.hooks.onRequest?.({ method, url });
170
+ } catch {
171
+ }
122
172
  try {
123
- const response = await fetch(`${this.baseUrl}/api${path}`, {
173
+ const response = await fetch(url, {
124
174
  method,
125
175
  headers: {
126
176
  Authorization: `Bearer ${this.apiKey}`,
@@ -129,10 +179,20 @@ var WebhooksCC = class {
129
179
  body: body ? JSON.stringify(body) : void 0,
130
180
  signal: controller.signal
131
181
  });
182
+ const durationMs = Date.now() - start;
132
183
  if (!response.ok) {
133
- const error = await response.text();
134
- const sanitizedError = error.length > 200 ? error.slice(0, 200) + "..." : error;
135
- throw new ApiError(response.status, sanitizedError);
184
+ const errorText = await response.text();
185
+ const sanitizedError = errorText.length > 200 ? errorText.slice(0, 200) + "..." : errorText;
186
+ const error = mapStatusToError(response.status, sanitizedError, response);
187
+ try {
188
+ this.hooks.onError?.({ method, url, error, durationMs });
189
+ } catch {
190
+ }
191
+ throw error;
192
+ }
193
+ try {
194
+ this.hooks.onResponse?.({ method, url, status: response.status, durationMs });
195
+ } catch {
136
196
  }
137
197
  if (response.status === 204 || response.headers.get("content-length") === "0") {
138
198
  return void 0;
@@ -144,7 +204,17 @@ var WebhooksCC = class {
144
204
  return response.json();
145
205
  } catch (error) {
146
206
  if (error instanceof Error && error.name === "AbortError") {
147
- throw new Error(`Request timed out after ${this.timeout}ms`);
207
+ const timeoutError = new TimeoutError(this.timeout);
208
+ try {
209
+ this.hooks.onError?.({
210
+ method,
211
+ url,
212
+ error: timeoutError,
213
+ durationMs: Date.now() - start
214
+ });
215
+ } catch {
216
+ }
217
+ throw timeoutError;
148
218
  }
149
219
  throw error;
150
220
  } finally {
@@ -155,7 +225,40 @@ var WebhooksCC = class {
155
225
  function sleep(ms) {
156
226
  return new Promise((resolve) => setTimeout(resolve, ms));
157
227
  }
228
+
229
+ // src/helpers.ts
230
+ function parseJsonBody(request) {
231
+ if (!request.body) return void 0;
232
+ try {
233
+ return JSON.parse(request.body);
234
+ } catch {
235
+ return void 0;
236
+ }
237
+ }
238
+ function isStripeWebhook(request) {
239
+ return Object.keys(request.headers).some((k) => k.toLowerCase() === "stripe-signature");
240
+ }
241
+ function isGitHubWebhook(request) {
242
+ return Object.keys(request.headers).some((k) => k.toLowerCase() === "x-github-event");
243
+ }
244
+ function matchJsonField(field, value) {
245
+ return (request) => {
246
+ const body = parseJsonBody(request);
247
+ if (typeof body !== "object" || body === null) return false;
248
+ if (!Object.prototype.hasOwnProperty.call(body, field)) return false;
249
+ return body[field] === value;
250
+ };
251
+ }
158
252
  export {
159
253
  ApiError,
160
- WebhooksCC
254
+ NotFoundError,
255
+ RateLimitError,
256
+ TimeoutError,
257
+ UnauthorizedError,
258
+ WebhooksCC,
259
+ WebhooksCCError,
260
+ isGitHubWebhook,
261
+ isStripeWebhook,
262
+ matchJsonField,
263
+ parseJsonBody
161
264
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@webhooks-cc/sdk",
3
- "version": "0.1.0",
4
- "description": "Official SDK for webhooks.cc — programmatic webhook testing",
3
+ "version": "0.2.0",
4
+ "description": "TypeScript SDK for webhooks.cc — create endpoints, capture requests, assert in tests",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
@@ -13,7 +13,8 @@
13
13
  }
14
14
  },
15
15
  "files": [
16
- "dist"
16
+ "dist",
17
+ "README.md"
17
18
  ],
18
19
  "keywords": [
19
20
  "webhook",