@webhooks-cc/sdk 0.4.0 → 0.6.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/dist/index.d.mts CHANGED
@@ -73,7 +73,7 @@ interface SendOptions {
73
73
  /** Request body (will be JSON-serialized if not a string) */
74
74
  body?: unknown;
75
75
  }
76
- type TemplateProvider = "stripe" | "github" | "shopify" | "twilio";
76
+ type TemplateProvider = "stripe" | "github" | "shopify" | "twilio" | "standard-webhooks";
77
77
  /**
78
78
  * Options for sending a provider template webhook with signed headers.
79
79
  */
@@ -172,10 +172,34 @@ interface OperationDescription {
172
172
  description: string;
173
173
  params: Record<string, string>;
174
174
  }
175
+ /**
176
+ * Options for sending a webhook directly to an arbitrary URL.
177
+ * Supports optional provider signing (Standard Webhooks, Stripe, etc.).
178
+ */
179
+ interface SendToOptions {
180
+ /** Provider template for signing (optional). When set, secret is required. */
181
+ provider?: TemplateProvider;
182
+ /** Provider-specific template preset (e.g. "checkout.session.completed" for Stripe) */
183
+ template?: string;
184
+ /** Secret for provider signature generation (required when provider is set) */
185
+ secret?: string;
186
+ /** Event name for provider headers */
187
+ event?: string;
188
+ /** HTTP method (default: "POST") */
189
+ method?: string;
190
+ /** HTTP headers to include */
191
+ headers?: Record<string, string>;
192
+ /** Request body (will be JSON-serialized if not a string) */
193
+ body?: unknown;
194
+ /** Unix timestamp (seconds) override for deterministic signatures in tests */
195
+ timestamp?: number;
196
+ }
175
197
  /** Self-describing schema returned by client.describe(). */
176
198
  interface SDKDescription {
177
199
  version: string;
178
200
  endpoints: Record<string, OperationDescription>;
201
+ sendTo: OperationDescription;
202
+ buildRequest: OperationDescription;
179
203
  requests: Record<string, OperationDescription>;
180
204
  }
181
205
 
@@ -252,6 +276,31 @@ declare class WebhooksCC {
252
276
  send: (slug: string, options?: SendOptions) => Promise<Response>;
253
277
  sendTemplate: (slug: string, options: SendTemplateOptions) => Promise<Response>;
254
278
  };
279
+ /**
280
+ * Build a request without sending it. Returns the computed method, URL,
281
+ * headers, and body — including any provider signatures. Useful for
282
+ * debugging what sendTo would actually send.
283
+ *
284
+ * @param url - Target URL (http or https)
285
+ * @param options - Same options as sendTo
286
+ * @returns The computed request details
287
+ */
288
+ buildRequest: (url: string, options?: SendToOptions) => Promise<{
289
+ url: string;
290
+ method: string;
291
+ headers: Record<string, string>;
292
+ body?: string;
293
+ }>;
294
+ /**
295
+ * Send a webhook directly to any URL with optional provider signing.
296
+ * Use this for local integration testing — send properly signed webhooks
297
+ * to localhost handlers without routing through webhooks.cc infrastructure.
298
+ *
299
+ * @param url - Target URL to send the webhook to (http or https)
300
+ * @param options - Method, headers, body, and optional provider signing
301
+ * @returns Raw fetch Response from the target
302
+ */
303
+ sendTo: (url: string, options?: SendToOptions) => Promise<Response>;
255
304
  requests: {
256
305
  list: (endpointSlug: string, options?: ListRequestsOptions) => Promise<Request[]>;
257
306
  get: (requestId: string) => Promise<Request>;
@@ -325,6 +374,12 @@ declare function isTwilioWebhook(request: Request): boolean;
325
374
  declare function isPaddleWebhook(request: Request): boolean;
326
375
  /** Check if a request looks like a Linear webhook. */
327
376
  declare function isLinearWebhook(request: Request): boolean;
377
+ /**
378
+ * Check if a request looks like a Standard Webhooks request.
379
+ * Matches on the presence of all three Standard Webhooks headers:
380
+ * webhook-id, webhook-timestamp, and webhook-signature.
381
+ */
382
+ declare function isStandardWebhook(request: Request): boolean;
328
383
 
329
384
  /** Match requests by HTTP method (case-insensitive). */
330
385
  declare function matchMethod(method: string): (request: Request) => boolean;
@@ -376,4 +431,4 @@ interface SSEFrame {
376
431
  */
377
432
  declare function parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEFrame, void, undefined>;
378
433
 
379
- export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, type OperationDescription, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, type SDKDescription, type SSEFrame, type SendOptions, type SendTemplateOptions, type SubscribeOptions, type TemplateProvider, TimeoutError, UnauthorizedError, type UpdateEndpointOptions, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isLinearWebhook, isPaddleWebhook, isShopifyWebhook, isSlackWebhook, isStripeWebhook, isTwilioWebhook, matchAll, matchAny, matchBodyPath, matchHeader, matchJsonField, matchMethod, parseDuration, parseJsonBody, parseSSE };
434
+ export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, type OperationDescription, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, type SDKDescription, type SSEFrame, type SendOptions, type SendTemplateOptions, type SendToOptions, type SubscribeOptions, type TemplateProvider, TimeoutError, UnauthorizedError, type UpdateEndpointOptions, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isLinearWebhook, isPaddleWebhook, isShopifyWebhook, isSlackWebhook, isStandardWebhook, isStripeWebhook, isTwilioWebhook, matchAll, matchAny, matchBodyPath, matchHeader, matchJsonField, matchMethod, parseDuration, parseJsonBody, parseSSE };
package/dist/index.d.ts CHANGED
@@ -73,7 +73,7 @@ interface SendOptions {
73
73
  /** Request body (will be JSON-serialized if not a string) */
74
74
  body?: unknown;
75
75
  }
76
- type TemplateProvider = "stripe" | "github" | "shopify" | "twilio";
76
+ type TemplateProvider = "stripe" | "github" | "shopify" | "twilio" | "standard-webhooks";
77
77
  /**
78
78
  * Options for sending a provider template webhook with signed headers.
79
79
  */
@@ -172,10 +172,34 @@ interface OperationDescription {
172
172
  description: string;
173
173
  params: Record<string, string>;
174
174
  }
175
+ /**
176
+ * Options for sending a webhook directly to an arbitrary URL.
177
+ * Supports optional provider signing (Standard Webhooks, Stripe, etc.).
178
+ */
179
+ interface SendToOptions {
180
+ /** Provider template for signing (optional). When set, secret is required. */
181
+ provider?: TemplateProvider;
182
+ /** Provider-specific template preset (e.g. "checkout.session.completed" for Stripe) */
183
+ template?: string;
184
+ /** Secret for provider signature generation (required when provider is set) */
185
+ secret?: string;
186
+ /** Event name for provider headers */
187
+ event?: string;
188
+ /** HTTP method (default: "POST") */
189
+ method?: string;
190
+ /** HTTP headers to include */
191
+ headers?: Record<string, string>;
192
+ /** Request body (will be JSON-serialized if not a string) */
193
+ body?: unknown;
194
+ /** Unix timestamp (seconds) override for deterministic signatures in tests */
195
+ timestamp?: number;
196
+ }
175
197
  /** Self-describing schema returned by client.describe(). */
176
198
  interface SDKDescription {
177
199
  version: string;
178
200
  endpoints: Record<string, OperationDescription>;
201
+ sendTo: OperationDescription;
202
+ buildRequest: OperationDescription;
179
203
  requests: Record<string, OperationDescription>;
180
204
  }
181
205
 
@@ -252,6 +276,31 @@ declare class WebhooksCC {
252
276
  send: (slug: string, options?: SendOptions) => Promise<Response>;
253
277
  sendTemplate: (slug: string, options: SendTemplateOptions) => Promise<Response>;
254
278
  };
279
+ /**
280
+ * Build a request without sending it. Returns the computed method, URL,
281
+ * headers, and body — including any provider signatures. Useful for
282
+ * debugging what sendTo would actually send.
283
+ *
284
+ * @param url - Target URL (http or https)
285
+ * @param options - Same options as sendTo
286
+ * @returns The computed request details
287
+ */
288
+ buildRequest: (url: string, options?: SendToOptions) => Promise<{
289
+ url: string;
290
+ method: string;
291
+ headers: Record<string, string>;
292
+ body?: string;
293
+ }>;
294
+ /**
295
+ * Send a webhook directly to any URL with optional provider signing.
296
+ * Use this for local integration testing — send properly signed webhooks
297
+ * to localhost handlers without routing through webhooks.cc infrastructure.
298
+ *
299
+ * @param url - Target URL to send the webhook to (http or https)
300
+ * @param options - Method, headers, body, and optional provider signing
301
+ * @returns Raw fetch Response from the target
302
+ */
303
+ sendTo: (url: string, options?: SendToOptions) => Promise<Response>;
255
304
  requests: {
256
305
  list: (endpointSlug: string, options?: ListRequestsOptions) => Promise<Request[]>;
257
306
  get: (requestId: string) => Promise<Request>;
@@ -325,6 +374,12 @@ declare function isTwilioWebhook(request: Request): boolean;
325
374
  declare function isPaddleWebhook(request: Request): boolean;
326
375
  /** Check if a request looks like a Linear webhook. */
327
376
  declare function isLinearWebhook(request: Request): boolean;
377
+ /**
378
+ * Check if a request looks like a Standard Webhooks request.
379
+ * Matches on the presence of all three Standard Webhooks headers:
380
+ * webhook-id, webhook-timestamp, and webhook-signature.
381
+ */
382
+ declare function isStandardWebhook(request: Request): boolean;
328
383
 
329
384
  /** Match requests by HTTP method (case-insensitive). */
330
385
  declare function matchMethod(method: string): (request: Request) => boolean;
@@ -376,4 +431,4 @@ interface SSEFrame {
376
431
  */
377
432
  declare function parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEFrame, void, undefined>;
378
433
 
379
- export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, type OperationDescription, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, type SDKDescription, type SSEFrame, type SendOptions, type SendTemplateOptions, type SubscribeOptions, type TemplateProvider, TimeoutError, UnauthorizedError, type UpdateEndpointOptions, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isLinearWebhook, isPaddleWebhook, isShopifyWebhook, isSlackWebhook, isStripeWebhook, isTwilioWebhook, matchAll, matchAny, matchBodyPath, matchHeader, matchJsonField, matchMethod, parseDuration, parseJsonBody, parseSSE };
434
+ export { ApiError, type ClientHooks, type ClientOptions, type CreateEndpointOptions, type Endpoint, type ErrorHookInfo, type ListRequestsOptions, NotFoundError, type OperationDescription, RateLimitError, type Request, type RequestHookInfo, type ResponseHookInfo, type SDKDescription, type SSEFrame, type SendOptions, type SendTemplateOptions, type SendToOptions, type SubscribeOptions, type TemplateProvider, TimeoutError, UnauthorizedError, type UpdateEndpointOptions, type WaitForOptions, WebhooksCC, WebhooksCCError, isGitHubWebhook, isLinearWebhook, isPaddleWebhook, isShopifyWebhook, isSlackWebhook, isStandardWebhook, isStripeWebhook, isTwilioWebhook, matchAll, matchAny, matchBodyPath, matchHeader, matchJsonField, matchMethod, parseDuration, parseJsonBody, parseSSE };
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ __export(index_exports, {
32
32
  isPaddleWebhook: () => isPaddleWebhook,
33
33
  isShopifyWebhook: () => isShopifyWebhook,
34
34
  isSlackWebhook: () => isSlackWebhook,
35
+ isStandardWebhook: () => isStandardWebhook,
35
36
  isStripeWebhook: () => isStripeWebhook,
36
37
  isTwilioWebhook: () => isTwilioWebhook,
37
38
  matchAll: () => matchAll,
@@ -721,6 +722,33 @@ function toBase64(bytes) {
721
722
  for (const byte of bytes) binary += String.fromCharCode(byte);
722
723
  return btoa(binary);
723
724
  }
725
+ function fromBase64(str) {
726
+ if (typeof atob !== "function") {
727
+ return new Uint8Array(Buffer.from(str, "base64"));
728
+ }
729
+ const binary = atob(str);
730
+ const bytes = new Uint8Array(binary.length);
731
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
732
+ return bytes;
733
+ }
734
+ async function hmacSignRaw(algorithm, keyBytes, payload) {
735
+ if (!globalThis.crypto?.subtle) {
736
+ throw new Error("crypto.subtle is required for signature generation");
737
+ }
738
+ const key = await globalThis.crypto.subtle.importKey(
739
+ "raw",
740
+ keyBytes.buffer,
741
+ { name: "HMAC", hash: algorithm },
742
+ false,
743
+ ["sign"]
744
+ );
745
+ const signature = await globalThis.crypto.subtle.sign(
746
+ "HMAC",
747
+ key,
748
+ new TextEncoder().encode(payload)
749
+ );
750
+ return new Uint8Array(signature);
751
+ }
724
752
  function buildTwilioSignaturePayload(endpointUrl, params) {
725
753
  const sortedParams = params.map(([key, value], index) => ({ key, value, index })).sort(
726
754
  (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.value < b.value ? -1 : a.value > b.value ? 1 : 0
@@ -732,35 +760,68 @@ function buildTwilioSignaturePayload(endpointUrl, params) {
732
760
  return payload;
733
761
  }
734
762
  async function buildTemplateSendOptions(endpointUrl, options) {
763
+ if (options.provider === "standard-webhooks") {
764
+ const method2 = (options.method ?? "POST").toUpperCase();
765
+ const payload = options.body ?? {};
766
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
767
+ const msgId = options.event ? `msg_${options.event}_${randomHex(8)}` : `msg_${randomHex(16)}`;
768
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
769
+ const signingInput = `${msgId}.${timestamp}.${body}`;
770
+ let rawSecret = options.secret;
771
+ const hadPrefix = rawSecret.startsWith("whsec_");
772
+ if (hadPrefix) {
773
+ rawSecret = rawSecret.slice(6);
774
+ }
775
+ let secretBytes;
776
+ try {
777
+ secretBytes = fromBase64(rawSecret);
778
+ } catch {
779
+ const raw = hadPrefix ? options.secret : rawSecret;
780
+ secretBytes = new TextEncoder().encode(raw);
781
+ }
782
+ const signature = await hmacSignRaw("SHA-256", secretBytes, signingInput);
783
+ return {
784
+ method: method2,
785
+ headers: {
786
+ "content-type": "application/json",
787
+ "webhook-id": msgId,
788
+ "webhook-timestamp": String(timestamp),
789
+ "webhook-signature": `v1,${toBase64(signature)}`,
790
+ ...options.headers ?? {}
791
+ },
792
+ body
793
+ };
794
+ }
795
+ const provider = options.provider;
735
796
  const method = (options.method ?? "POST").toUpperCase();
736
- const template = ensureTemplate(options.provider, options.template);
737
- const event = options.event ?? defaultEvent(options.provider, template);
797
+ const template = ensureTemplate(provider, options.template);
798
+ const event = options.event ?? defaultEvent(provider, template);
738
799
  const now = /* @__PURE__ */ new Date();
739
- const built = buildTemplatePayload(options.provider, template, event, now, options.body);
800
+ const built = buildTemplatePayload(provider, template, event, now, options.body);
740
801
  const headers = {
741
802
  "content-type": built.contentType,
742
- "x-webhooks-cc-template-provider": options.provider,
803
+ "x-webhooks-cc-template-provider": provider,
743
804
  "x-webhooks-cc-template-template": template,
744
805
  "x-webhooks-cc-template-event": event,
745
806
  ...built.headers
746
807
  };
747
- if (options.provider === "stripe") {
808
+ if (provider === "stripe") {
748
809
  const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
749
810
  const signature = await hmacSign("SHA-256", options.secret, `${timestamp}.${built.body}`);
750
811
  headers["stripe-signature"] = `t=${timestamp},v1=${toHex(signature)}`;
751
812
  }
752
- if (options.provider === "github") {
813
+ if (provider === "github") {
753
814
  headers["x-github-event"] = event;
754
815
  headers["x-github-delivery"] = randomUuid();
755
816
  const signature = await hmacSign("SHA-256", options.secret, built.body);
756
817
  headers["x-hub-signature-256"] = `sha256=${toHex(signature)}`;
757
818
  }
758
- if (options.provider === "shopify") {
819
+ if (provider === "shopify") {
759
820
  headers["x-shopify-topic"] = event;
760
821
  const signature = await hmacSign("SHA-256", options.secret, built.body);
761
822
  headers["x-shopify-hmac-sha256"] = toBase64(signature);
762
823
  }
763
- if (options.provider === "twilio") {
824
+ if (provider === "twilio") {
764
825
  const signaturePayload = built.twilioParams ? buildTwilioSignaturePayload(endpointUrl, built.twilioParams) : `${endpointUrl}${built.body}`;
765
826
  const signature = await hmacSign("SHA-1", options.secret, signaturePayload);
766
827
  headers["x-twilio-signature"] = toBase64(signature);
@@ -779,7 +840,7 @@ async function buildTemplateSendOptions(endpointUrl, options) {
779
840
  var DEFAULT_BASE_URL = "https://webhooks.cc";
780
841
  var DEFAULT_WEBHOOK_URL = "https://go.webhooks.cc";
781
842
  var DEFAULT_TIMEOUT = 3e4;
782
- var SDK_VERSION = "0.3.0";
843
+ var SDK_VERSION = "0.6.0";
783
844
  var MIN_POLL_INTERVAL = 10;
784
845
  var MAX_POLL_INTERVAL = 6e4;
785
846
  var ALLOWED_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]);
@@ -902,6 +963,123 @@ var WebhooksCC = class {
902
963
  return this.endpoints.send(slug, sendOptions);
903
964
  }
904
965
  };
966
+ /**
967
+ * Build a request without sending it. Returns the computed method, URL,
968
+ * headers, and body — including any provider signatures. Useful for
969
+ * debugging what sendTo would actually send.
970
+ *
971
+ * @param url - Target URL (http or https)
972
+ * @param options - Same options as sendTo
973
+ * @returns The computed request details
974
+ */
975
+ this.buildRequest = async (url, options = {}) => {
976
+ let parsed;
977
+ try {
978
+ parsed = new URL(url);
979
+ } catch {
980
+ throw new Error(`Invalid URL: "${url}" is not a valid URL`);
981
+ }
982
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
983
+ throw new Error("Invalid URL: only http and https protocols are supported");
984
+ }
985
+ if (options.provider) {
986
+ if (!options.secret || typeof options.secret !== "string") {
987
+ throw new Error("buildRequest with a provider requires a non-empty secret");
988
+ }
989
+ const sendOptions = await buildTemplateSendOptions(url, {
990
+ provider: options.provider,
991
+ template: options.template,
992
+ secret: options.secret,
993
+ event: options.event,
994
+ body: options.body,
995
+ method: options.method,
996
+ headers: options.headers,
997
+ timestamp: options.timestamp
998
+ });
999
+ return {
1000
+ url,
1001
+ method: (sendOptions.method ?? "POST").toUpperCase(),
1002
+ headers: sendOptions.headers ?? {},
1003
+ body: sendOptions.body !== void 0 ? typeof sendOptions.body === "string" ? sendOptions.body : JSON.stringify(sendOptions.body) : void 0
1004
+ };
1005
+ }
1006
+ const method = (options.method ?? "POST").toUpperCase();
1007
+ if (!ALLOWED_METHODS.has(method)) {
1008
+ throw new Error(
1009
+ `Invalid HTTP method: "${options.method}". Must be one of: ${[...ALLOWED_METHODS].join(", ")}`
1010
+ );
1011
+ }
1012
+ const headers = { ...options.headers ?? {} };
1013
+ const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
1014
+ if (options.body !== void 0 && !hasContentType) {
1015
+ headers["Content-Type"] = "application/json";
1016
+ }
1017
+ return {
1018
+ url,
1019
+ method,
1020
+ headers,
1021
+ body: options.body !== void 0 ? typeof options.body === "string" ? options.body : JSON.stringify(options.body) : void 0
1022
+ };
1023
+ };
1024
+ /**
1025
+ * Send a webhook directly to any URL with optional provider signing.
1026
+ * Use this for local integration testing — send properly signed webhooks
1027
+ * to localhost handlers without routing through webhooks.cc infrastructure.
1028
+ *
1029
+ * @param url - Target URL to send the webhook to (http or https)
1030
+ * @param options - Method, headers, body, and optional provider signing
1031
+ * @returns Raw fetch Response from the target
1032
+ */
1033
+ this.sendTo = async (url, options = {}) => {
1034
+ let parsed;
1035
+ try {
1036
+ parsed = new URL(url);
1037
+ } catch {
1038
+ throw new Error(`Invalid URL: "${url}" is not a valid URL`);
1039
+ }
1040
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1041
+ throw new Error("Invalid URL: only http and https protocols are supported");
1042
+ }
1043
+ if (options.provider) {
1044
+ if (!options.secret || typeof options.secret !== "string") {
1045
+ throw new Error("sendTo with a provider requires a non-empty secret");
1046
+ }
1047
+ const sendOptions = await buildTemplateSendOptions(url, {
1048
+ provider: options.provider,
1049
+ template: options.template,
1050
+ secret: options.secret,
1051
+ event: options.event,
1052
+ body: options.body,
1053
+ method: options.method,
1054
+ headers: options.headers,
1055
+ timestamp: options.timestamp
1056
+ });
1057
+ const method2 = (sendOptions.method ?? "POST").toUpperCase();
1058
+ return fetch(url, {
1059
+ method: method2,
1060
+ headers: sendOptions.headers ?? {},
1061
+ body: sendOptions.body !== void 0 ? typeof sendOptions.body === "string" ? sendOptions.body : JSON.stringify(sendOptions.body) : void 0,
1062
+ signal: AbortSignal.timeout(this.timeout)
1063
+ });
1064
+ }
1065
+ const method = (options.method ?? "POST").toUpperCase();
1066
+ if (!ALLOWED_METHODS.has(method)) {
1067
+ throw new Error(
1068
+ `Invalid HTTP method: "${options.method}". Must be one of: ${[...ALLOWED_METHODS].join(", ")}`
1069
+ );
1070
+ }
1071
+ const headers = { ...options.headers ?? {} };
1072
+ const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
1073
+ if (options.body !== void 0 && !hasContentType) {
1074
+ headers["Content-Type"] = "application/json";
1075
+ }
1076
+ return fetch(url, {
1077
+ method,
1078
+ headers,
1079
+ body: options.body !== void 0 ? typeof options.body === "string" ? options.body : JSON.stringify(options.body) : void 0,
1080
+ signal: AbortSignal.timeout(this.timeout)
1081
+ });
1082
+ };
905
1083
  this.requests = {
906
1084
  list: async (endpointSlug, options = {}) => {
907
1085
  validatePathSegment(endpointSlug, "endpointSlug");
@@ -1250,13 +1428,33 @@ var WebhooksCC = class {
1250
1428
  description: "Send a provider template webhook with signed headers",
1251
1429
  params: {
1252
1430
  slug: "string",
1253
- provider: '"stripe"|"github"|"shopify"|"twilio"',
1431
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"',
1254
1432
  template: "string?",
1255
1433
  secret: "string",
1256
1434
  event: "string?"
1257
1435
  }
1258
1436
  }
1259
1437
  },
1438
+ sendTo: {
1439
+ description: "Send a webhook directly to any URL with optional provider signing",
1440
+ params: {
1441
+ url: "string",
1442
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"?',
1443
+ secret: "string?",
1444
+ body: "unknown?",
1445
+ headers: "Record<string, string>?"
1446
+ }
1447
+ },
1448
+ buildRequest: {
1449
+ description: "Build a request without sending it \u2014 returns computed method, URL, headers, and body including provider signatures",
1450
+ params: {
1451
+ url: "string",
1452
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"?',
1453
+ secret: "string?",
1454
+ body: "unknown?",
1455
+ headers: "Record<string, string>?"
1456
+ }
1457
+ },
1260
1458
  requests: {
1261
1459
  list: {
1262
1460
  description: "List captured requests",
@@ -1333,6 +1531,10 @@ function isPaddleWebhook(request) {
1333
1531
  function isLinearWebhook(request) {
1334
1532
  return Object.keys(request.headers).some((k) => k.toLowerCase() === "linear-signature");
1335
1533
  }
1534
+ function isStandardWebhook(request) {
1535
+ const keys = Object.keys(request.headers).map((k) => k.toLowerCase());
1536
+ return keys.includes("webhook-id") && keys.includes("webhook-timestamp") && keys.includes("webhook-signature");
1537
+ }
1336
1538
 
1337
1539
  // src/matchers.ts
1338
1540
  function matchMethod(method) {
@@ -1392,6 +1594,7 @@ function matchAny(first, ...rest) {
1392
1594
  isPaddleWebhook,
1393
1595
  isShopifyWebhook,
1394
1596
  isSlackWebhook,
1597
+ isStandardWebhook,
1395
1598
  isStripeWebhook,
1396
1599
  isTwilioWebhook,
1397
1600
  matchAll,
package/dist/index.mjs CHANGED
@@ -673,6 +673,33 @@ function toBase64(bytes) {
673
673
  for (const byte of bytes) binary += String.fromCharCode(byte);
674
674
  return btoa(binary);
675
675
  }
676
+ function fromBase64(str) {
677
+ if (typeof atob !== "function") {
678
+ return new Uint8Array(Buffer.from(str, "base64"));
679
+ }
680
+ const binary = atob(str);
681
+ const bytes = new Uint8Array(binary.length);
682
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
683
+ return bytes;
684
+ }
685
+ async function hmacSignRaw(algorithm, keyBytes, payload) {
686
+ if (!globalThis.crypto?.subtle) {
687
+ throw new Error("crypto.subtle is required for signature generation");
688
+ }
689
+ const key = await globalThis.crypto.subtle.importKey(
690
+ "raw",
691
+ keyBytes.buffer,
692
+ { name: "HMAC", hash: algorithm },
693
+ false,
694
+ ["sign"]
695
+ );
696
+ const signature = await globalThis.crypto.subtle.sign(
697
+ "HMAC",
698
+ key,
699
+ new TextEncoder().encode(payload)
700
+ );
701
+ return new Uint8Array(signature);
702
+ }
676
703
  function buildTwilioSignaturePayload(endpointUrl, params) {
677
704
  const sortedParams = params.map(([key, value], index) => ({ key, value, index })).sort(
678
705
  (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.value < b.value ? -1 : a.value > b.value ? 1 : 0
@@ -684,35 +711,68 @@ function buildTwilioSignaturePayload(endpointUrl, params) {
684
711
  return payload;
685
712
  }
686
713
  async function buildTemplateSendOptions(endpointUrl, options) {
714
+ if (options.provider === "standard-webhooks") {
715
+ const method2 = (options.method ?? "POST").toUpperCase();
716
+ const payload = options.body ?? {};
717
+ const body = typeof payload === "string" ? payload : JSON.stringify(payload);
718
+ const msgId = options.event ? `msg_${options.event}_${randomHex(8)}` : `msg_${randomHex(16)}`;
719
+ const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
720
+ const signingInput = `${msgId}.${timestamp}.${body}`;
721
+ let rawSecret = options.secret;
722
+ const hadPrefix = rawSecret.startsWith("whsec_");
723
+ if (hadPrefix) {
724
+ rawSecret = rawSecret.slice(6);
725
+ }
726
+ let secretBytes;
727
+ try {
728
+ secretBytes = fromBase64(rawSecret);
729
+ } catch {
730
+ const raw = hadPrefix ? options.secret : rawSecret;
731
+ secretBytes = new TextEncoder().encode(raw);
732
+ }
733
+ const signature = await hmacSignRaw("SHA-256", secretBytes, signingInput);
734
+ return {
735
+ method: method2,
736
+ headers: {
737
+ "content-type": "application/json",
738
+ "webhook-id": msgId,
739
+ "webhook-timestamp": String(timestamp),
740
+ "webhook-signature": `v1,${toBase64(signature)}`,
741
+ ...options.headers ?? {}
742
+ },
743
+ body
744
+ };
745
+ }
746
+ const provider = options.provider;
687
747
  const method = (options.method ?? "POST").toUpperCase();
688
- const template = ensureTemplate(options.provider, options.template);
689
- const event = options.event ?? defaultEvent(options.provider, template);
748
+ const template = ensureTemplate(provider, options.template);
749
+ const event = options.event ?? defaultEvent(provider, template);
690
750
  const now = /* @__PURE__ */ new Date();
691
- const built = buildTemplatePayload(options.provider, template, event, now, options.body);
751
+ const built = buildTemplatePayload(provider, template, event, now, options.body);
692
752
  const headers = {
693
753
  "content-type": built.contentType,
694
- "x-webhooks-cc-template-provider": options.provider,
754
+ "x-webhooks-cc-template-provider": provider,
695
755
  "x-webhooks-cc-template-template": template,
696
756
  "x-webhooks-cc-template-event": event,
697
757
  ...built.headers
698
758
  };
699
- if (options.provider === "stripe") {
759
+ if (provider === "stripe") {
700
760
  const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
701
761
  const signature = await hmacSign("SHA-256", options.secret, `${timestamp}.${built.body}`);
702
762
  headers["stripe-signature"] = `t=${timestamp},v1=${toHex(signature)}`;
703
763
  }
704
- if (options.provider === "github") {
764
+ if (provider === "github") {
705
765
  headers["x-github-event"] = event;
706
766
  headers["x-github-delivery"] = randomUuid();
707
767
  const signature = await hmacSign("SHA-256", options.secret, built.body);
708
768
  headers["x-hub-signature-256"] = `sha256=${toHex(signature)}`;
709
769
  }
710
- if (options.provider === "shopify") {
770
+ if (provider === "shopify") {
711
771
  headers["x-shopify-topic"] = event;
712
772
  const signature = await hmacSign("SHA-256", options.secret, built.body);
713
773
  headers["x-shopify-hmac-sha256"] = toBase64(signature);
714
774
  }
715
- if (options.provider === "twilio") {
775
+ if (provider === "twilio") {
716
776
  const signaturePayload = built.twilioParams ? buildTwilioSignaturePayload(endpointUrl, built.twilioParams) : `${endpointUrl}${built.body}`;
717
777
  const signature = await hmacSign("SHA-1", options.secret, signaturePayload);
718
778
  headers["x-twilio-signature"] = toBase64(signature);
@@ -731,7 +791,7 @@ async function buildTemplateSendOptions(endpointUrl, options) {
731
791
  var DEFAULT_BASE_URL = "https://webhooks.cc";
732
792
  var DEFAULT_WEBHOOK_URL = "https://go.webhooks.cc";
733
793
  var DEFAULT_TIMEOUT = 3e4;
734
- var SDK_VERSION = "0.3.0";
794
+ var SDK_VERSION = "0.6.0";
735
795
  var MIN_POLL_INTERVAL = 10;
736
796
  var MAX_POLL_INTERVAL = 6e4;
737
797
  var ALLOWED_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]);
@@ -854,6 +914,123 @@ var WebhooksCC = class {
854
914
  return this.endpoints.send(slug, sendOptions);
855
915
  }
856
916
  };
917
+ /**
918
+ * Build a request without sending it. Returns the computed method, URL,
919
+ * headers, and body — including any provider signatures. Useful for
920
+ * debugging what sendTo would actually send.
921
+ *
922
+ * @param url - Target URL (http or https)
923
+ * @param options - Same options as sendTo
924
+ * @returns The computed request details
925
+ */
926
+ this.buildRequest = async (url, options = {}) => {
927
+ let parsed;
928
+ try {
929
+ parsed = new URL(url);
930
+ } catch {
931
+ throw new Error(`Invalid URL: "${url}" is not a valid URL`);
932
+ }
933
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
934
+ throw new Error("Invalid URL: only http and https protocols are supported");
935
+ }
936
+ if (options.provider) {
937
+ if (!options.secret || typeof options.secret !== "string") {
938
+ throw new Error("buildRequest with a provider requires a non-empty secret");
939
+ }
940
+ const sendOptions = await buildTemplateSendOptions(url, {
941
+ provider: options.provider,
942
+ template: options.template,
943
+ secret: options.secret,
944
+ event: options.event,
945
+ body: options.body,
946
+ method: options.method,
947
+ headers: options.headers,
948
+ timestamp: options.timestamp
949
+ });
950
+ return {
951
+ url,
952
+ method: (sendOptions.method ?? "POST").toUpperCase(),
953
+ headers: sendOptions.headers ?? {},
954
+ body: sendOptions.body !== void 0 ? typeof sendOptions.body === "string" ? sendOptions.body : JSON.stringify(sendOptions.body) : void 0
955
+ };
956
+ }
957
+ const method = (options.method ?? "POST").toUpperCase();
958
+ if (!ALLOWED_METHODS.has(method)) {
959
+ throw new Error(
960
+ `Invalid HTTP method: "${options.method}". Must be one of: ${[...ALLOWED_METHODS].join(", ")}`
961
+ );
962
+ }
963
+ const headers = { ...options.headers ?? {} };
964
+ const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
965
+ if (options.body !== void 0 && !hasContentType) {
966
+ headers["Content-Type"] = "application/json";
967
+ }
968
+ return {
969
+ url,
970
+ method,
971
+ headers,
972
+ body: options.body !== void 0 ? typeof options.body === "string" ? options.body : JSON.stringify(options.body) : void 0
973
+ };
974
+ };
975
+ /**
976
+ * Send a webhook directly to any URL with optional provider signing.
977
+ * Use this for local integration testing — send properly signed webhooks
978
+ * to localhost handlers without routing through webhooks.cc infrastructure.
979
+ *
980
+ * @param url - Target URL to send the webhook to (http or https)
981
+ * @param options - Method, headers, body, and optional provider signing
982
+ * @returns Raw fetch Response from the target
983
+ */
984
+ this.sendTo = async (url, options = {}) => {
985
+ let parsed;
986
+ try {
987
+ parsed = new URL(url);
988
+ } catch {
989
+ throw new Error(`Invalid URL: "${url}" is not a valid URL`);
990
+ }
991
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
992
+ throw new Error("Invalid URL: only http and https protocols are supported");
993
+ }
994
+ if (options.provider) {
995
+ if (!options.secret || typeof options.secret !== "string") {
996
+ throw new Error("sendTo with a provider requires a non-empty secret");
997
+ }
998
+ const sendOptions = await buildTemplateSendOptions(url, {
999
+ provider: options.provider,
1000
+ template: options.template,
1001
+ secret: options.secret,
1002
+ event: options.event,
1003
+ body: options.body,
1004
+ method: options.method,
1005
+ headers: options.headers,
1006
+ timestamp: options.timestamp
1007
+ });
1008
+ const method2 = (sendOptions.method ?? "POST").toUpperCase();
1009
+ return fetch(url, {
1010
+ method: method2,
1011
+ headers: sendOptions.headers ?? {},
1012
+ body: sendOptions.body !== void 0 ? typeof sendOptions.body === "string" ? sendOptions.body : JSON.stringify(sendOptions.body) : void 0,
1013
+ signal: AbortSignal.timeout(this.timeout)
1014
+ });
1015
+ }
1016
+ const method = (options.method ?? "POST").toUpperCase();
1017
+ if (!ALLOWED_METHODS.has(method)) {
1018
+ throw new Error(
1019
+ `Invalid HTTP method: "${options.method}". Must be one of: ${[...ALLOWED_METHODS].join(", ")}`
1020
+ );
1021
+ }
1022
+ const headers = { ...options.headers ?? {} };
1023
+ const hasContentType = Object.keys(headers).some((k) => k.toLowerCase() === "content-type");
1024
+ if (options.body !== void 0 && !hasContentType) {
1025
+ headers["Content-Type"] = "application/json";
1026
+ }
1027
+ return fetch(url, {
1028
+ method,
1029
+ headers,
1030
+ body: options.body !== void 0 ? typeof options.body === "string" ? options.body : JSON.stringify(options.body) : void 0,
1031
+ signal: AbortSignal.timeout(this.timeout)
1032
+ });
1033
+ };
857
1034
  this.requests = {
858
1035
  list: async (endpointSlug, options = {}) => {
859
1036
  validatePathSegment(endpointSlug, "endpointSlug");
@@ -1202,13 +1379,33 @@ var WebhooksCC = class {
1202
1379
  description: "Send a provider template webhook with signed headers",
1203
1380
  params: {
1204
1381
  slug: "string",
1205
- provider: '"stripe"|"github"|"shopify"|"twilio"',
1382
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"',
1206
1383
  template: "string?",
1207
1384
  secret: "string",
1208
1385
  event: "string?"
1209
1386
  }
1210
1387
  }
1211
1388
  },
1389
+ sendTo: {
1390
+ description: "Send a webhook directly to any URL with optional provider signing",
1391
+ params: {
1392
+ url: "string",
1393
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"?',
1394
+ secret: "string?",
1395
+ body: "unknown?",
1396
+ headers: "Record<string, string>?"
1397
+ }
1398
+ },
1399
+ buildRequest: {
1400
+ description: "Build a request without sending it \u2014 returns computed method, URL, headers, and body including provider signatures",
1401
+ params: {
1402
+ url: "string",
1403
+ provider: '"stripe"|"github"|"shopify"|"twilio"|"standard-webhooks"?',
1404
+ secret: "string?",
1405
+ body: "unknown?",
1406
+ headers: "Record<string, string>?"
1407
+ }
1408
+ },
1212
1409
  requests: {
1213
1410
  list: {
1214
1411
  description: "List captured requests",
@@ -1285,6 +1482,10 @@ function isPaddleWebhook(request) {
1285
1482
  function isLinearWebhook(request) {
1286
1483
  return Object.keys(request.headers).some((k) => k.toLowerCase() === "linear-signature");
1287
1484
  }
1485
+ function isStandardWebhook(request) {
1486
+ const keys = Object.keys(request.headers).map((k) => k.toLowerCase());
1487
+ return keys.includes("webhook-id") && keys.includes("webhook-timestamp") && keys.includes("webhook-signature");
1488
+ }
1288
1489
 
1289
1490
  // src/matchers.ts
1290
1491
  function matchMethod(method) {
@@ -1343,6 +1544,7 @@ export {
1343
1544
  isPaddleWebhook,
1344
1545
  isShopifyWebhook,
1345
1546
  isSlackWebhook,
1547
+ isStandardWebhook,
1346
1548
  isStripeWebhook,
1347
1549
  isTwilioWebhook,
1348
1550
  matchAll,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webhooks-cc/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
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",