@hookflo/tern 4.3.0-beta.0 → 4.3.1-beta

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
@@ -189,6 +189,10 @@ app.post('/webhooks/stripe', createWebhookHandler({
189
189
  | **Grafana** | HMAC-SHA256 | ✅ Tested |
190
190
  | **Doppler** | HMAC-SHA256 | ✅ Tested |
191
191
  | **Sanity** | HMAC-SHA256 | ✅ Tested |
192
+ | **Svix** | HMAC-SHA256 | ⚠️ Untested for now |
193
+ | **Linear** | HMAC-SHA256 | ⚠️ Untested for now |
194
+ | **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now |
195
+ | **Twilio** | HMAC-SHA1 | ⚠️ Untested for now |
192
196
  | **Razorpay** | HMAC-SHA256 | 🔄 Pending |
193
197
  | **Vercel** | HMAC-SHA256 | 🔄 Pending |
194
198
 
package/dist/index.js CHANGED
@@ -189,6 +189,10 @@ class WebhookVerificationService {
189
189
  case 'workos':
190
190
  case 'sentry':
191
191
  case 'vercel':
192
+ case 'linear':
193
+ case 'pagerduty':
194
+ case 'twilio':
195
+ case 'svix':
192
196
  return this.pickString(payload?.id) || null;
193
197
  case 'doppler':
194
198
  return this.pickString(payload?.event?.id, metadata?.id) || null;
@@ -220,7 +224,13 @@ class WebhookVerificationService {
220
224
  if (headers.has('x-hub-signature-256'))
221
225
  return 'github';
222
226
  if (headers.has('svix-signature'))
223
- return 'clerk';
227
+ return headers.has('svix-id') ? 'svix' : 'clerk';
228
+ if (headers.has('linear-signature'))
229
+ return 'linear';
230
+ if (headers.has('x-pagerduty-signature'))
231
+ return 'pagerduty';
232
+ if (headers.has('x-twilio-signature'))
233
+ return 'twilio';
224
234
  if (headers.has('workos-signature'))
225
235
  return 'workos';
226
236
  if (headers.has('webhook-signature')) {
@@ -51,6 +51,27 @@ exports.platformAlgorithmConfigs = {
51
51
  },
52
52
  description: "Clerk webhooks use HMAC-SHA256 with base64 encoding",
53
53
  },
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
+ },
54
75
  dodopayments: {
55
76
  platform: "dodopayments",
56
77
  signatureConfig: {
@@ -287,6 +308,50 @@ exports.platformAlgorithmConfigs = {
287
308
  },
288
309
  description: "Sanity webhooks use Stripe-compatible HMAC-SHA256 with base64 encoded signature and plain UTF-8 secret",
289
310
  },
311
+ linear: {
312
+ platform: 'linear',
313
+ signatureConfig: {
314
+ algorithm: 'hmac-sha256',
315
+ headerName: 'linear-signature',
316
+ headerFormat: 'raw',
317
+ payloadFormat: 'raw',
318
+ customConfig: {
319
+ replayToleranceMs: 60000,
320
+ },
321
+ },
322
+ description: 'Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window',
323
+ },
324
+ pagerduty: {
325
+ platform: 'pagerduty',
326
+ signatureConfig: {
327
+ algorithm: 'hmac-sha256',
328
+ headerName: 'x-pagerduty-signature',
329
+ headerFormat: 'raw',
330
+ payloadFormat: 'raw',
331
+ prefix: 'v1=',
332
+ customConfig: {
333
+ signatureFormat: 'v1={signature}',
334
+ comparePrefixed: true,
335
+ },
336
+ },
337
+ description: 'PagerDuty webhooks use HMAC-SHA256 with v1=<hex> signatures',
338
+ },
339
+ twilio: {
340
+ platform: 'twilio',
341
+ signatureConfig: {
342
+ algorithm: 'hmac-sha1',
343
+ headerName: 'x-twilio-signature',
344
+ headerFormat: 'raw',
345
+ payloadFormat: 'custom',
346
+ customConfig: {
347
+ payloadFormat: '{url}',
348
+ encoding: 'base64',
349
+ secretEncoding: 'utf8',
350
+ validateBodySHA256: true,
351
+ },
352
+ },
353
+ description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)',
354
+ },
290
355
  custom: {
291
356
  platform: "custom",
292
357
  signatureConfig: {
package/dist/types.d.ts CHANGED
@@ -1,8 +1,9 @@
1
- export type WebhookPlatform = 'custom' | 'clerk' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'paddle' | 'razorpay' | 'lemonsqueezy' | 'workos' | 'woocommerce' | 'replicateai' | 'falai' | 'sentry' | 'grafana' | 'doppler' | 'sanity' | 'unknown';
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' | 'pagerduty' | 'twilio' | 'unknown';
2
2
  export declare enum WebhookPlatformKeys {
3
3
  GitHub = "github",
4
4
  Stripe = "stripe",
5
5
  Clerk = "clerk",
6
+ Svix = "svix",
6
7
  DodoPayments = "dodopayments",
7
8
  Shopify = "shopify",
8
9
  Vercel = "vercel",
@@ -19,6 +20,9 @@ export declare enum WebhookPlatformKeys {
19
20
  Grafana = "grafana",
20
21
  Doppler = "doppler",
21
22
  Sanity = "sanity",
23
+ Linear = "linear",
24
+ PagerDuty = "pagerduty",
25
+ Twilio = "twilio",
22
26
  Custom = "custom",
23
27
  Unknown = "unknown"
24
28
  }
package/dist/types.js CHANGED
@@ -6,6 +6,7 @@ var WebhookPlatformKeys;
6
6
  WebhookPlatformKeys["GitHub"] = "github";
7
7
  WebhookPlatformKeys["Stripe"] = "stripe";
8
8
  WebhookPlatformKeys["Clerk"] = "clerk";
9
+ WebhookPlatformKeys["Svix"] = "svix";
9
10
  WebhookPlatformKeys["DodoPayments"] = "dodopayments";
10
11
  WebhookPlatformKeys["Shopify"] = "shopify";
11
12
  WebhookPlatformKeys["Vercel"] = "vercel";
@@ -22,6 +23,9 @@ var WebhookPlatformKeys;
22
23
  WebhookPlatformKeys["Grafana"] = "grafana";
23
24
  WebhookPlatformKeys["Doppler"] = "doppler";
24
25
  WebhookPlatformKeys["Sanity"] = "sanity";
26
+ WebhookPlatformKeys["Linear"] = "linear";
27
+ WebhookPlatformKeys["PagerDuty"] = "pagerduty";
28
+ WebhookPlatformKeys["Twilio"] = "twilio";
25
29
  WebhookPlatformKeys["Custom"] = "custom";
26
30
  WebhookPlatformKeys["Unknown"] = "unknown";
27
31
  })(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
@@ -18,6 +18,8 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
18
18
  protected extractMetadata(request: Request): Record<string, any>;
19
19
  }
20
20
  export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
21
+ private validateLinearReplayWindow;
22
+ private validateTwilioBodyHash;
21
23
  private resolveSentryPayloadCandidates;
22
24
  verify(request: Request): Promise<WebhookVerificationResult>;
23
25
  }
@@ -63,9 +63,34 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
63
63
  }
64
64
  // Accept "v1=<signature>" variants used by some providers/docs.
65
65
  if (sig.startsWith("v1=")) {
66
- const [, value] = sig.split("=", 2);
67
- if (value) {
68
- normalized.push(value.trim());
66
+ if (this.config.customConfig?.comparePrefixed) {
67
+ for (const fragment of sig.split(',')) {
68
+ const candidate = fragment.trim();
69
+ if (candidate.startsWith('v1=')) {
70
+ normalized.push(candidate);
71
+ }
72
+ }
73
+ }
74
+ else {
75
+ const [, value] = sig.split("=", 2);
76
+ if (value) {
77
+ normalized.push(value.trim());
78
+ }
79
+ }
80
+ continue;
81
+ }
82
+ for (const fragment of sig.split(',')) {
83
+ const candidate = fragment.trim();
84
+ if (candidate.startsWith('v1=')) {
85
+ if (this.config.customConfig?.comparePrefixed) {
86
+ normalized.push(candidate);
87
+ }
88
+ else {
89
+ const [, value] = candidate.split('=', 2);
90
+ if (value) {
91
+ normalized.push(value.trim());
92
+ }
93
+ }
69
94
  }
70
95
  }
71
96
  }
@@ -77,7 +102,9 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
77
102
  extractTimestamp(request) {
78
103
  if (!this.config.timestampHeader)
79
104
  return null;
80
- const timestampHeader = request.headers.get(this.config.timestampHeader);
105
+ const timestampHeader = request.headers.get(this.config.timestampHeader)
106
+ || this.config.customConfig?.timestampHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean)
107
+ || null;
81
108
  if (!timestampHeader)
82
109
  return null;
83
110
  switch (this.config.timestampFormat) {
@@ -108,7 +135,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
108
135
  return true;
109
136
  // These platforms have timestampHeader in config but timestamp
110
137
  // is optional in their spec — validate only if present, never mandate
111
- const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana'];
138
+ const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana', 'twilio'];
112
139
  if (optionalTimestampPlatforms.includes(this.platform))
113
140
  return false;
114
141
  // For all other platforms: infer from config
@@ -152,7 +179,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
152
179
  }
153
180
  const customFormat = this.config.customConfig.payloadFormat;
154
181
  if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) {
155
- const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id");
182
+ const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id") || this.config.customConfig?.idHeaderAliases?.map((alias) => request.headers.get(alias)).find(Boolean);
156
183
  const timestamp = request.headers.get(this.config.timestampHeader ||
157
184
  this.config.customConfig?.timestampHeader ||
158
185
  "x-webhook-timestamp");
@@ -167,6 +194,11 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
167
194
  .replace("{timestamp}", timestamp.trim() || "")
168
195
  .replace("{body}", rawBody);
169
196
  }
197
+ if (customFormat.includes('{url}')) {
198
+ return customFormat
199
+ .replace('{url}', request.url)
200
+ .replace('{body}', rawBody);
201
+ }
170
202
  if (customFormat.includes("{timestamp}") &&
171
203
  customFormat.includes("{body}")) {
172
204
  const timestamp = this.extractTimestampFromSignature(request) ||
@@ -250,6 +282,40 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
250
282
  }
251
283
  exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
252
284
  class GenericHMACVerifier extends AlgorithmBasedVerifier {
285
+ validateLinearReplayWindow(rawBody) {
286
+ if (this.platform !== 'linear')
287
+ return null;
288
+ try {
289
+ const parsed = JSON.parse(rawBody);
290
+ const rawTimestamp = parsed.webhookTimestamp;
291
+ const timestampMs = Number(rawTimestamp);
292
+ if (!Number.isFinite(timestampMs)) {
293
+ return 'Missing or invalid Linear webhookTimestamp';
294
+ }
295
+ const replayToleranceMs = this.config.customConfig?.replayToleranceMs || 60000;
296
+ if (Math.abs(Date.now() - timestampMs) > replayToleranceMs) {
297
+ return 'Linear webhook timestamp is outside the replay window';
298
+ }
299
+ }
300
+ catch {
301
+ return 'Linear webhook replay check requires JSON payload';
302
+ }
303
+ return null;
304
+ }
305
+ validateTwilioBodyHash(rawBody, request) {
306
+ if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) {
307
+ return null;
308
+ }
309
+ const url = new URL(request.url);
310
+ const bodySha = url.searchParams.get('bodySHA256');
311
+ if (!bodySha)
312
+ return null;
313
+ const computed = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
314
+ if (!this.safeCompare(computed, bodySha)) {
315
+ return 'Twilio bodySHA256 query param does not match payload hash';
316
+ }
317
+ return null;
318
+ }
253
319
  resolveSentryPayloadCandidates(rawBody, request) {
254
320
  const candidates = [
255
321
  this.formatPayload(rawBody, request),
@@ -289,6 +355,24 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
289
355
  };
290
356
  }
291
357
  const rawBody = await request.text();
358
+ const linearReplayError = this.validateLinearReplayWindow(rawBody);
359
+ if (linearReplayError) {
360
+ return {
361
+ isValid: false,
362
+ error: linearReplayError,
363
+ errorCode: 'TIMESTAMP_EXPIRED',
364
+ platform: this.platform,
365
+ };
366
+ }
367
+ const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request);
368
+ if (twilioBodyHashError) {
369
+ return {
370
+ isValid: false,
371
+ error: twilioBodyHashError,
372
+ errorCode: 'INVALID_SIGNATURE',
373
+ platform: this.platform,
374
+ };
375
+ }
292
376
  let timestamp = null;
293
377
  if (this.config.headerFormat === "comma-separated") {
294
378
  timestamp = this.extractTimestampFromSignature(request);
@@ -322,7 +406,7 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
322
406
  if (this.config.customConfig?.encoding === "base64") {
323
407
  isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
324
408
  }
325
- else if (this.config.headerFormat === "prefixed") {
409
+ else if (this.config.headerFormat === "prefixed" || this.config.customConfig?.comparePrefixed) {
326
410
  isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
327
411
  }
328
412
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "4.3.0-beta.0",
3
+ "version": "4.3.1-beta",
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",