diff --git a/README.md b/README.md index f43441d..27ea6df 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json) -Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. +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. > 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). @@ -79,27 +79,6 @@ const result = await WebhookVerificationService.verifyAny(request, { console.log(`Verified ${result.platform} webhook`); ``` -### Twilio example - -```typescript -import { WebhookVerificationService } from '@hookflo/tern'; - -export async function POST(request: Request) { - const result = await WebhookVerificationService.verify(request, { - platform: 'twilio', - secret: process.env.TWILIO_AUTH_TOKEN!, - // Optional when behind proxies/CDNs if request.url differs from the public Twilio URL: - twilioBaseUrl: 'https://yourdomain.com/api/webhooks/twilio', - }); - - if (!result.isValid) { - return Response.json({ error: result.error }, { status: 400 }); - } - - return Response.json({ ok: true }); -} -``` - ### Core SDK (runtime-agnostic) Use Tern without framework adapters in any runtime that supports the Web `Request` API. @@ -214,9 +193,8 @@ app.post('/webhooks/stripe', createWebhookHandler({ | **Doppler** | HMAC-SHA256 | ✅ Tested | | **Sanity** | HMAC-SHA256 | ✅ Tested | | **Svix** | HMAC-SHA256 | ⚠️ Untested for now | +| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested | | **Linear** | HMAC-SHA256 | ⚠️ Untested for now | -| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now | -| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now | | **Razorpay** | HMAC-SHA256 | 🔄 Pending | | **Vercel** | HMAC-SHA256 | 🔄 Pending | @@ -224,7 +202,7 @@ app.post('/webhooks/stripe', createWebhookHandler({ ### Platform signature notes -- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`. +- **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_...`. - **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`. - **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly. @@ -365,25 +343,31 @@ const result = await WebhookVerificationService.verify(request, { }); ``` -### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.) +### Standard Webhooks config helpers (Svix-style and webhook-* headers) ```typescript -const svixConfig = { - platform: 'my-svix-platform', - secret: 'whsec_abc123...', +import { + createStandardWebhooksConfig, + STANDARD_WEBHOOKS_BASE, +} from '@hookflo/tern'; + +const signatureConfig = createStandardWebhooksConfig({ + id: 'webhook-id', + timestamp: 'webhook-timestamp', + signature: 'webhook-signature', + idAliases: ['svix-id'], + timestampAliases: ['svix-timestamp'], + signatureAliases: ['svix-signature'], +}); + +const result = await WebhookVerificationService.verify(request, { + platform: 'standardwebhooks', + secret: process.env.STANDARD_WEBHOOKS_SECRET!, signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'webhook-signature', - headerFormat: 'raw', - timestampHeader: 'webhook-timestamp', - timestampFormat: 'unix', - payloadFormat: 'custom', - customConfig: { - payloadFormat: '{id}.{timestamp}.{body}', - idHeader: 'webhook-id', - }, + ...STANDARD_WEBHOOKS_BASE, + ...signatureConfig, }, -}; +}); ``` See the [SignatureConfig type](https://tern.hookflo.com) for all options. @@ -431,8 +415,6 @@ interface WebhookVerificationResult { ## Troubleshooting -- **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`. - **`Module not found: Can't resolve "@hookflo/tern/nextjs"`** diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index 2125f18..b565599 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -10,7 +10,6 @@ export interface CloudflareWebhookHandlerOptions, secret?: string; secretEnv?: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -66,7 +65,6 @@ export function createWebhookHandler, TPayload = platform: options.platform, secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/express.ts b/src/adapters/express.ts index 93721bf..6b57025 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -24,7 +24,6 @@ export interface ExpressWebhookMiddlewareOptions { platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -93,7 +92,6 @@ export function createWebhookMiddleware( platform: options.platform, secret: options.secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/hono.ts b/src/adapters/hono.ts index 26e8a1a..7df5d80 100644 --- a/src/adapters/hono.ts +++ b/src/adapters/hono.ts @@ -21,7 +21,6 @@ export interface HonoWebhookHandlerOptions< platform: WebhookPlatform; secret: string; toleranceInSeconds?: number; - twilioBaseUrl?: string; queue?: QueueOption; alerts?: AlertConfig; alert?: Omit; @@ -77,7 +76,6 @@ export function createWebhookHandler< platform: options.platform, secret: options.secret, toleranceInSeconds: options.toleranceInSeconds, - twilioBaseUrl: options.twilioBaseUrl, }, ); diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index 8b1a460..be3ca77 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -9,7 +9,6 @@ export interface NextWebhookHandlerOptions; @@ -58,7 +57,6 @@ export function createWebhookHandler signatures', - }, - - twilio: { - platform: 'twilio', - signatureConfig: { - algorithm: 'hmac-sha1', - headerName: 'x-twilio-signature', - headerFormat: 'raw', - payloadFormat: 'custom', - customConfig: { - payloadFormat: '{url}', - encoding: 'base64', - secretEncoding: 'utf8', - validateBodySHA256: true, - }, + ...createStandardWebhooksConfig({ + id: 'webhook-id', + timestamp: 'webhook-timestamp', + signature: 'webhook-signature', + idAliases: ['svix-id'], + timestampAliases: ['svix-timestamp'], + signatureAliases: ['svix-signature'], + }), }, - description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)', + description: 'Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.', }, custom: { diff --git a/src/test.ts b/src/test.ts index 7311cd5..337bf5b 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,5 +1,6 @@ import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto'; import { WebhookVerificationService } from './index'; +import { platformAlgorithmConfigs } from './platforms/algorithms'; import { normalizeAlertOptions } from './notifications/utils'; import { buildSlackPayload } from './notifications/channels/slack'; import { buildDiscordPayload } from './notifications/channels/discord'; @@ -114,32 +115,12 @@ function createSanitySignature(body: string, secret: string, timestamp: number): return `t=${timestamp},v1=${hmac.digest('base64')}`; } -function createPagerDutySignature(body: string, secret: string): string { - const hmac = createHmac('sha256', secret); - hmac.update(body); - return `v1=${hmac.digest('hex')}`; -} - function createLinearSignature(body: string, secret: string): string { const hmac = createHmac('sha256', secret); hmac.update(body); return hmac.digest('hex'); } -function createSvixSignature(body: string, secret: string, id: string, timestamp: number): string { - const signedContent = `${id}.${timestamp}.${body}`; - const secretBytes = new Uint8Array(Buffer.from(secret.split('whsec_')[1], 'base64')); - const hmac = createHmac('sha256', secretBytes); - hmac.update(signedContent); - return `v1,${hmac.digest('base64')}`; -} - -function createTwilioSignature(url: string, authToken: string): string { - const hmac = createHmac('sha1', authToken); - hmac.update(url); - return hmac.digest('base64'); -} - function createFalPayloadToSign(body: string, requestId: string, userId: string, timestamp: string): string { const bodyHash = createHash('sha256').update(body).digest('hex'); return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`; @@ -963,25 +944,30 @@ async function runTests() { console.log(' ❌ Hono invalid signature test failed:', error); } - // Test 26: PagerDuty platform verification - console.log('\n26. Testing PagerDuty platform verification...'); + // Test 26: Standard Webhooks canonical platform with webhook-* headers + console.log('\n26. Testing standardwebhooks with webhook-* headers...'); try { - const payload = JSON.stringify({ messages: [{ event: 'incident.triggered' }] }); - const signature = createPagerDutySignature(payload, testSecret); + const payload = JSON.stringify({ type: 'invoice.paid' }); + const id = 'msg_standard_001'; + const timestamp = Math.floor(Date.now() / 1000); + const standardSecret = `whsec_${Buffer.from(testSecret).toString('base64')}`; + const signature = createStandardWebhooksSignature(payload, standardSecret, id, timestamp); const request = createMockRequest({ - 'x-pagerduty-signature': `${signature},v1=deadbeef`, + 'webhook-id': id, + 'webhook-timestamp': String(timestamp), + 'webhook-signature': `${signature} v1,invalid`, 'content-type': 'application/json', }, payload); const result = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'pagerduty', - testSecret, + 'standardwebhooks', + standardSecret, ); - console.log(' ✅ PagerDuty:', trackCheck('pagerduty platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + console.log(' ✅ standardwebhooks (webhook-*):', trackCheck('standardwebhooks webhook headers', result.isValid, result.error) ? 'PASSED' : 'FAILED'); } catch (error) { - console.log(' ❌ PagerDuty platform verifier test failed:', error); + console.log(' ❌ standardwebhooks webhook-* test failed:', error); } // Test 27: Linear platform verification with replay protection @@ -1008,14 +994,14 @@ async function runTests() { console.log(' ❌ Linear platform verifier test failed:', error); } - // Test 28: Svix platform verification with replay protection - console.log('\n28. Testing Svix platform verification...'); + // Test 28: Standard Webhooks canonical platform with svix-* aliases + console.log('\n28. Testing standardwebhooks with svix-* aliases...'); try { const id = 'msg_2LJC7S5QfRZk9k9bM2QxWjv1l3U'; const timestamp = Math.floor(Date.now() / 1000); const payload = JSON.stringify({ type: 'invoice.paid' }); const svixSecret = `whsec_${Buffer.from(testSecret).toString('base64')}`; - const signature = createSvixSignature(payload, svixSecret, id, timestamp); + const signature = createStandardWebhooksSignature(payload, svixSecret, id, timestamp); const request = createMockRequest({ 'svix-id': id, @@ -1026,80 +1012,29 @@ async function runTests() { const result = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'svix', + 'standardwebhooks', svixSecret, ); - console.log(' ✅ Svix:', trackCheck('svix platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + console.log(' ✅ standardwebhooks (svix-* aliases):', trackCheck('standardwebhooks svix aliases', result.isValid, result.error) ? 'PASSED' : 'FAILED'); } catch (error) { - console.log(' ❌ Svix platform verifier test failed:', error); + console.log(' ❌ standardwebhooks svix aliases test failed:', error); } - - // Test 29.5: Twilio verification with twilioBaseUrl override - console.log('\n29.5. Testing Twilio verification with twilioBaseUrl override...'); + // Test 29: standardwebhooks factory output matches dodopayments shape modulo aliases + console.log('\n29. Testing standardwebhooks structure parity with dodopayments...'); try { - const payload = JSON.stringify({ messageSid: 'SM123', status: 'delivered' }); - const bodySha256 = createHash('sha256').update(payload).digest('hex'); - const publicUrl = `https://prateekjn.me/api/webhooks/stripe?bodySHA256=${bodySha256}`; - const internalUrl = `http://127.0.0.1:3000/internal/webhook?bodySHA256=${bodySha256}`; - const signature = createTwilioSignature(publicUrl, testSecret); - - const request = new Request(internalUrl, { - method: 'POST', - headers: { - 'x-twilio-signature': signature, - 'content-type': 'application/json', - }, - body: payload, - }); - - const withoutOverride = await WebhookVerificationService.verifyWithPlatformConfig( - request.clone(), - 'twilio', - testSecret, - ); - - const withOverride = await WebhookVerificationService.verify( - request, - { - platform: 'twilio', - secret: testSecret, - twilioBaseUrl: 'https://prateekjn.me/api/webhooks/stripe', - }, - ); - - const pass = !withoutOverride.isValid && withOverride.isValid; - console.log(' ✅ Twilio base URL override:', trackCheck('twilio base url override', pass, withOverride.error || withoutOverride.error) ? 'PASSED' : 'FAILED'); - } catch (error) { - console.log(' ❌ Twilio base URL override test failed:', error); - } - - // Test 29: Twilio platform verification (JSON + bodySHA256) - console.log('\n29. Testing Twilio platform verification...'); - try { - const payload = JSON.stringify({ callSid: 'CA123', status: 'completed' }); - const bodySha256 = createHash('sha256').update(payload).digest('hex'); - const url = `https://example.com/twilio/webhook?bodySHA256=${bodySha256}`; - const signature = createTwilioSignature(url, testSecret); - const request = new Request(url, { - method: 'POST', - headers: { - 'x-twilio-signature': signature, - 'content-type': 'application/json', - }, - body: payload, - }); - - const result = await WebhookVerificationService.verifyWithPlatformConfig( - request, - 'twilio', - testSecret, - ); - - console.log(' ✅ Twilio:', trackCheck('twilio platform verifier', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + const { + idHeaderAliases, + timestampHeaderAliases, + signatureHeaderAliases, + ...standardCustomBase + } = platformAlgorithmConfigs.standardwebhooks.signatureConfig.customConfig || {}; + const dodoCustomConfig = platformAlgorithmConfigs.dodopayments.signatureConfig.customConfig || {}; + const pass = JSON.stringify(standardCustomBase) === JSON.stringify(dodoCustomConfig); + console.log(' ✅ standardwebhooks shape parity:', trackCheck('standardwebhooks shape parity', pass) ? 'PASSED' : 'FAILED'); } catch (error) { - console.log(' ❌ Twilio platform verifier test failed:', error); + console.log(' ❌ standardwebhooks shape parity test failed:', error); } if (failedChecks.length > 0) { diff --git a/src/types.ts b/src/types.ts index b012cf3..0c3340d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,8 +21,7 @@ export type WebhookPlatform = | 'doppler' | 'sanity' | 'linear' - | 'pagerduty' - | 'twilio' + | 'standardwebhooks' | 'unknown'; export enum WebhookPlatformKeys { @@ -47,8 +46,7 @@ export enum WebhookPlatformKeys { Doppler = 'doppler', Sanity = 'sanity', Linear = 'linear', - PagerDuty = 'pagerduty', - Twilio = 'twilio', + StandardWebhooks = 'standardwebhooks', Custom = 'custom', Unknown = 'unknown' } @@ -104,8 +102,6 @@ export interface WebhookConfig { toleranceInSeconds?: number; // New fields for algorithm-based verification signatureConfig?: SignatureConfig; - // Optional override for Twilio signature URL construction (useful behind proxies/CDNs) - twilioBaseUrl?: string; } export interface MultiPlatformSecrets { diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index e59397a..99e31fa 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -53,20 +53,17 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { const genericHint = `Invalid signature for ${this.platform}. Confirm webhook secret, raw request body handling, and signature header formatting.`; switch (this.platform) { - case 'twilio': - 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.`; case 'stripe': return `${genericHint} Stripe signatures require the exact raw body and Stripe-Signature timestamp/value pair.`; case 'github': return `${genericHint} GitHub signatures must include the sha256= prefix from x-hub-signature-256.`; case 'svix': + case 'standardwebhooks': case 'clerk': case 'dodopayments': case 'replicateai': case 'polar': return `${genericHint} Standard Webhooks payload must be signed as id.timestamp.body and secrets may need whsec_ base64 decoding.`; - case 'pagerduty': - return `${genericHint} PagerDuty expects v1= signature values from x-pagerduty-signature.`; default: return genericHint; } @@ -97,7 +94,9 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { } protected extractSignatures(request: Request): string[] { - const headerValue = request.headers.get(this.config.headerName); + const headerValue: string | null = request.headers.get(this.config.headerName) + || this.config.customConfig?.signatureHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean) + || null; if (!headerValue) return []; switch (this.config.headerFormat) { @@ -208,7 +207,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { // These platforms have timestampHeader in config but timestamp // is optional in their spec — validate only if present, never mandate - const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana', 'twilio']; + const optionalTimestampPlatforms = ['vercel', 'sentry', 'grafana']; if (optionalTimestampPlatforms.includes(this.platform as string)) return false; // For all other platforms: infer from config @@ -227,19 +226,6 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { return false; } - protected resolveTwilioSignatureUrl(request: Request): string { - const overrideBaseUrl = this.config.customConfig?.twilioBaseUrl as string | undefined; - if (!overrideBaseUrl) { - return request.url; - } - - const requestUrl = new URL(request.url); - const baseUrl = new URL(overrideBaseUrl); - baseUrl.search = requestUrl.search; - - return baseUrl.toString(); - } - protected formatPayload(rawBody: string, request: Request): string { switch (this.config.payloadFormat) { case "timestamped": { @@ -277,7 +263,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { this.config.timestampHeader || this.config.customConfig?.timestampHeader || "x-webhook-timestamp", - ); + ) || this.config.customConfig?.timestampHeaderAliases?.map((alias: string) => request.headers.get(alias)).find(Boolean); // if either is missing payload will be malformed — fail explicitly if (!id || !timestamp) { @@ -300,7 +286,7 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { if (customFormat.includes('{url}')) { return customFormat - .replace('{url}', this.platform === 'twilio' ? this.resolveTwilioSignatureUrl(request) : request.url) + .replace('{url}', request.url) .replace('{body}', rawBody); } @@ -444,23 +430,6 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { return null; } - private validateTwilioBodyHash(rawBody: string, request: Request): string | null { - if (this.platform !== 'twilio' || !this.config.customConfig?.validateBodySHA256) { - return null; - } - - const url = new URL(this.resolveTwilioSignatureUrl(request)); - const bodySha = url.searchParams.get('bodySHA256'); - if (!bodySha) return null; - - const computed = createHash('sha256').update(rawBody).digest('hex'); - if (!this.safeCompare(computed, bodySha)) { - return 'Twilio bodySHA256 query param does not match payload hash'; - } - - return null; - } - private resolveSentryPayloadCandidates( rawBody: string, request: Request, @@ -520,16 +489,6 @@ export class GenericHMACVerifier extends AlgorithmBasedVerifier { }; } - const twilioBodyHashError = this.validateTwilioBodyHash(rawBody, request); - if (twilioBodyHashError) { - return { - isValid: false, - error: twilioBodyHashError, - errorCode: 'INVALID_SIGNATURE', - platform: this.platform, - }; - } - let timestamp: number | null = null; if (this.config.headerFormat === "comma-separated") { timestamp = this.extractTimestampFromSignature(request);