@hookflo/tern 2.0.0 → 2.0.2-experimental.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 CHANGED
@@ -3,6 +3,10 @@
3
3
  A robust, algorithm-agnostic webhook verification framework that supports multiple platforms with accurate signature verification and payload retrieval.
4
4
  The same framework that secures webhook verification at [Hookflo](https://hookflo.com).
5
5
 
6
+ ⭐ Star this repo to support the project and help others discover it!
7
+
8
+ 💬 Join the discussion & contribute in our Discord: [Hookflo Community](https://discord.com/invite/SNmCjU97nr)
9
+
6
10
  ```bash
7
11
  npm install @hookflo/tern
8
12
  ```
@@ -24,6 +28,12 @@ Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
24
28
  - **Flexible Configuration**: Custom signature configurations for any webhook format
25
29
  - **Type Safe**: Full TypeScript support with comprehensive type definitions
26
30
  - **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more
31
+ - **Body-Parser Safe Adapters**: Read raw request bodies correctly to avoid signature mismatch issues
32
+ - **Multi-Provider Verification**: Verify and auto-detect across multiple providers with one API
33
+ - **Payload Normalization**: Opt-in normalized event shape to reduce provider lock-in
34
+ - **Category-aware Migration**: Normalize within provider categories (payment/auth/infrastructure) for safe platform switching
35
+ - **Strong Typed Normalized Schemas**: Category types like `PaymentWebhookNormalized` and `AuthWebhookNormalized` for safe migrations
36
+ - **Foundational Error Taxonomy**: Stable `errorCode` values (`INVALID_SIGNATURE`, `MISSING_SIGNATURE`, etc.)
27
37
 
28
38
  ## Why Tern?
29
39
 
@@ -65,6 +75,78 @@ if (result.isValid) {
65
75
  }
66
76
  ```
67
77
 
78
+ ### Universal Verification (auto-detect platform)
79
+
80
+ ```typescript
81
+ import { WebhookVerificationService } from '@hookflo/tern';
82
+
83
+ const result = await WebhookVerificationService.verifyAny(request, {
84
+ stripe: process.env.STRIPE_WEBHOOK_SECRET,
85
+ github: process.env.GITHUB_WEBHOOK_SECRET,
86
+ clerk: process.env.CLERK_WEBHOOK_SECRET,
87
+ });
88
+
89
+ if (result.isValid) {
90
+ console.log(`Verified ${result.platform} webhook`);
91
+ }
92
+ ```
93
+
94
+ ### Category-aware Payload Normalization
95
+
96
+ ### Strongly-Typed Normalized Payloads
97
+
98
+ ```typescript
99
+ import {
100
+ WebhookVerificationService,
101
+ PaymentWebhookNormalized,
102
+ } from '@hookflo/tern';
103
+
104
+ const result = await WebhookVerificationService.verifyWithPlatformConfig<PaymentWebhookNormalized>(
105
+ request,
106
+ 'stripe',
107
+ process.env.STRIPE_WEBHOOK_SECRET!,
108
+ 300,
109
+ { enabled: true, category: 'payment' },
110
+ );
111
+
112
+ if (result.isValid && result.payload?.event === 'payment.succeeded') {
113
+ // result.payload is strongly typed
114
+ console.log(result.payload.amount, result.payload.customer_id);
115
+ }
116
+ ```
117
+
118
+ ```typescript
119
+ import { WebhookVerificationService, getPlatformsByCategory } from '@hookflo/tern';
120
+
121
+ // Discover migration-compatible providers in the same category
122
+ const paymentPlatforms = getPlatformsByCategory('payment');
123
+ // ['stripe', 'polar', ...]
124
+
125
+ const result = await WebhookVerificationService.verifyWithPlatformConfig(
126
+ request,
127
+ 'stripe',
128
+ process.env.STRIPE_WEBHOOK_SECRET!,
129
+ 300,
130
+ {
131
+ enabled: true,
132
+ category: 'payment',
133
+ includeRaw: true,
134
+ },
135
+ );
136
+
137
+ console.log(result.payload);
138
+ // {
139
+ // event: 'payment.succeeded',
140
+ // amount: 5000,
141
+ // currency: 'USD',
142
+ // customer_id: 'cus_123',
143
+ // transaction_id: 'pi_123',
144
+ // provider: 'stripe',
145
+ // category: 'payment',
146
+ // _raw: {...}
147
+ // }
148
+ ```
149
+
68
150
  ### Platform-Specific Usage
69
151
 
70
152
  ```typescript
@@ -132,6 +214,7 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
132
214
  - **Vercel**: HMAC-SHA256
133
215
  - **Polar**: HMAC-SHA256
134
216
  - **Supabase**: Token-based authentication
217
+ - **GitLab**: Token-based authentication
135
218
 
136
219
  ## Custom Platform Configuration
137
220
 
@@ -231,80 +314,59 @@ const timestampedConfig = {
231
314
 
232
315
  ## Framework Integration
233
316
 
234
- ### Express.js
317
+ ### Express.js middleware (body-parser safe)
235
318
 
236
319
  ```typescript
237
- app.post('/webhooks/stripe', async (req, res) => {
238
- const result = await WebhookVerificationService.verifyWithPlatformConfig(
239
- req,
240
- 'stripe',
241
- process.env.STRIPE_WEBHOOK_SECRET
242
- );
243
-
244
- if (!result.isValid) {
245
- return res.status(400).json({ error: result.error });
246
- }
247
-
248
- // Process the webhook
249
- console.log('Stripe event:', result.payload.type);
250
- res.json({ received: true });
251
- });
320
+ import express from 'express';
321
+ import { createWebhookMiddleware } from '@hookflo/tern/express';
322
+
323
+ const app = express();
324
+
325
+ app.post(
326
+ '/webhooks/stripe',
327
+ createWebhookMiddleware({
328
+ platform: 'stripe',
329
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
330
+ normalize: true,
331
+ }),
332
+ (req, res) => {
333
+ const event = (req as any).webhook.payload;
334
+ res.json({ received: true, event: event.event });
335
+ },
336
+ );
252
337
  ```
253
338
 
254
- ### Next.js API Route
339
+ ### Next.js App Router
255
340
 
256
341
  ```typescript
257
- // pages/api/webhooks/github.js
258
- export default async function handler(req, res) {
259
- if (req.method !== 'POST') {
260
- return res.status(405).json({ error: 'Method not allowed' });
261
- }
262
-
263
- const result = await WebhookVerificationService.verifyWithPlatformConfig(
264
- req,
265
- 'github',
266
- process.env.GITHUB_WEBHOOK_SECRET
267
- );
268
-
269
- if (!result.isValid) {
270
- return res.status(400).json({ error: result.error });
271
- }
342
+ import { createWebhookHandler } from '@hookflo/tern/nextjs';
272
343
 
273
- // Handle GitHub webhook
274
- const event = req.headers['x-github-event'];
275
- console.log('GitHub event:', event);
276
-
277
- res.json({ received: true });
278
- }
344
+ export const POST = createWebhookHandler({
345
+ platform: 'github',
346
+ secret: process.env.GITHUB_WEBHOOK_SECRET!,
347
+ handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
348
+ });
279
349
  ```
280
350
 
281
351
  ### Cloudflare Workers
282
352
 
283
353
  ```typescript
284
- addEventListener('fetch', event => {
285
- event.respondWith(handleRequest(event.request));
354
+ import { createWebhookHandler } from '@hookflo/tern/cloudflare';
355
+
356
+ const handleStripe = createWebhookHandler({
357
+ platform: 'stripe',
358
+ secretEnv: 'STRIPE_WEBHOOK_SECRET',
359
+ handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
286
360
  });
287
361
 
288
- async function handleRequest(request) {
289
- if (request.url.includes('/webhooks/clerk')) {
290
- const result = await WebhookVerificationService.verifyWithPlatformConfig(
291
- request,
292
- 'clerk',
293
- CLERK_WEBHOOK_SECRET
294
- );
295
-
296
- if (!result.isValid) {
297
- return new Response(JSON.stringify({ error: result.error }), {
298
- status: 400,
299
- headers: { 'Content-Type': 'application/json' }
300
- });
362
+ export default {
363
+ async fetch(request: Request, env: Record<string, string>) {
364
+ if (new URL(request.url).pathname === '/webhooks/stripe') {
365
+ return handleStripe(request, env);
301
366
  }
302
-
303
- // Process Clerk webhook
304
- console.log('Clerk event:', result.payload.type);
305
- return new Response(JSON.stringify({ received: true }));
306
- }
307
- }
367
+ return new Response('Not Found', { status: 404 });
368
+ },
369
+ };
308
370
  ```
309
371
 
310
372
  ## API Reference
@@ -315,14 +377,22 @@ async function handleRequest(request) {
315
377
 
316
378
  Verifies a webhook using the provided configuration.
317
379
 
318
- #### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number): Promise<WebhookVerificationResult>`
380
+ #### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult>`
381
+
382
+ Simplified verification using platform-specific configurations with optional payload normalization.
319
383
 
320
- Simplified verification using platform-specific configurations.
384
+ #### `verifyAny(request: Request, secrets: Record<string, string>, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult>`
385
+
386
+ Auto-detects platform from headers and verifies against one or more provider secrets.
321
387
 
322
388
  #### `verifyTokenBased(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult>`
323
389
 
324
390
  Verifies token-based webhooks (like Supabase).
325
391
 
392
+ #### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]`
393
+
394
+ Returns built-in providers that normalize into a shared schema for the given migration category.
395
+
326
396
  ### Types
327
397
 
328
398
  #### `WebhookVerificationResult`
@@ -331,6 +401,7 @@ Verifies token-based webhooks (like Supabase).
331
401
  interface WebhookVerificationResult {
332
402
  isValid: boolean;
333
403
  error?: string;
404
+ errorCode?: WebhookErrorCode;
334
405
  platform: WebhookPlatform;
335
406
  payload?: any;
336
407
  metadata?: {
@@ -349,6 +420,7 @@ interface WebhookConfig {
349
420
  secret: string;
350
421
  toleranceInSeconds?: number;
351
422
  signatureConfig?: SignatureConfig;
423
+ normalize?: boolean | NormalizeOptions;
352
424
  }
353
425
  ```
354
426
 
@@ -422,4 +494,5 @@ MIT License - see [LICENSE](./LICENSE) for details.
422
494
 
423
495
  - [Documentation](./USAGE.md)
424
496
  - [Framework Summary](./FRAMEWORK_SUMMARY.md)
497
+ - [Architecture Guide](./ARCHITECTURE.md)
425
498
  - [Issues](https://github.com/Hookflo/tern/issues)
@@ -0,0 +1,11 @@
1
+ import { WebhookPlatform, NormalizeOptions } from '../types';
2
+ export interface CloudflareWebhookHandlerOptions<TEnv = Record<string, unknown>, TResponse = unknown> {
3
+ platform: WebhookPlatform;
4
+ secret?: string;
5
+ secretEnv?: string;
6
+ toleranceInSeconds?: number;
7
+ normalize?: boolean | NormalizeOptions;
8
+ onError?: (error: Error) => void;
9
+ handler: (payload: any, env: TEnv, metadata: Record<string, any>) => Promise<TResponse> | TResponse;
10
+ }
11
+ export declare function createWebhookHandler<TEnv = Record<string, unknown>, TResponse = unknown>(options: CloudflareWebhookHandlerOptions<TEnv, TResponse>): (request: Request, env: TEnv) => Promise<Response>;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookHandler = createWebhookHandler;
4
+ const index_1 = require("../index");
5
+ function createWebhookHandler(options) {
6
+ return async (request, env) => {
7
+ try {
8
+ const secret = options.secret
9
+ || (options.secretEnv ? env[options.secretEnv] : undefined);
10
+ if (!secret) {
11
+ return Response.json({ error: 'Webhook secret is not configured' }, { status: 500 });
12
+ }
13
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, secret, options.toleranceInSeconds, options.normalize);
14
+ if (!result.isValid) {
15
+ return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
16
+ }
17
+ const data = await options.handler(result.payload, env, result.metadata || {});
18
+ return Response.json(data);
19
+ }
20
+ catch (error) {
21
+ options.onError?.(error);
22
+ return Response.json({ error: error.message }, { status: 500 });
23
+ }
24
+ };
25
+ }
@@ -0,0 +1,18 @@
1
+ import { WebhookPlatform, WebhookVerificationResult, NormalizeOptions } from '../types';
2
+ import { MinimalNodeRequest } from './shared';
3
+ export interface ExpressLikeResponse {
4
+ status: (code: number) => ExpressLikeResponse;
5
+ json: (payload: unknown) => unknown;
6
+ }
7
+ export interface ExpressLikeRequest extends MinimalNodeRequest {
8
+ webhook?: WebhookVerificationResult;
9
+ }
10
+ export type ExpressLikeNext = () => void;
11
+ export interface ExpressWebhookMiddlewareOptions {
12
+ platform: WebhookPlatform;
13
+ secret: string;
14
+ toleranceInSeconds?: number;
15
+ normalize?: boolean | NormalizeOptions;
16
+ onError?: (error: Error) => void;
17
+ }
18
+ export declare function createWebhookMiddleware(options: ExpressWebhookMiddlewareOptions): (req: ExpressLikeRequest, res: ExpressLikeResponse, next: ExpressLikeNext) => Promise<void>;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookMiddleware = createWebhookMiddleware;
4
+ const index_1 = require("../index");
5
+ const shared_1 = require("./shared");
6
+ function createWebhookMiddleware(options) {
7
+ return async (req, res, next) => {
8
+ try {
9
+ const webRequest = await (0, shared_1.toWebRequest)(req);
10
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(webRequest, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
11
+ if (!result.isValid) {
12
+ res.status(400).json({ error: result.error, platform: result.platform });
13
+ return;
14
+ }
15
+ req.webhook = result;
16
+ next();
17
+ }
18
+ catch (error) {
19
+ options.onError?.(error);
20
+ res.status(500).json({ error: error.message });
21
+ }
22
+ };
23
+ }
@@ -0,0 +1,4 @@
1
+ export { createWebhookMiddleware, ExpressWebhookMiddlewareOptions, ExpressLikeRequest, ExpressLikeResponse, } from './express';
2
+ export { createWebhookHandler as createNextjsWebhookHandler, NextWebhookHandlerOptions, } from './nextjs';
3
+ export { createWebhookHandler as createCloudflareWebhookHandler, CloudflareWebhookHandlerOptions, } from './cloudflare';
4
+ export { toWebRequest, extractRawBody } from './shared';
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractRawBody = exports.toWebRequest = exports.createCloudflareWebhookHandler = exports.createNextjsWebhookHandler = exports.createWebhookMiddleware = void 0;
4
+ var express_1 = require("./express");
5
+ Object.defineProperty(exports, "createWebhookMiddleware", { enumerable: true, get: function () { return express_1.createWebhookMiddleware; } });
6
+ var nextjs_1 = require("./nextjs");
7
+ Object.defineProperty(exports, "createNextjsWebhookHandler", { enumerable: true, get: function () { return nextjs_1.createWebhookHandler; } });
8
+ var cloudflare_1 = require("./cloudflare");
9
+ Object.defineProperty(exports, "createCloudflareWebhookHandler", { enumerable: true, get: function () { return cloudflare_1.createWebhookHandler; } });
10
+ var shared_1 = require("./shared");
11
+ Object.defineProperty(exports, "toWebRequest", { enumerable: true, get: function () { return shared_1.toWebRequest; } });
12
+ Object.defineProperty(exports, "extractRawBody", { enumerable: true, get: function () { return shared_1.extractRawBody; } });
@@ -0,0 +1,10 @@
1
+ import { WebhookPlatform, NormalizeOptions } from '../types';
2
+ export interface NextWebhookHandlerOptions<TResponse = unknown> {
3
+ platform: WebhookPlatform;
4
+ secret: string;
5
+ toleranceInSeconds?: number;
6
+ normalize?: boolean | NormalizeOptions;
7
+ onError?: (error: Error) => void;
8
+ handler: (payload: any, metadata: Record<string, any>) => Promise<TResponse> | TResponse;
9
+ }
10
+ export declare function createWebhookHandler<TResponse = unknown>(options: NextWebhookHandlerOptions<TResponse>): (request: Request) => Promise<Response>;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookHandler = createWebhookHandler;
4
+ const index_1 = require("../index");
5
+ function createWebhookHandler(options) {
6
+ return async (request) => {
7
+ try {
8
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
9
+ if (!result.isValid) {
10
+ return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
11
+ }
12
+ const data = await options.handler(result.payload, result.metadata || {});
13
+ return Response.json(data);
14
+ }
15
+ catch (error) {
16
+ options.onError?.(error);
17
+ return Response.json({ error: error.message }, { status: 500 });
18
+ }
19
+ };
20
+ }
@@ -0,0 +1,13 @@
1
+ export interface MinimalNodeRequest {
2
+ method?: string;
3
+ headers: Record<string, string | string[] | undefined>;
4
+ body?: unknown;
5
+ protocol?: string;
6
+ get?: (name: string) => string | undefined;
7
+ originalUrl?: string;
8
+ url?: string;
9
+ on?: (event: string, cb: (chunk?: any) => void) => void;
10
+ }
11
+ export declare function extractRawBody(request: MinimalNodeRequest): Promise<string>;
12
+ export declare function toHeadersInit(headers: Record<string, string | string[] | undefined>): HeadersInit;
13
+ export declare function toWebRequest(request: MinimalNodeRequest): Promise<Request>;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.extractRawBody = extractRawBody;
4
+ exports.toHeadersInit = toHeadersInit;
5
+ exports.toWebRequest = toWebRequest;
6
+ function getHeaderValue(headers, name) {
7
+ const value = headers[name.toLowerCase()] ?? headers[name];
8
+ if (Array.isArray(value)) {
9
+ return value.join(',');
10
+ }
11
+ return value;
12
+ }
13
+ async function readIncomingMessageBody(request) {
14
+ if (!request.on) {
15
+ return '';
16
+ }
17
+ const chunks = [];
18
+ return new Promise((resolve, reject) => {
19
+ request.on?.('data', (chunk) => {
20
+ if (typeof chunk === 'string') {
21
+ chunks.push(chunk);
22
+ return;
23
+ }
24
+ chunks.push(Buffer.from(chunk ?? '').toString('utf8'));
25
+ });
26
+ request.on?.('end', () => resolve(chunks.join('')));
27
+ request.on?.('error', reject);
28
+ });
29
+ }
30
+ async function extractRawBody(request) {
31
+ const body = request.body;
32
+ if (typeof body === 'string') {
33
+ return body;
34
+ }
35
+ if (Buffer.isBuffer(body)) {
36
+ return body.toString('utf8');
37
+ }
38
+ if (body && typeof body === 'object') {
39
+ return JSON.stringify(body);
40
+ }
41
+ return readIncomingMessageBody(request);
42
+ }
43
+ function toHeadersInit(headers) {
44
+ const normalized = new Headers();
45
+ for (const [key, value] of Object.entries(headers)) {
46
+ if (!value) {
47
+ continue;
48
+ }
49
+ if (Array.isArray(value)) {
50
+ normalized.set(key, value.join(','));
51
+ continue;
52
+ }
53
+ normalized.set(key, value);
54
+ }
55
+ return normalized;
56
+ }
57
+ async function toWebRequest(request) {
58
+ const protocol = request.protocol || 'https';
59
+ const host = request.get?.('host') || getHeaderValue(request.headers, 'host') || 'localhost';
60
+ const path = request.originalUrl || request.url || '/';
61
+ const rawBody = await extractRawBody(request);
62
+ return new Request(`${protocol}://${host}${path}`, {
63
+ method: request.method || 'POST',
64
+ headers: toHeadersInit(request.headers),
65
+ body: rawBody,
66
+ });
67
+ }
@@ -0,0 +1,2 @@
1
+ export { createWebhookHandler } from './adapters/cloudflare';
2
+ export type { CloudflareWebhookHandlerOptions } from './adapters/cloudflare';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookHandler = void 0;
4
+ var cloudflare_1 = require("./adapters/cloudflare");
5
+ Object.defineProperty(exports, "createWebhookHandler", { enumerable: true, get: function () { return cloudflare_1.createWebhookHandler; } });
@@ -0,0 +1,2 @@
1
+ export { createWebhookMiddleware } from './adapters/express';
2
+ export type { ExpressWebhookMiddlewareOptions, ExpressLikeRequest, ExpressLikeResponse, } from './adapters/express';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookMiddleware = void 0;
4
+ var express_1 = require("./adapters/express");
5
+ Object.defineProperty(exports, "createWebhookMiddleware", { enumerable: true, get: function () { return express_1.createWebhookMiddleware; } });
package/dist/index.d.ts CHANGED
@@ -1,17 +1,21 @@
1
- import { WebhookConfig, WebhookVerificationResult, WebhookPlatform, SignatureConfig } from './types';
1
+ import { WebhookConfig, WebhookVerificationResult, WebhookPlatform, SignatureConfig, MultiPlatformSecrets, NormalizeOptions } from './types';
2
2
  export declare class WebhookVerificationService {
3
- static verify(request: Request, config: WebhookConfig): Promise<WebhookVerificationResult>;
3
+ static verify<TPayload = unknown>(request: Request, config: WebhookConfig): Promise<WebhookVerificationResult<TPayload>>;
4
4
  private static getVerifier;
5
5
  private static createAlgorithmBasedVerifier;
6
6
  private static getLegacyVerifier;
7
- static verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number): Promise<WebhookVerificationResult>;
7
+ static verifyWithPlatformConfig<TPayload = unknown>(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
8
+ static verifyAny<TPayload = unknown>(request: Request, secrets: MultiPlatformSecrets, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult<TPayload>>;
9
+ static detectPlatform(request: Request): WebhookPlatform;
8
10
  static getPlatformsUsingAlgorithm(algorithm: string): WebhookPlatform[];
9
11
  static platformUsesAlgorithm(platform: WebhookPlatform, algorithm: string): boolean;
10
12
  static validateSignatureConfig(config: SignatureConfig): boolean;
11
- static verifyTokenBased(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult>;
13
+ static verifyTokenBased<TPayload = unknown>(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult<TPayload>>;
12
14
  }
13
15
  export * from './types';
14
16
  export { getPlatformAlgorithmConfig, platformUsesAlgorithm, getPlatformsUsingAlgorithm, validateSignatureConfig, } from './platforms/algorithms';
15
17
  export { createAlgorithmVerifier } from './verifiers/algorithms';
16
18
  export { createCustomVerifier } from './verifiers/custom-algorithms';
19
+ export { normalizePayload, getPlatformNormalizationCategory, getPlatformsByCategory, } from './normalization/simple';
20
+ export * from './adapters';
17
21
  export default WebhookVerificationService;
package/dist/index.js CHANGED
@@ -14,22 +14,25 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.createCustomVerifier = exports.createAlgorithmVerifier = exports.validateSignatureConfig = exports.getPlatformsUsingAlgorithm = exports.platformUsesAlgorithm = exports.getPlatformAlgorithmConfig = exports.WebhookVerificationService = void 0;
17
+ exports.getPlatformsByCategory = exports.getPlatformNormalizationCategory = exports.normalizePayload = exports.createCustomVerifier = exports.createAlgorithmVerifier = exports.validateSignatureConfig = exports.getPlatformsUsingAlgorithm = exports.platformUsesAlgorithm = exports.getPlatformAlgorithmConfig = exports.WebhookVerificationService = void 0;
18
18
  const algorithms_1 = require("./verifiers/algorithms");
19
19
  const custom_algorithms_1 = require("./verifiers/custom-algorithms");
20
20
  const algorithms_2 = require("./platforms/algorithms");
21
+ const simple_1 = require("./normalization/simple");
21
22
  class WebhookVerificationService {
22
23
  static async verify(request, config) {
23
24
  const verifier = this.getVerifier(config);
24
- const result = await verifier.verify(request);
25
+ const result = await verifier.verify(request.clone());
25
26
  // Ensure the platform is set correctly in the result
26
27
  if (result.isValid) {
27
28
  result.platform = config.platform;
29
+ if (config.normalize) {
30
+ result.payload = (0, simple_1.normalizePayload)(config.platform, result.payload, config.normalize);
31
+ }
28
32
  }
29
33
  return result;
30
34
  }
31
35
  static getVerifier(config) {
32
- const platform = config.platform.toLowerCase();
33
36
  // If a custom signature config is provided, use the new algorithm-based framework
34
37
  if (config.signatureConfig) {
35
38
  return this.createAlgorithmBasedVerifier(config);
@@ -50,9 +53,8 @@ class WebhookVerificationService {
50
53
  return (0, algorithms_1.createAlgorithmVerifier)(secret, signatureConfig, config.platform, toleranceInSeconds);
51
54
  }
52
55
  static getLegacyVerifier(config) {
53
- const platform = config.platform.toLowerCase();
54
56
  // For legacy support, we'll use the algorithm-based approach
55
- const platformConfig = (0, algorithms_2.getPlatformAlgorithmConfig)(platform);
57
+ const platformConfig = (0, algorithms_2.getPlatformAlgorithmConfig)(config.platform);
56
58
  const configWithSignature = {
57
59
  ...config,
58
60
  signatureConfig: platformConfig.signatureConfig,
@@ -60,30 +62,72 @@ class WebhookVerificationService {
60
62
  return this.createAlgorithmBasedVerifier(configWithSignature);
61
63
  }
62
64
  // New method to create verifier using platform algorithm config
63
- static async verifyWithPlatformConfig(request, platform, secret, toleranceInSeconds = 300) {
65
+ static async verifyWithPlatformConfig(request, platform, secret, toleranceInSeconds = 300, normalize = false) {
64
66
  const platformConfig = (0, algorithms_2.getPlatformAlgorithmConfig)(platform);
65
67
  const config = {
66
68
  platform,
67
69
  secret,
68
70
  toleranceInSeconds,
69
71
  signatureConfig: platformConfig.signatureConfig,
72
+ normalize,
70
73
  };
71
- return await this.verify(request, config);
74
+ return this.verify(request, config);
75
+ }
76
+ static async verifyAny(request, secrets, toleranceInSeconds = 300, normalize = false) {
77
+ const requestClone = request.clone();
78
+ const detectedPlatform = this.detectPlatform(requestClone);
79
+ if (detectedPlatform !== 'unknown' && secrets[detectedPlatform]) {
80
+ return this.verifyWithPlatformConfig(requestClone, detectedPlatform, secrets[detectedPlatform], toleranceInSeconds, normalize);
81
+ }
82
+ for (const [platform, secret] of Object.entries(secrets)) {
83
+ if (!secret) {
84
+ continue;
85
+ }
86
+ const result = await this.verifyWithPlatformConfig(requestClone, platform.toLowerCase(), secret, toleranceInSeconds, normalize);
87
+ if (result.isValid) {
88
+ return result;
89
+ }
90
+ }
91
+ return {
92
+ isValid: false,
93
+ error: 'Unable to verify webhook with provided platform secrets',
94
+ errorCode: 'VERIFICATION_ERROR',
95
+ platform: detectedPlatform,
96
+ };
97
+ }
98
+ static detectPlatform(request) {
99
+ const headers = request.headers;
100
+ if (headers.has('stripe-signature'))
101
+ return 'stripe';
102
+ if (headers.has('x-hub-signature-256'))
103
+ return 'github';
104
+ if (headers.has('svix-signature'))
105
+ return 'clerk';
106
+ if (headers.has('webhook-signature'))
107
+ return 'dodopayments';
108
+ if (headers.has('x-gitlab-token'))
109
+ return 'gitlab';
110
+ if (headers.has('x-polar-signature'))
111
+ return 'polar';
112
+ if (headers.has('x-shopify-hmac-sha256'))
113
+ return 'shopify';
114
+ if (headers.has('x-vercel-signature'))
115
+ return 'vercel';
116
+ if (headers.has('x-webhook-token') && headers.has('x-webhook-id'))
117
+ return 'supabase';
118
+ return 'unknown';
72
119
  }
73
120
  // Helper method to get all platforms using a specific algorithm
74
121
  static getPlatformsUsingAlgorithm(algorithm) {
75
- const { getPlatformsUsingAlgorithm } = require('./platforms/algorithms');
76
- return getPlatformsUsingAlgorithm(algorithm);
122
+ return (0, algorithms_2.getPlatformsUsingAlgorithm)(algorithm);
77
123
  }
78
124
  // Helper method to check if a platform uses a specific algorithm
79
125
  static platformUsesAlgorithm(platform, algorithm) {
80
- const { platformUsesAlgorithm } = require('./platforms/algorithms');
81
- return platformUsesAlgorithm(platform, algorithm);
126
+ return (0, algorithms_2.platformUsesAlgorithm)(platform, algorithm);
82
127
  }
83
128
  // Helper method to validate signature config
84
129
  static validateSignatureConfig(config) {
85
- const { validateSignatureConfig } = require('./platforms/algorithms');
86
- return validateSignatureConfig(config);
130
+ return (0, algorithms_2.validateSignatureConfig)(config);
87
131
  }
88
132
  // Simple token-based verification for platforms like Supabase
89
133
  static async verifyTokenBased(request, webhookId, webhookToken) {
@@ -94,6 +138,7 @@ class WebhookVerificationService {
94
138
  return {
95
139
  isValid: false,
96
140
  error: 'Missing required headers: x-webhook-id and x-webhook-token',
141
+ errorCode: 'MISSING_TOKEN',
97
142
  platform: 'custom',
98
143
  };
99
144
  }
@@ -103,6 +148,7 @@ class WebhookVerificationService {
103
148
  return {
104
149
  isValid: false,
105
150
  error: 'Invalid webhook ID or token',
151
+ errorCode: 'INVALID_TOKEN',
106
152
  platform: 'custom',
107
153
  };
108
154
  }
@@ -117,7 +163,7 @@ class WebhookVerificationService {
117
163
  return {
118
164
  isValid: true,
119
165
  platform: 'custom',
120
- payload,
166
+ payload: payload,
121
167
  metadata: {
122
168
  id: idHeader,
123
169
  algorithm: 'token-based',
@@ -128,6 +174,7 @@ class WebhookVerificationService {
128
174
  return {
129
175
  isValid: false,
130
176
  error: `Token-based verification error: ${error.message}`,
177
+ errorCode: 'VERIFICATION_ERROR',
131
178
  platform: 'custom',
132
179
  };
133
180
  }
@@ -144,4 +191,9 @@ var algorithms_4 = require("./verifiers/algorithms");
144
191
  Object.defineProperty(exports, "createAlgorithmVerifier", { enumerable: true, get: function () { return algorithms_4.createAlgorithmVerifier; } });
145
192
  var custom_algorithms_2 = require("./verifiers/custom-algorithms");
146
193
  Object.defineProperty(exports, "createCustomVerifier", { enumerable: true, get: function () { return custom_algorithms_2.createCustomVerifier; } });
194
+ var simple_2 = require("./normalization/simple");
195
+ Object.defineProperty(exports, "normalizePayload", { enumerable: true, get: function () { return simple_2.normalizePayload; } });
196
+ Object.defineProperty(exports, "getPlatformNormalizationCategory", { enumerable: true, get: function () { return simple_2.getPlatformNormalizationCategory; } });
197
+ Object.defineProperty(exports, "getPlatformsByCategory", { enumerable: true, get: function () { return simple_2.getPlatformsByCategory; } });
198
+ __exportStar(require("./adapters"), exports);
147
199
  exports.default = WebhookVerificationService;
@@ -0,0 +1,2 @@
1
+ export { createWebhookHandler } from './adapters/nextjs';
2
+ export type { NextWebhookHandlerOptions } from './adapters/nextjs';
package/dist/nextjs.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createWebhookHandler = void 0;
4
+ var nextjs_1 = require("./adapters/nextjs");
5
+ Object.defineProperty(exports, "createWebhookHandler", { enumerable: true, get: function () { return nextjs_1.createWebhookHandler; } });
@@ -0,0 +1,4 @@
1
+ import { AnyNormalizedWebhook, NormalizeOptions, NormalizationCategory, WebhookPlatform } from '../types';
2
+ export declare function getPlatformNormalizationCategory(platform: WebhookPlatform): NormalizationCategory | null;
3
+ export declare function getPlatformsByCategory(category: NormalizationCategory): WebhookPlatform[];
4
+ export declare function normalizePayload(platform: WebhookPlatform, payload: any, normalize?: boolean | NormalizeOptions): AnyNormalizedWebhook | unknown;
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getPlatformNormalizationCategory = getPlatformNormalizationCategory;
4
+ exports.getPlatformsByCategory = getPlatformsByCategory;
5
+ exports.normalizePayload = normalizePayload;
6
+ function readPath(payload, path) {
7
+ return path.split('.').reduce((acc, key) => {
8
+ if (acc === undefined || acc === null) {
9
+ return undefined;
10
+ }
11
+ return acc[key];
12
+ }, payload);
13
+ }
14
+ const platformNormalizers = {
15
+ stripe: {
16
+ platform: 'stripe',
17
+ category: 'payment',
18
+ normalize: (payload) => ({
19
+ category: 'payment',
20
+ event: readPath(payload, 'type') === 'payment_intent.succeeded'
21
+ ? 'payment.succeeded'
22
+ : 'payment.unknown',
23
+ amount: readPath(payload, 'data.object.amount_received')
24
+ ?? readPath(payload, 'data.object.amount'),
25
+ currency: String(readPath(payload, 'data.object.currency') ?? '').toUpperCase() || undefined,
26
+ customer_id: readPath(payload, 'data.object.customer'),
27
+ transaction_id: readPath(payload, 'data.object.id'),
28
+ metadata: {},
29
+ occurred_at: new Date().toISOString(),
30
+ }),
31
+ },
32
+ polar: {
33
+ platform: 'polar',
34
+ category: 'payment',
35
+ normalize: (payload) => ({
36
+ category: 'payment',
37
+ event: readPath(payload, 'event') === 'payment.completed'
38
+ ? 'payment.succeeded'
39
+ : 'payment.unknown',
40
+ amount: readPath(payload, 'payload.amount_cents'),
41
+ currency: String(readPath(payload, 'payload.currency_code') ?? '').toUpperCase() || undefined,
42
+ customer_id: readPath(payload, 'payload.customer_id'),
43
+ transaction_id: readPath(payload, 'payload.transaction_id'),
44
+ metadata: {},
45
+ occurred_at: new Date().toISOString(),
46
+ }),
47
+ },
48
+ clerk: {
49
+ platform: 'clerk',
50
+ category: 'auth',
51
+ normalize: (payload) => ({
52
+ category: 'auth',
53
+ event: readPath(payload, 'type') || 'auth.unknown',
54
+ user_id: readPath(payload, 'data.id'),
55
+ email: readPath(payload, 'data.email_addresses.0.email_address'),
56
+ metadata: {},
57
+ occurred_at: new Date().toISOString(),
58
+ }),
59
+ },
60
+ supabase: {
61
+ platform: 'supabase',
62
+ category: 'auth',
63
+ normalize: (payload) => ({
64
+ category: 'auth',
65
+ event: readPath(payload, 'type') || readPath(payload, 'event') || 'auth.unknown',
66
+ user_id: readPath(payload, 'record.id') || readPath(payload, 'id'),
67
+ email: readPath(payload, 'record.email') || readPath(payload, 'email'),
68
+ metadata: {},
69
+ occurred_at: new Date().toISOString(),
70
+ }),
71
+ },
72
+ vercel: {
73
+ platform: 'vercel',
74
+ category: 'infrastructure',
75
+ normalize: (payload) => ({
76
+ category: 'infrastructure',
77
+ event: readPath(payload, 'type') || 'deployment.unknown',
78
+ project_id: readPath(payload, 'payload.project.id'),
79
+ deployment_id: readPath(payload, 'payload.deployment.id'),
80
+ status: 'unknown',
81
+ metadata: {},
82
+ occurred_at: new Date().toISOString(),
83
+ }),
84
+ },
85
+ };
86
+ function getPlatformNormalizationCategory(platform) {
87
+ return platformNormalizers[platform]?.category || null;
88
+ }
89
+ function getPlatformsByCategory(category) {
90
+ return Object.values(platformNormalizers)
91
+ .filter((spec) => !!spec)
92
+ .filter((spec) => spec.category === category)
93
+ .map((spec) => spec.platform);
94
+ }
95
+ function resolveNormalizeOptions(normalize) {
96
+ if (typeof normalize === 'boolean') {
97
+ return {
98
+ enabled: normalize,
99
+ category: undefined,
100
+ includeRaw: true,
101
+ };
102
+ }
103
+ return {
104
+ enabled: normalize?.enabled ?? true,
105
+ category: normalize?.category,
106
+ includeRaw: normalize?.includeRaw ?? true,
107
+ };
108
+ }
109
+ function buildUnknownNormalizedPayload(platform, payload, category, includeRaw, warning) {
110
+ return {
111
+ category: category || 'infrastructure',
112
+ event: payload?.type ?? payload?.event ?? 'unknown',
113
+ _platform: platform,
114
+ _raw: includeRaw ? payload : undefined,
115
+ warning,
116
+ occurred_at: new Date().toISOString(),
117
+ };
118
+ }
119
+ function normalizePayload(platform, payload, normalize) {
120
+ const options = resolveNormalizeOptions(normalize);
121
+ if (!options.enabled) {
122
+ return payload;
123
+ }
124
+ const spec = platformNormalizers[platform];
125
+ const inferredCategory = spec?.category;
126
+ if (!spec) {
127
+ return buildUnknownNormalizedPayload(platform, payload, options.category, options.includeRaw);
128
+ }
129
+ if (options.category && options.category !== inferredCategory) {
130
+ return buildUnknownNormalizedPayload(platform, payload, inferredCategory, options.includeRaw, `Requested normalization category '${options.category}' does not match platform category '${inferredCategory}'`);
131
+ }
132
+ const normalized = spec.normalize(payload);
133
+ return {
134
+ ...normalized,
135
+ _platform: platform,
136
+ _raw: options.includeRaw ? payload : undefined,
137
+ };
138
+ }
@@ -117,6 +117,20 @@ exports.platformAlgorithmConfigs = {
117
117
  },
118
118
  description: 'Supabase webhooks use token-based authentication',
119
119
  },
120
+ gitlab: {
121
+ platform: 'gitlab',
122
+ signatureConfig: {
123
+ algorithm: 'custom',
124
+ headerName: 'X-Gitlab-Token',
125
+ headerFormat: 'raw',
126
+ payloadFormat: 'raw',
127
+ customConfig: {
128
+ type: 'token-based',
129
+ idHeader: 'X-Gitlab-Token',
130
+ },
131
+ },
132
+ description: 'GitLab webhooks use HMAC-SHA256 with X-Gitlab-Token header',
133
+ },
120
134
  custom: {
121
135
  platform: 'custom',
122
136
  signatureConfig: {
package/dist/test.js CHANGED
@@ -24,6 +24,10 @@ function createGitHubSignature(body, secret) {
24
24
  hmac.update(body);
25
25
  return `sha256=${hmac.digest('hex')}`;
26
26
  }
27
+ function createGitLabSignature(body, secret) {
28
+ // GitLab just compares the token in X-Gitlab-Token header
29
+ return secret;
30
+ }
27
31
  function createClerkSignature(body, secret, id, timestamp) {
28
32
  const signedContent = `${id}.${timestamp}.${body}`;
29
33
  const secretBytes = new Uint8Array(Buffer.from(secret.split('_')[1], 'base64'));
@@ -170,7 +174,9 @@ async function runTests() {
170
174
  'content-type': 'application/json',
171
175
  });
172
176
  const invalidResult = await index_1.WebhookVerificationService.verifyWithPlatformConfig(invalidRequest, 'stripe', testSecret);
173
- console.log(' ✅ Invalid signature correctly rejected:', !invalidResult.isValid ? 'PASSED' : 'FAILED');
177
+ const invalidSigPassed = !invalidResult.isValid && (invalidResult.errorCode === 'INVALID_SIGNATURE'
178
+ || invalidResult.errorCode === 'TIMESTAMP_EXPIRED');
179
+ console.log(' ✅ Invalid signature correctly rejected:', invalidSigPassed ? 'PASSED' : 'FAILED');
174
180
  if (invalidResult.isValid) {
175
181
  console.log(' ❌ Should have been rejected');
176
182
  }
@@ -185,7 +191,8 @@ async function runTests() {
185
191
  'content-type': 'application/json',
186
192
  });
187
193
  const missingHeaderResult = await index_1.WebhookVerificationService.verifyWithPlatformConfig(missingHeaderRequest, 'stripe', testSecret);
188
- console.log(' ✅ Missing headers correctly rejected:', !missingHeaderResult.isValid ? 'PASSED' : 'FAILED');
194
+ const missingHeaderPassed = !missingHeaderResult.isValid && missingHeaderResult.errorCode === 'MISSING_SIGNATURE';
195
+ console.log(' ✅ Missing headers correctly rejected:', missingHeaderPassed ? 'PASSED' : 'FAILED');
189
196
  if (missingHeaderResult.isValid) {
190
197
  console.log(' ❌ Should have been rejected');
191
198
  }
@@ -193,6 +200,95 @@ async function runTests() {
193
200
  catch (error) {
194
201
  console.log(' ❌ Missing headers test failed:', error);
195
202
  }
203
+ // Test 8: GitLab Webhook
204
+ console.log('\n8. Testing GitLab Webhook...');
205
+ try {
206
+ const gitlabSecret = testSecret;
207
+ const gitlabRequest = createMockRequest({
208
+ 'X-Gitlab-Token': gitlabSecret,
209
+ 'content-type': 'application/json',
210
+ });
211
+ const gitlabResult = await index_1.WebhookVerificationService.verifyWithPlatformConfig(gitlabRequest, 'gitlab', gitlabSecret);
212
+ console.log(' ✅ GitLab:', gitlabResult.isValid ? 'PASSED' : 'FAILED');
213
+ if (!gitlabResult.isValid) {
214
+ console.log(' ❌ Error:', gitlabResult.error);
215
+ }
216
+ }
217
+ catch (error) {
218
+ console.log(' ❌ GitLab test failed:', error);
219
+ }
220
+ // Test 9: GitLab Invalid Token
221
+ console.log('\n9. Testing GitLab Invalid Token...');
222
+ try {
223
+ const gitlabRequest = createMockRequest({
224
+ 'X-Gitlab-Token': 'wrong_secret',
225
+ 'content-type': 'application/json',
226
+ });
227
+ const gitlabResult = await index_1.WebhookVerificationService.verifyWithPlatformConfig(gitlabRequest, 'gitlab', testSecret);
228
+ console.log(' ✅ Invalid token correctly rejected:', !gitlabResult.isValid ? 'PASSED' : 'FAILED');
229
+ }
230
+ catch (error) {
231
+ console.log(' ❌ GitLab invalid token test failed:', error);
232
+ }
233
+ // Test 10: verifyAny should auto-detect Stripe
234
+ console.log('\n10. Testing verifyAny auto-detection...');
235
+ try {
236
+ const timestamp = Math.floor(Date.now() / 1000);
237
+ const stripeSignature = createStripeSignature(testBody, testSecret, timestamp);
238
+ const request = createMockRequest({
239
+ 'stripe-signature': stripeSignature,
240
+ 'content-type': 'application/json',
241
+ });
242
+ const result = await index_1.WebhookVerificationService.verifyAny(request, {
243
+ stripe: testSecret,
244
+ github: 'wrong-secret',
245
+ });
246
+ console.log(' ✅ verifyAny:', result.isValid && result.platform === 'stripe' ? 'PASSED' : 'FAILED');
247
+ }
248
+ catch (error) {
249
+ console.log(' ❌ verifyAny test failed:', error);
250
+ }
251
+ // Test 11: Normalization for Stripe
252
+ console.log('\n11. Testing payload normalization...');
253
+ try {
254
+ const normalizedStripeBody = JSON.stringify({
255
+ type: 'payment_intent.succeeded',
256
+ data: {
257
+ object: {
258
+ id: 'pi_123',
259
+ amount: 5000,
260
+ currency: 'usd',
261
+ customer: 'cus_456',
262
+ },
263
+ },
264
+ });
265
+ const timestamp = Math.floor(Date.now() / 1000);
266
+ const stripeSignature = createStripeSignature(normalizedStripeBody, testSecret, timestamp);
267
+ const request = createMockRequest({
268
+ 'stripe-signature': stripeSignature,
269
+ 'content-type': 'application/json',
270
+ }, normalizedStripeBody);
271
+ const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'stripe', testSecret, 300, true);
272
+ const payload = result.payload;
273
+ const passed = result.isValid
274
+ && payload.event === 'payment.succeeded'
275
+ && payload.currency === 'USD'
276
+ && payload.transaction_id === 'pi_123';
277
+ console.log(' ✅ Normalization:', passed ? 'PASSED' : 'FAILED');
278
+ }
279
+ catch (error) {
280
+ console.log(' ❌ Normalization test failed:', error);
281
+ }
282
+ // Test 12: Category-aware normalization registry
283
+ console.log('\n12. Testing category-based platform registry...');
284
+ try {
285
+ const paymentPlatforms = (0, index_1.getPlatformsByCategory)('payment');
286
+ const hasStripeAndPolar = paymentPlatforms.includes('stripe') && paymentPlatforms.includes('polar');
287
+ console.log(' ✅ Category registry:', hasStripeAndPolar ? 'PASSED' : 'FAILED');
288
+ }
289
+ catch (error) {
290
+ console.log(' ❌ Category registry test failed:', error);
291
+ }
196
292
  console.log('\n🎉 All tests completed!');
197
293
  }
198
294
  // Run tests if this file is executed directly
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'unknown';
1
+ export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'unknown';
2
2
  export declare enum WebhookPlatformKeys {
3
3
  GitHub = "github",
4
4
  Stripe = "stripe",
@@ -8,6 +8,7 @@ export declare enum WebhookPlatformKeys {
8
8
  Vercel = "vercel",
9
9
  Polar = "polar",
10
10
  Supabase = "supabase",
11
+ GitLab = "gitlab",
11
12
  Custom = "custom",
12
13
  Unknown = "unknown"
13
14
  }
@@ -22,11 +23,76 @@ export interface SignatureConfig {
22
23
  payloadFormat?: 'raw' | 'timestamped' | 'custom';
23
24
  customConfig?: Record<string, any>;
24
25
  }
25
- export interface WebhookVerificationResult {
26
+ export type WebhookErrorCode = 'MISSING_SIGNATURE' | 'INVALID_SIGNATURE' | 'TIMESTAMP_EXPIRED' | 'MISSING_TOKEN' | 'INVALID_TOKEN' | 'PLATFORM_NOT_SUPPORTED' | 'NORMALIZATION_ERROR' | 'VERIFICATION_ERROR';
27
+ export type NormalizationCategory = 'payment' | 'auth' | 'ecommerce' | 'infrastructure';
28
+ export interface BaseNormalizedWebhook {
29
+ category: NormalizationCategory;
30
+ event: string;
31
+ _platform: WebhookPlatform | string;
32
+ _raw: unknown;
33
+ occurred_at?: string;
34
+ }
35
+ export type PaymentWebhookEvent = 'payment.succeeded' | 'payment.failed' | 'payment.refunded' | 'subscription.created' | 'subscription.cancelled' | 'payment.unknown';
36
+ export interface PaymentWebhookNormalized extends BaseNormalizedWebhook {
37
+ category: 'payment';
38
+ event: PaymentWebhookEvent;
39
+ amount?: number;
40
+ currency?: string;
41
+ customer_id?: string;
42
+ transaction_id?: string;
43
+ subscription_id?: string;
44
+ refund_amount?: number;
45
+ failure_reason?: string;
46
+ metadata?: Record<string, string>;
47
+ }
48
+ export type AuthWebhookEvent = 'user.created' | 'user.updated' | 'user.deleted' | 'session.started' | 'session.ended' | 'auth.unknown';
49
+ export interface AuthWebhookNormalized extends BaseNormalizedWebhook {
50
+ category: 'auth';
51
+ event: AuthWebhookEvent;
52
+ user_id?: string;
53
+ email?: string;
54
+ phone?: string;
55
+ metadata?: Record<string, string>;
56
+ }
57
+ export interface EcommerceWebhookNormalized extends BaseNormalizedWebhook {
58
+ category: 'ecommerce';
59
+ event: string;
60
+ order_id?: string;
61
+ customer_id?: string;
62
+ amount?: number;
63
+ currency?: string;
64
+ metadata?: Record<string, string>;
65
+ }
66
+ export interface InfrastructureWebhookNormalized extends BaseNormalizedWebhook {
67
+ category: 'infrastructure';
68
+ event: string;
69
+ project_id?: string;
70
+ deployment_id?: string;
71
+ status?: 'queued' | 'building' | 'ready' | 'error' | 'unknown';
72
+ metadata?: Record<string, string>;
73
+ }
74
+ export interface UnknownNormalizedWebhook extends BaseNormalizedWebhook {
75
+ event: string;
76
+ warning?: string;
77
+ }
78
+ export type NormalizedPayloadByCategory = {
79
+ payment: PaymentWebhookNormalized;
80
+ auth: AuthWebhookNormalized;
81
+ ecommerce: EcommerceWebhookNormalized;
82
+ infrastructure: InfrastructureWebhookNormalized;
83
+ };
84
+ export type AnyNormalizedWebhook = PaymentWebhookNormalized | AuthWebhookNormalized | EcommerceWebhookNormalized | InfrastructureWebhookNormalized | UnknownNormalizedWebhook;
85
+ export interface NormalizeOptions {
86
+ enabled?: boolean;
87
+ category?: NormalizationCategory;
88
+ includeRaw?: boolean;
89
+ }
90
+ export interface WebhookVerificationResult<TPayload = unknown> {
26
91
  isValid: boolean;
27
92
  error?: string;
93
+ errorCode?: WebhookErrorCode;
28
94
  platform: WebhookPlatform;
29
- payload?: any;
95
+ payload?: TPayload;
30
96
  metadata?: {
31
97
  timestamp?: string;
32
98
  id?: string | null;
@@ -38,6 +104,10 @@ export interface WebhookConfig {
38
104
  secret: string;
39
105
  toleranceInSeconds?: number;
40
106
  signatureConfig?: SignatureConfig;
107
+ normalize?: boolean | NormalizeOptions;
108
+ }
109
+ export interface MultiPlatformSecrets {
110
+ [platform: string]: string | undefined;
41
111
  }
42
112
  export interface PlatformAlgorithmConfig {
43
113
  platform: WebhookPlatform;
package/dist/types.js CHANGED
@@ -11,6 +11,7 @@ var WebhookPlatformKeys;
11
11
  WebhookPlatformKeys["Vercel"] = "vercel";
12
12
  WebhookPlatformKeys["Polar"] = "polar";
13
13
  WebhookPlatformKeys["Supabase"] = "supabase";
14
+ WebhookPlatformKeys["GitLab"] = "gitlab";
14
15
  WebhookPlatformKeys["Custom"] = "custom";
15
16
  WebhookPlatformKeys["Unknown"] = "unknown";
16
17
  })(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
@@ -185,6 +185,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
185
185
  return {
186
186
  isValid: false,
187
187
  error: `Missing signature header: ${this.config.headerName}`,
188
+ errorCode: 'MISSING_SIGNATURE',
188
189
  platform: this.platform,
189
190
  };
190
191
  }
@@ -204,6 +205,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
204
205
  return {
205
206
  isValid: false,
206
207
  error: 'Webhook timestamp expired',
208
+ errorCode: 'TIMESTAMP_EXPIRED',
207
209
  platform: this.platform,
208
210
  };
209
211
  }
@@ -228,6 +230,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
228
230
  return {
229
231
  isValid: false,
230
232
  error: 'Invalid signature',
233
+ errorCode: 'INVALID_SIGNATURE',
231
234
  platform: this.platform,
232
235
  };
233
236
  }
@@ -252,6 +255,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
252
255
  return {
253
256
  isValid: false,
254
257
  error: `${this.platform} verification error: ${error.message}`,
258
+ errorCode: 'VERIFICATION_ERROR',
255
259
  platform: this.platform,
256
260
  };
257
261
  }
@@ -17,6 +17,7 @@ class TokenBasedVerifier extends base_1.WebhookVerifier {
17
17
  return {
18
18
  isValid: false,
19
19
  error: `Missing token header: ${this.config.headerName}`,
20
+ errorCode: 'MISSING_TOKEN',
20
21
  platform: 'custom',
21
22
  };
22
23
  }
@@ -26,6 +27,7 @@ class TokenBasedVerifier extends base_1.WebhookVerifier {
26
27
  return {
27
28
  isValid: false,
28
29
  error: 'Invalid token',
30
+ errorCode: 'INVALID_TOKEN',
29
31
  platform: 'custom',
30
32
  };
31
33
  }
@@ -51,6 +53,7 @@ class TokenBasedVerifier extends base_1.WebhookVerifier {
51
53
  return {
52
54
  isValid: false,
53
55
  error: `Token-based verification error: ${error.message}`,
56
+ errorCode: 'VERIFICATION_ERROR',
54
57
  platform: 'custom',
55
58
  };
56
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "2.0.0",
3
+ "version": "2.0.2-experimental.0",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "release:patch": "npm run build && npm version patch && npm publish",
16
16
  "release:minor": "npm run build && npm version minor && npm publish",
17
17
  "release:major": "npm run build && npm version major && npm publish",
18
- "clean": "rm -rf dist",
18
+ "clean": "rimraf dist",
19
19
  "prebuild": "npm run clean"
20
20
  },
21
21
  "keywords": [
@@ -55,7 +55,9 @@
55
55
  "eslint-plugin-import": "^2.32.0",
56
56
  "jest": "29.5.0",
57
57
  "prettier": "3.0.0",
58
+ "rimraf": "^6.0.1",
58
59
  "ts-jest": "29.1.0",
60
+ "ts-node": "^10.9.2",
59
61
  "typescript": "^5.0.0"
60
62
  },
61
63
  "engines": {
@@ -68,5 +70,23 @@
68
70
  ],
69
71
  "publishConfig": {
70
72
  "access": "public"
73
+ },
74
+ "exports": {
75
+ ".": {
76
+ "require": "./dist/index.js",
77
+ "types": "./dist/index.d.ts"
78
+ },
79
+ "./express": {
80
+ "require": "./dist/express.js",
81
+ "types": "./dist/express.d.ts"
82
+ },
83
+ "./nextjs": {
84
+ "require": "./dist/nextjs.js",
85
+ "types": "./dist/nextjs.d.ts"
86
+ },
87
+ "./cloudflare": {
88
+ "require": "./dist/cloudflare.js",
89
+ "types": "./dist/cloudflare.d.ts"
90
+ }
71
91
  }
72
92
  }