@hookflo/tern 4.3.3-beta → 4.4.3
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 +27 -45
- package/dist/adapters/cloudflare.js +5 -1
- package/dist/adapters/express.js +5 -1
- package/dist/adapters/hono.js +5 -1
- package/dist/adapters/nextjs.js +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -10
- package/dist/platforms/algorithms.d.ts +20 -0
- package/dist/platforms/algorithms.js +65 -51
- package/dist/types.d.ts +7 -9
- package/dist/types.js +1 -2
- package/dist/verifiers/algorithms.d.ts +0 -2
- package/dist/verifiers/algorithms.js +7 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@hookflo/tern)
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
|
-
|
|
7
|
+

|
|
8
8
|
[](package.json)
|
|
9
9
|
|
|
10
|
-
Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API.
|
|
10
|
+
Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. It also verifies **Standard Webhooks** (including Svix-style `svix-*` and canonical `webhook-*` headers) through a single `standardwebhooks` platform config.
|
|
11
11
|
|
|
12
12
|
> Need reliable delivery too? Tern supports inbound webhook delivery via Upstash QStash — automatic retries, DLQ management, replay controls, and Slack/Discord alerting. Bring your own Upstash account (BYOK).
|
|
13
13
|
|
|
@@ -79,27 +79,6 @@ const result = await WebhookVerificationService.verifyAny(request, {
|
|
|
79
79
|
console.log(`Verified ${result.platform} webhook`);
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
### Twilio example
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
import { WebhookVerificationService } from '@hookflo/tern';
|
|
86
|
-
|
|
87
|
-
export async function POST(request: Request) {
|
|
88
|
-
const result = await WebhookVerificationService.verify(request, {
|
|
89
|
-
platform: 'twilio',
|
|
90
|
-
secret: process.env.TWILIO_AUTH_TOKEN!,
|
|
91
|
-
// Optional when behind proxies/CDNs if request.url differs from the public Twilio URL:
|
|
92
|
-
twilioBaseUrl: 'https://yourdomain.com/api/webhooks/twilio',
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
if (!result.isValid) {
|
|
96
|
-
return Response.json({ error: result.error }, { status: 400 });
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return Response.json({ ok: true });
|
|
100
|
-
}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
82
|
### Core SDK (runtime-agnostic)
|
|
104
83
|
|
|
105
84
|
Use Tern without framework adapters in any runtime that supports the Web `Request` API.
|
|
@@ -213,10 +192,9 @@ app.post('/webhooks/stripe', createWebhookHandler({
|
|
|
213
192
|
| **Grafana** | HMAC-SHA256 | ✅ Tested |
|
|
214
193
|
| **Doppler** | HMAC-SHA256 | ✅ Tested |
|
|
215
194
|
| **Sanity** | HMAC-SHA256 | ✅ Tested |
|
|
216
|
-
| **Svix** | HMAC-SHA256 |
|
|
217
|
-
| **
|
|
218
|
-
| **
|
|
219
|
-
| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now |
|
|
195
|
+
| **Svix** | HMAC-SHA256 | ✅ Tested |
|
|
196
|
+
| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested |
|
|
197
|
+
| **Linear** | HMAC-SHA256 | ✅ Tested |
|
|
220
198
|
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
|
|
221
199
|
| **Vercel** | HMAC-SHA256 | 🔄 Pending |
|
|
222
200
|
|
|
@@ -224,7 +202,7 @@ app.post('/webhooks/stripe', createWebhookHandler({
|
|
|
224
202
|
|
|
225
203
|
### Platform signature notes
|
|
226
204
|
|
|
227
|
-
- **Standard Webhooks style**
|
|
205
|
+
- **Standard Webhooks style** providers are supported via the canonical `standardwebhooks` platform (with aliases for both `webhook-*` and `svix-*` headers). Clerk, Dodo Payments, Polar, and ReplicateAI all follow this pattern and commonly use a secret that starts with `whsec_...`.
|
|
228
206
|
- **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`.
|
|
229
207
|
- **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly.
|
|
230
208
|
|
|
@@ -365,25 +343,31 @@ const result = await WebhookVerificationService.verify(request, {
|
|
|
365
343
|
});
|
|
366
344
|
```
|
|
367
345
|
|
|
368
|
-
###
|
|
346
|
+
### Standard Webhooks config helpers (Svix-style and webhook-* headers)
|
|
369
347
|
|
|
370
348
|
```typescript
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
349
|
+
import {
|
|
350
|
+
createStandardWebhooksConfig,
|
|
351
|
+
STANDARD_WEBHOOKS_BASE,
|
|
352
|
+
} from '@hookflo/tern';
|
|
353
|
+
|
|
354
|
+
const signatureConfig = createStandardWebhooksConfig({
|
|
355
|
+
id: 'webhook-id',
|
|
356
|
+
timestamp: 'webhook-timestamp',
|
|
357
|
+
signature: 'webhook-signature',
|
|
358
|
+
idAliases: ['svix-id'],
|
|
359
|
+
timestampAliases: ['svix-timestamp'],
|
|
360
|
+
signatureAliases: ['svix-signature'],
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = await WebhookVerificationService.verify(request, {
|
|
364
|
+
platform: 'standardwebhooks',
|
|
365
|
+
secret: process.env.STANDARD_WEBHOOKS_SECRET!,
|
|
374
366
|
signatureConfig: {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
headerFormat: 'raw',
|
|
378
|
-
timestampHeader: 'webhook-timestamp',
|
|
379
|
-
timestampFormat: 'unix',
|
|
380
|
-
payloadFormat: 'custom',
|
|
381
|
-
customConfig: {
|
|
382
|
-
payloadFormat: '{id}.{timestamp}.{body}',
|
|
383
|
-
idHeader: 'webhook-id',
|
|
384
|
-
},
|
|
367
|
+
...STANDARD_WEBHOOKS_BASE,
|
|
368
|
+
...signatureConfig,
|
|
385
369
|
},
|
|
386
|
-
};
|
|
370
|
+
});
|
|
387
371
|
```
|
|
388
372
|
|
|
389
373
|
See the [SignatureConfig type](https://tern.hookflo.com) for all options.
|
|
@@ -431,8 +415,6 @@ interface WebhookVerificationResult {
|
|
|
431
415
|
|
|
432
416
|
## Troubleshooting
|
|
433
417
|
|
|
434
|
-
- **Twilio invalid signature behind proxies/CDNs**: if your runtime `request.url` differs from the public Twilio webhook URL, pass `twilioBaseUrl` in `WebhookVerificationService.verify(...)` for platform `twilio`.
|
|
435
|
-
|
|
436
418
|
|
|
437
419
|
**`Module not found: Can't resolve "@hookflo/tern/nextjs"`**
|
|
438
420
|
|
|
@@ -39,7 +39,11 @@ function createWebhookHandler(options) {
|
|
|
39
39
|
}
|
|
40
40
|
return response;
|
|
41
41
|
}
|
|
42
|
-
const result = await index_1.WebhookVerificationService.
|
|
42
|
+
const result = await index_1.WebhookVerificationService.verify(request, {
|
|
43
|
+
platform: options.platform,
|
|
44
|
+
secret,
|
|
45
|
+
toleranceInSeconds: options.toleranceInSeconds,
|
|
46
|
+
});
|
|
43
47
|
if (!result.isValid) {
|
|
44
48
|
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
45
49
|
}
|
package/dist/adapters/express.js
CHANGED
|
@@ -48,7 +48,11 @@ function createWebhookMiddleware(options) {
|
|
|
48
48
|
}
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
-
const result = await index_1.WebhookVerificationService.
|
|
51
|
+
const result = await index_1.WebhookVerificationService.verify(webRequest, {
|
|
52
|
+
platform: options.platform,
|
|
53
|
+
secret: options.secret,
|
|
54
|
+
toleranceInSeconds: options.toleranceInSeconds,
|
|
55
|
+
});
|
|
52
56
|
if (!result.isValid) {
|
|
53
57
|
res.status(400).json({
|
|
54
58
|
error: result.error,
|
package/dist/adapters/hono.js
CHANGED
|
@@ -35,7 +35,11 @@ function createWebhookHandler(options) {
|
|
|
35
35
|
}
|
|
36
36
|
return response;
|
|
37
37
|
}
|
|
38
|
-
const result = await index_1.WebhookVerificationService.
|
|
38
|
+
const result = await index_1.WebhookVerificationService.verify(request, {
|
|
39
|
+
platform: options.platform,
|
|
40
|
+
secret: options.secret,
|
|
41
|
+
toleranceInSeconds: options.toleranceInSeconds,
|
|
42
|
+
});
|
|
39
43
|
if (!result.isValid) {
|
|
40
44
|
return c.json({
|
|
41
45
|
error: result.error,
|
package/dist/adapters/nextjs.js
CHANGED
|
@@ -34,7 +34,11 @@ function createWebhookHandler(options) {
|
|
|
34
34
|
}
|
|
35
35
|
return response;
|
|
36
36
|
}
|
|
37
|
-
const result = await index_1.WebhookVerificationService.
|
|
37
|
+
const result = await index_1.WebhookVerificationService.verify(request, {
|
|
38
|
+
platform: options.platform,
|
|
39
|
+
secret: options.secret,
|
|
40
|
+
toleranceInSeconds: options.toleranceInSeconds,
|
|
41
|
+
});
|
|
38
42
|
if (!result.isValid) {
|
|
39
43
|
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
40
44
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export declare class WebhookVerificationService {
|
|
|
29
29
|
static verifyTokenBased<TPayload = unknown>(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult<TPayload>>;
|
|
30
30
|
}
|
|
31
31
|
export * from './types';
|
|
32
|
-
export { getPlatformAlgorithmConfig, platformUsesAlgorithm, getPlatformsUsingAlgorithm, validateSignatureConfig, } from './platforms/algorithms';
|
|
32
|
+
export { getPlatformAlgorithmConfig, platformUsesAlgorithm, getPlatformsUsingAlgorithm, validateSignatureConfig, STANDARD_WEBHOOKS_BASE, createStandardWebhooksConfig, } from './platforms/algorithms';
|
|
33
33
|
export { createAlgorithmVerifier } from './verifiers/algorithms';
|
|
34
34
|
export { createCustomVerifier } from './verifiers/custom-algorithms';
|
|
35
35
|
export * from './adapters';
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
36
36
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.createCustomVerifier = exports.createAlgorithmVerifier = exports.validateSignatureConfig = exports.getPlatformsUsingAlgorithm = exports.platformUsesAlgorithm = exports.getPlatformAlgorithmConfig = exports.WebhookVerificationService = void 0;
|
|
39
|
+
exports.createCustomVerifier = exports.createAlgorithmVerifier = exports.createStandardWebhooksConfig = exports.STANDARD_WEBHOOKS_BASE = exports.validateSignatureConfig = exports.getPlatformsUsingAlgorithm = exports.platformUsesAlgorithm = exports.getPlatformAlgorithmConfig = exports.WebhookVerificationService = void 0;
|
|
40
40
|
const crypto_1 = require("crypto");
|
|
41
41
|
const algorithms_1 = require("./verifiers/algorithms");
|
|
42
42
|
const custom_algorithms_1 = require("./verifiers/custom-algorithms");
|
|
@@ -70,9 +70,6 @@ class WebhookVerificationService {
|
|
|
70
70
|
...signatureConfig,
|
|
71
71
|
customConfig: {
|
|
72
72
|
...(signatureConfig.customConfig || {}),
|
|
73
|
-
...(config.platform === 'twilio' && config.twilioBaseUrl
|
|
74
|
-
? { twilioBaseUrl: config.twilioBaseUrl }
|
|
75
|
-
: {}),
|
|
76
73
|
},
|
|
77
74
|
};
|
|
78
75
|
// Use custom verifiers for special cases (token-based, etc.)
|
|
@@ -194,9 +191,8 @@ class WebhookVerificationService {
|
|
|
194
191
|
case 'sentry':
|
|
195
192
|
case 'vercel':
|
|
196
193
|
case 'linear':
|
|
197
|
-
case 'pagerduty':
|
|
198
|
-
case 'twilio':
|
|
199
194
|
case 'svix':
|
|
195
|
+
case 'standardwebhooks':
|
|
200
196
|
return this.pickString(payload?.id) || null;
|
|
201
197
|
case 'doppler':
|
|
202
198
|
return this.pickString(payload?.event?.id, metadata?.id) || null;
|
|
@@ -231,10 +227,6 @@ class WebhookVerificationService {
|
|
|
231
227
|
return headers.has('svix-id') ? 'svix' : 'clerk';
|
|
232
228
|
if (headers.has('linear-signature'))
|
|
233
229
|
return 'linear';
|
|
234
|
-
if (headers.has('x-pagerduty-signature'))
|
|
235
|
-
return 'pagerduty';
|
|
236
|
-
if (headers.has('x-twilio-signature'))
|
|
237
|
-
return 'twilio';
|
|
238
230
|
if (headers.has('workos-signature'))
|
|
239
231
|
return 'workos';
|
|
240
232
|
if (headers.has('webhook-signature')) {
|
|
@@ -375,6 +367,8 @@ Object.defineProperty(exports, "getPlatformAlgorithmConfig", { enumerable: true,
|
|
|
375
367
|
Object.defineProperty(exports, "platformUsesAlgorithm", { enumerable: true, get: function () { return algorithms_3.platformUsesAlgorithm; } });
|
|
376
368
|
Object.defineProperty(exports, "getPlatformsUsingAlgorithm", { enumerable: true, get: function () { return algorithms_3.getPlatformsUsingAlgorithm; } });
|
|
377
369
|
Object.defineProperty(exports, "validateSignatureConfig", { enumerable: true, get: function () { return algorithms_3.validateSignatureConfig; } });
|
|
370
|
+
Object.defineProperty(exports, "STANDARD_WEBHOOKS_BASE", { enumerable: true, get: function () { return algorithms_3.STANDARD_WEBHOOKS_BASE; } });
|
|
371
|
+
Object.defineProperty(exports, "createStandardWebhooksConfig", { enumerable: true, get: function () { return algorithms_3.createStandardWebhooksConfig; } });
|
|
378
372
|
var algorithms_4 = require("./verifiers/algorithms");
|
|
379
373
|
Object.defineProperty(exports, "createAlgorithmVerifier", { enumerable: true, get: function () { return algorithms_4.createAlgorithmVerifier; } });
|
|
380
374
|
var custom_algorithms_2 = require("./verifiers/custom-algorithms");
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import { PlatformAlgorithmConfig, WebhookPlatform, SignatureConfig } from "../types";
|
|
2
|
+
export declare const STANDARD_WEBHOOKS_BASE: {
|
|
3
|
+
algorithm: "hmac-sha256";
|
|
4
|
+
headerFormat: "raw";
|
|
5
|
+
timestampFormat: "unix";
|
|
6
|
+
payloadFormat: "custom";
|
|
7
|
+
customConfig: {
|
|
8
|
+
signatureFormat: string;
|
|
9
|
+
payloadFormat: string;
|
|
10
|
+
encoding: string;
|
|
11
|
+
secretEncoding: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export declare function createStandardWebhooksConfig(headers: {
|
|
15
|
+
id: string;
|
|
16
|
+
timestamp: string;
|
|
17
|
+
signature: string;
|
|
18
|
+
idAliases?: string[];
|
|
19
|
+
timestampAliases?: string[];
|
|
20
|
+
signatureAliases?: string[];
|
|
21
|
+
}): SignatureConfig;
|
|
2
22
|
export declare const platformAlgorithmConfigs: Record<WebhookPlatform, PlatformAlgorithmConfig>;
|
|
3
23
|
export declare function getPlatformAlgorithmConfig(platform: WebhookPlatform): PlatformAlgorithmConfig;
|
|
4
24
|
export declare function platformUsesAlgorithm(platform: WebhookPlatform, algorithm: string): boolean;
|
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.platformAlgorithmConfigs = void 0;
|
|
3
|
+
exports.platformAlgorithmConfigs = exports.STANDARD_WEBHOOKS_BASE = void 0;
|
|
4
|
+
exports.createStandardWebhooksConfig = createStandardWebhooksConfig;
|
|
4
5
|
exports.getPlatformAlgorithmConfig = getPlatformAlgorithmConfig;
|
|
5
6
|
exports.platformUsesAlgorithm = platformUsesAlgorithm;
|
|
6
7
|
exports.getPlatformsUsingAlgorithm = getPlatformsUsingAlgorithm;
|
|
7
8
|
exports.validateSignatureConfig = validateSignatureConfig;
|
|
9
|
+
exports.STANDARD_WEBHOOKS_BASE = {
|
|
10
|
+
algorithm: "hmac-sha256",
|
|
11
|
+
headerFormat: "raw",
|
|
12
|
+
timestampFormat: "unix",
|
|
13
|
+
payloadFormat: "custom",
|
|
14
|
+
customConfig: {
|
|
15
|
+
signatureFormat: "v1={signature}",
|
|
16
|
+
payloadFormat: "{id}.{timestamp}.{body}",
|
|
17
|
+
encoding: "base64",
|
|
18
|
+
secretEncoding: "base64",
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
function createStandardWebhooksConfig(headers) {
|
|
22
|
+
return {
|
|
23
|
+
...exports.STANDARD_WEBHOOKS_BASE,
|
|
24
|
+
headerName: headers.signature,
|
|
25
|
+
timestampHeader: headers.timestamp,
|
|
26
|
+
customConfig: {
|
|
27
|
+
...exports.STANDARD_WEBHOOKS_BASE.customConfig,
|
|
28
|
+
idHeader: headers.id,
|
|
29
|
+
...(headers.idAliases && { idHeaderAliases: headers.idAliases }),
|
|
30
|
+
...(headers.timestampAliases && {
|
|
31
|
+
timestampHeaderAliases: headers.timestampAliases,
|
|
32
|
+
}),
|
|
33
|
+
...(headers.signatureAliases && {
|
|
34
|
+
signatureHeaderAliases: headers.signatureAliases,
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
8
39
|
exports.platformAlgorithmConfigs = {
|
|
9
40
|
github: {
|
|
10
41
|
platform: "github",
|
|
@@ -51,27 +82,6 @@ exports.platformAlgorithmConfigs = {
|
|
|
51
82
|
},
|
|
52
83
|
description: "Clerk webhooks use HMAC-SHA256 with base64 encoding",
|
|
53
84
|
},
|
|
54
|
-
svix: {
|
|
55
|
-
platform: 'svix',
|
|
56
|
-
signatureConfig: {
|
|
57
|
-
algorithm: 'hmac-sha256',
|
|
58
|
-
headerName: 'svix-signature',
|
|
59
|
-
headerFormat: 'raw',
|
|
60
|
-
timestampHeader: 'svix-timestamp',
|
|
61
|
-
timestampFormat: 'unix',
|
|
62
|
-
payloadFormat: 'custom',
|
|
63
|
-
customConfig: {
|
|
64
|
-
signatureFormat: 'v1={signature}',
|
|
65
|
-
payloadFormat: '{id}.{timestamp}.{body}',
|
|
66
|
-
encoding: 'base64',
|
|
67
|
-
secretEncoding: 'base64',
|
|
68
|
-
idHeader: 'svix-id',
|
|
69
|
-
idHeaderAliases: ['webhook-id'],
|
|
70
|
-
timestampHeaderAliases: ['webhook-timestamp'],
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
description: 'Svix webhooks use HMAC-SHA256 with Standard Webhooks format',
|
|
74
|
-
},
|
|
75
85
|
dodopayments: {
|
|
76
86
|
platform: "dodopayments",
|
|
77
87
|
signatureConfig: {
|
|
@@ -309,48 +319,52 @@ exports.platformAlgorithmConfigs = {
|
|
|
309
319
|
description: "Sanity webhooks use Stripe-compatible HMAC-SHA256 with base64 encoded signature and plain UTF-8 secret",
|
|
310
320
|
},
|
|
311
321
|
linear: {
|
|
312
|
-
platform:
|
|
322
|
+
platform: "linear",
|
|
313
323
|
signatureConfig: {
|
|
314
|
-
algorithm:
|
|
315
|
-
headerName:
|
|
316
|
-
headerFormat:
|
|
317
|
-
payloadFormat:
|
|
324
|
+
algorithm: "hmac-sha256",
|
|
325
|
+
headerName: "linear-signature",
|
|
326
|
+
headerFormat: "raw",
|
|
327
|
+
payloadFormat: "raw",
|
|
318
328
|
customConfig: {
|
|
319
329
|
replayToleranceMs: 60000,
|
|
320
330
|
},
|
|
321
331
|
},
|
|
322
|
-
description:
|
|
332
|
+
description: "Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window",
|
|
323
333
|
},
|
|
324
|
-
|
|
325
|
-
platform:
|
|
334
|
+
svix: {
|
|
335
|
+
platform: "svix",
|
|
326
336
|
signatureConfig: {
|
|
327
|
-
algorithm:
|
|
328
|
-
headerName:
|
|
329
|
-
headerFormat:
|
|
330
|
-
|
|
331
|
-
|
|
337
|
+
algorithm: "hmac-sha256",
|
|
338
|
+
headerName: "svix-signature",
|
|
339
|
+
headerFormat: "raw",
|
|
340
|
+
timestampHeader: "svix-timestamp",
|
|
341
|
+
timestampFormat: "unix",
|
|
342
|
+
payloadFormat: "custom",
|
|
332
343
|
customConfig: {
|
|
333
|
-
signatureFormat:
|
|
334
|
-
|
|
344
|
+
signatureFormat: "v1={signature}",
|
|
345
|
+
payloadFormat: "{id}.{timestamp}.{body}",
|
|
346
|
+
encoding: "base64",
|
|
347
|
+
secretEncoding: "base64",
|
|
348
|
+
idHeader: "svix-id",
|
|
349
|
+
idHeaderAliases: ["webhook-id"],
|
|
350
|
+
timestampHeaderAliases: ["webhook-timestamp"],
|
|
335
351
|
},
|
|
336
352
|
},
|
|
337
|
-
description:
|
|
353
|
+
description: "Svix webhooks use HMAC-SHA256 with Standard Webhooks format",
|
|
338
354
|
},
|
|
339
|
-
|
|
340
|
-
platform:
|
|
355
|
+
standardwebhooks: {
|
|
356
|
+
platform: "standardwebhooks",
|
|
341
357
|
signatureConfig: {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
validateBodySHA256: true,
|
|
351
|
-
},
|
|
358
|
+
...createStandardWebhooksConfig({
|
|
359
|
+
id: "webhook-id",
|
|
360
|
+
timestamp: "webhook-timestamp",
|
|
361
|
+
signature: "webhook-signature",
|
|
362
|
+
idAliases: ["svix-id"],
|
|
363
|
+
timestampAliases: ["svix-timestamp"],
|
|
364
|
+
signatureAliases: ["svix-signature"],
|
|
365
|
+
}),
|
|
352
366
|
},
|
|
353
|
-
description:
|
|
367
|
+
description: "Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.",
|
|
354
368
|
},
|
|
355
369
|
custom: {
|
|
356
370
|
platform: "custom",
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WebhookPlatform =
|
|
1
|
+
export type WebhookPlatform = "custom" | "clerk" | "svix" | "github" | "stripe" | "shopify" | "vercel" | "polar" | "dodopayments" | "gitlab" | "paddle" | "razorpay" | "lemonsqueezy" | "workos" | "woocommerce" | "replicateai" | "falai" | "sentry" | "grafana" | "doppler" | "sanity" | "linear" | "standardwebhooks" | "unknown";
|
|
2
2
|
export declare enum WebhookPlatformKeys {
|
|
3
3
|
GitHub = "github",
|
|
4
4
|
Stripe = "stripe",
|
|
@@ -21,24 +21,23 @@ export declare enum WebhookPlatformKeys {
|
|
|
21
21
|
Doppler = "doppler",
|
|
22
22
|
Sanity = "sanity",
|
|
23
23
|
Linear = "linear",
|
|
24
|
-
|
|
25
|
-
Twilio = "twilio",
|
|
24
|
+
StandardWebhooks = "standardwebhooks",
|
|
26
25
|
Custom = "custom",
|
|
27
26
|
Unknown = "unknown"
|
|
28
27
|
}
|
|
29
|
-
export type SignatureAlgorithm =
|
|
28
|
+
export type SignatureAlgorithm = "hmac-sha256" | "hmac-sha1" | "hmac-sha512" | "rsa-sha256" | "ed25519" | "custom";
|
|
30
29
|
export interface SignatureConfig {
|
|
31
30
|
algorithm: SignatureAlgorithm;
|
|
32
31
|
headerName: string;
|
|
33
|
-
headerFormat?:
|
|
32
|
+
headerFormat?: "raw" | "prefixed" | "comma-separated";
|
|
34
33
|
prefix?: string;
|
|
35
34
|
timestampHeader?: string;
|
|
36
|
-
timestampFormat?:
|
|
37
|
-
payloadFormat?:
|
|
35
|
+
timestampFormat?: "unix" | "iso" | "custom";
|
|
36
|
+
payloadFormat?: "raw" | "timestamped" | "json-stringified" | "custom";
|
|
38
37
|
idHeader?: string;
|
|
39
38
|
customConfig?: Record<string, any>;
|
|
40
39
|
}
|
|
41
|
-
export type WebhookErrorCode =
|
|
40
|
+
export type WebhookErrorCode = "MISSING_SIGNATURE" | "INVALID_SIGNATURE" | "TIMESTAMP_EXPIRED" | "MISSING_TOKEN" | "INVALID_TOKEN" | "PLATFORM_NOT_SUPPORTED" | "NORMALIZATION_ERROR" | "VERIFICATION_ERROR";
|
|
42
41
|
export interface WebhookVerificationResult<TPayload = unknown> {
|
|
43
42
|
isValid: boolean;
|
|
44
43
|
error?: string;
|
|
@@ -57,7 +56,6 @@ export interface WebhookConfig {
|
|
|
57
56
|
secret: string;
|
|
58
57
|
toleranceInSeconds?: number;
|
|
59
58
|
signatureConfig?: SignatureConfig;
|
|
60
|
-
twilioBaseUrl?: string;
|
|
61
59
|
}
|
|
62
60
|
export interface MultiPlatformSecrets {
|
|
63
61
|
[platform: string]: string | undefined;
|
package/dist/types.js
CHANGED
|
@@ -24,8 +24,7 @@ var WebhookPlatformKeys;
|
|
|
24
24
|
WebhookPlatformKeys["Doppler"] = "doppler";
|
|
25
25
|
WebhookPlatformKeys["Sanity"] = "sanity";
|
|
26
26
|
WebhookPlatformKeys["Linear"] = "linear";
|
|
27
|
-
WebhookPlatformKeys["
|
|
28
|
-
WebhookPlatformKeys["Twilio"] = "twilio";
|
|
27
|
+
WebhookPlatformKeys["StandardWebhooks"] = "standardwebhooks";
|
|
29
28
|
WebhookPlatformKeys["Custom"] = "custom";
|
|
30
29
|
WebhookPlatformKeys["Unknown"] = "unknown";
|
|
31
30
|
})(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
|
|
@@ -15,7 +15,6 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
|
|
|
15
15
|
protected extractTimestamp(request: Request): number | null;
|
|
16
16
|
protected extractTimestampFromSignature(request: Request): number | null;
|
|
17
17
|
protected requiresTimestamp(): boolean;
|
|
18
|
-
protected resolveTwilioSignatureUrl(request: Request): string;
|
|
19
18
|
protected formatPayload(rawBody: string, request: Request): string;
|
|
20
19
|
protected formatCustomPayload(rawBody: string, request: Request): string;
|
|
21
20
|
protected verifyHMAC(payload: string, signature: string, algorithm?: string): boolean;
|
|
@@ -25,7 +24,6 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
|
|
|
25
24
|
}
|
|
26
25
|
export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
27
26
|
private validateLinearReplayWindow;
|
|
28
|
-
private validateTwilioBodyHash;
|
|
29
27
|
private resolveSentryPayloadCandidates;
|
|
30
28
|
verify(request: Request): Promise<WebhookVerificationResult>;
|
|
31
29
|
}
|
|
@@ -26,20 +26,17 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
26
26
|
getInvalidSignatureMessage() {
|
|
27
27
|
const genericHint = `Invalid signature for ${this.platform}. Confirm webhook secret, raw request body handling, and signature header formatting.`;
|
|
28
28
|
switch (this.platform) {
|
|
29
|
-
case 'twilio':
|
|
30
|
-
return `${genericHint} Twilio also requires the exact public URL used for signing (including query params like bodySHA256). Use twilioBaseUrl if your runtime URL is rewritten behind a proxy.`;
|
|
31
29
|
case 'stripe':
|
|
32
30
|
return `${genericHint} Stripe signatures require the exact raw body and Stripe-Signature timestamp/value pair.`;
|
|
33
31
|
case 'github':
|
|
34
32
|
return `${genericHint} GitHub signatures must include the sha256= prefix from x-hub-signature-256.`;
|
|
35
33
|
case 'svix':
|
|
34
|
+
case 'standardwebhooks':
|
|
36
35
|
case 'clerk':
|
|
37
36
|
case 'dodopayments':
|
|
38
37
|
case 'replicateai':
|
|
39
38
|
case 'polar':
|
|
40
39
|
return `${genericHint} Standard Webhooks payload must be signed as id.timestamp.body and secrets may need whsec_ base64 decoding.`;
|
|
41
|
-
case 'pagerduty':
|
|
42
|
-
return `${genericHint} PagerDuty expects v1=<hex> signature values from x-pagerduty-signature.`;
|
|
43
40
|
default:
|
|
44
41
|
return genericHint;
|
|
45
42
|
}
|
|
@@ -66,7 +63,9 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
66
63
|
return values;
|
|
67
64
|
}
|
|
68
65
|
extractSignatures(request) {
|
|
69
|
-
const headerValue = request.headers.get(this.config.headerName)
|
|
66
|
+
const headerValue = request.headers.get(this.config.headerName)
|
|
67
|
+
|| this.config.customConfig?.signatureHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean)
|
|
68
|
+
|| null;
|
|
70
69
|
if (!headerValue)
|
|
71
70
|
return [];
|
|
72
71
|
switch (this.config.headerFormat) {
|
|
@@ -169,7 +168,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
169
168
|
return true;
|
|
170
169
|
// These platforms have timestampHeader in config but timestamp
|
|
171
170
|
// is optional in their spec — validate only if present, never mandate
|
|
172
|
-
const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana'
|
|
171
|
+
const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana'];
|
|
173
172
|
if (optionalTimestampPlatforms.includes(this.platform))
|
|
174
173
|
return false;
|
|
175
174
|
// For all other platforms: infer from config
|
|
@@ -186,16 +185,6 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
186
185
|
return true;
|
|
187
186
|
return false;
|
|
188
187
|
}
|
|
189
|
-
resolveTwilioSignatureUrl(request) {
|
|
190
|
-
const overrideBaseUrl = this.config.customConfig?.twilioBaseUrl;
|
|
191
|
-
if (!overrideBaseUrl) {
|
|
192
|
-
return request.url;
|
|
193
|
-
}
|
|
194
|
-
const requestUrl = new URL(request.url);
|
|
195
|
-
const baseUrl = new URL(overrideBaseUrl);
|
|
196
|
-
baseUrl.search = requestUrl.search;
|
|
197
|
-
return baseUrl.toString();
|
|
198
|
-
}
|
|
199
188
|
formatPayload(rawBody, request) {
|
|
200
189
|
switch (this.config.payloadFormat) {
|
|
201
190
|
case "timestamped": {
|
|
@@ -226,7 +215,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
226
215
|
const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id") || this.config.customConfig?.idHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean);
|
|
227
216
|
const timestamp = request.headers.get(this.config.timestampHeader ||
|
|
228
217
|
this.config.customConfig?.timestampHeader ||
|
|
229
|
-
"x-webhook-timestamp");
|
|
218
|
+
"x-webhook-timestamp") || this.config.customConfig?.timestampHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean);
|
|
230
219
|
// if either is missing payload will be malformed — fail explicitly
|
|
231
220
|
if (!id || !timestamp) {
|
|
232
221
|
throw new Error(`Missing required headers for payload construction: ${!id ? this.config.customConfig.idHeader || "x-webhook-id" : ""} ${!timestamp
|
|
@@ -240,7 +229,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
240
229
|
}
|
|
241
230
|
if (customFormat.includes('{url}')) {
|
|
242
231
|
return customFormat
|
|
243
|
-
.replace('{url}',
|
|
232
|
+
.replace('{url}', request.url)
|
|
244
233
|
.replace('{body}', rawBody);
|
|
245
234
|
}
|
|
246
235
|
if (customFormat.includes("{timestamp}") &&
|
|
@@ -346,20 +335,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
346
335
|
}
|
|
347
336
|
return null;
|
|
348
337
|
}
|
|
349
|
-
validateTwilioBodyHash(rawBody, request) {
|
|
350
|
-
if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) {
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
const url = new URL(this.resolveTwilioSignatureUrl(request));
|
|
354
|
-
const bodySha = url.searchParams.get('bodySHA256');
|
|
355
|
-
if (!bodySha)
|
|
356
|
-
return null;
|
|
357
|
-
const computed = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
|
|
358
|
-
if (!this.safeCompare(computed, bodySha)) {
|
|
359
|
-
return 'Twilio bodySHA256 query param does not match payload hash';
|
|
360
|
-
}
|
|
361
|
-
return null;
|
|
362
|
-
}
|
|
363
338
|
resolveSentryPayloadCandidates(rawBody, request) {
|
|
364
339
|
const candidates = [
|
|
365
340
|
this.formatPayload(rawBody, request),
|
|
@@ -408,15 +383,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
408
383
|
platform: this.platform,
|
|
409
384
|
};
|
|
410
385
|
}
|
|
411
|
-
const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request);
|
|
412
|
-
if (twilioBodyHashError) {
|
|
413
|
-
return {
|
|
414
|
-
isValid: false,
|
|
415
|
-
error: twilioBodyHashError,
|
|
416
|
-
errorCode: 'INVALID_SIGNATURE',
|
|
417
|
-
platform: this.platform,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
386
|
let timestamp = null;
|
|
421
387
|
if (this.config.headerFormat === "comma-separated") {
|
|
422
388
|
timestamp = this.extractTimestampFromSignature(request);
|
package/package.json
CHANGED