Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ 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.
Expand Down Expand Up @@ -171,6 +192,9 @@ app.post('/webhooks/stripe', createWebhookHandler({

## Supported Platforms

> ⚠️ Normalization is no longer supported in Tern and has been removed from the public verification APIs.


| Platform | Algorithm | Status |
|---|---|---|
| **Stripe** | HMAC-SHA256 | ✅ Tested |
Expand All @@ -189,6 +213,10 @@ app.post('/webhooks/stripe', createWebhookHandler({
| **Grafana** | HMAC-SHA256 | ✅ Tested |
| **Doppler** | HMAC-SHA256 | ✅ Tested |
| **Sanity** | HMAC-SHA256 | ✅ Tested |
| **Svix** | HMAC-SHA256 | ⚠️ Untested for now |
| **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 |

Expand Down Expand Up @@ -403,6 +431,9 @@ 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"`**

```bash
Expand Down
16 changes: 9 additions & 7 deletions src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { WebhookPlatform, NormalizeOptions } from '../types';
import { WebhookPlatform } from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
import { dispatchWebhookAlert } from '../notifications/dispatch';
import type { AlertConfig, SendAlertOptions } from '../notifications/types';

export interface CloudflareWebhookHandlerOptions<TEnv = Record<string, unknown>, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown> {

Check warning on line 8 in src/adapters/cloudflare.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
platform: WebhookPlatform;
secret?: string;
secretEnv?: string;
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
twilioBaseUrl?: string;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -18,7 +18,7 @@
handler: (payload: TPayload, env: TEnv, metadata: TMetadata) => Promise<TResponse> | TResponse;
}

export function createWebhookHandler<TEnv = Record<string, unknown>, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown>(

Check warning on line 21 in src/adapters/cloudflare.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
options: CloudflareWebhookHandlerOptions<TEnv, TPayload, TMetadata, TResponse>,
) {
return async (request: Request, env: TEnv): Promise<Response> => {
Expand Down Expand Up @@ -60,12 +60,14 @@
return response;
}

const result = await WebhookVerificationService.verifyWithPlatformConfig(
const result = await WebhookVerificationService.verify(
request,
options.platform,
secret,
options.toleranceInSeconds,
options.normalize,
{
platform: options.platform,
secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

if (!result.isValid) {
Expand Down
15 changes: 8 additions & 7 deletions src/adapters/express.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
WebhookPlatform,
WebhookVerificationResult,
NormalizeOptions,
} from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
Expand All @@ -25,7 +24,7 @@ export interface ExpressWebhookMiddlewareOptions {
platform: WebhookPlatform;
secret: string;
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
twilioBaseUrl?: string;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand Down Expand Up @@ -88,12 +87,14 @@ export function createWebhookMiddleware(
return;
}

const result = await WebhookVerificationService.verifyWithPlatformConfig(
const result = await WebhookVerificationService.verify(
webRequest,
options.platform,
options.secret,
options.toleranceInSeconds,
options.normalize,
{
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

if (!result.isValid) {
Expand Down
16 changes: 9 additions & 7 deletions src/adapters/hono.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WebhookPlatform, NormalizeOptions } from '../types';
import { WebhookPlatform } from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
Expand All @@ -14,14 +14,14 @@

export interface HonoWebhookHandlerOptions<
TContext extends HonoContextLike = HonoContextLike,
TPayload = any,

Check warning on line 17 in src/adapters/hono.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
TMetadata extends Record<string, unknown> = Record<string, unknown>,
TResponse = unknown,
> {
platform: WebhookPlatform;
secret: string;
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
twilioBaseUrl?: string;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -31,7 +31,7 @@

export function createWebhookHandler<
TContext extends HonoContextLike = HonoContextLike,
TPayload = any,

Check warning on line 34 in src/adapters/hono.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
TMetadata extends Record<string, unknown> = Record<string, unknown>,
TResponse = unknown,
>(
Expand Down Expand Up @@ -71,12 +71,14 @@
return response;
}

const result = await WebhookVerificationService.verifyWithPlatformConfig(
const result = await WebhookVerificationService.verify(
request,
options.platform,
options.secret,
options.toleranceInSeconds,
options.normalize,
{
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

if (!result.isValid) {
Expand Down
16 changes: 9 additions & 7 deletions src/adapters/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { WebhookPlatform, NormalizeOptions } from '../types';
import { WebhookPlatform } from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
import { dispatchWebhookAlert } from '../notifications/dispatch';
import type { AlertConfig, SendAlertOptions } from '../notifications/types';

export interface NextWebhookHandlerOptions<TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown> {

Check warning on line 8 in src/adapters/nextjs.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
platform: WebhookPlatform;
secret: string;
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
twilioBaseUrl?: string;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -17,7 +17,7 @@
handler: (payload: TPayload, metadata: TMetadata) => Promise<TResponse> | TResponse;
}

export function createWebhookHandler<TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown>(

Check warning on line 20 in src/adapters/nextjs.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
options: NextWebhookHandlerOptions<TPayload, TMetadata, TResponse>,
) {
return async (request: Request): Promise<Response> => {
Expand Down Expand Up @@ -52,12 +52,14 @@
return response;
}

const result = await WebhookVerificationService.verifyWithPlatformConfig(
const result = await WebhookVerificationService.verify(
request,
options.platform,
options.secret,
options.toleranceInSeconds,
options.normalize,
{
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

if (!result.isValid) {
Expand Down
7 changes: 5 additions & 2 deletions src/adapters/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ export function toHeadersInit(
export async function toWebRequest(
request: MinimalNodeRequest,
): Promise<Request> {
const protocol = request.protocol || 'https';
const host = request.get?.('host')
const forwardedProto = getHeaderValue(request.headers, 'x-forwarded-proto')?.split(',')[0]?.trim();
const protocol = forwardedProto || request.protocol || 'https';
const forwardedHost = getHeaderValue(request.headers, 'x-forwarded-host')?.split(',')[0]?.trim();
const host = forwardedHost
|| request.get?.('host')
|| getHeaderValue(request.headers, 'host')
|| 'localhost';
const path = request.originalUrl || request.url || '/';
Expand Down
50 changes: 26 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
WebhookPlatform,
SignatureConfig,
MultiPlatformSecrets,
NormalizeOptions,
WebhookErrorCode,
} from './types';
import { createAlgorithmVerifier } from './verifiers/algorithms';
Expand All @@ -16,7 +15,6 @@ import {
platformUsesAlgorithm,
validateSignatureConfig,
} from './platforms/algorithms';
import { normalizePayload } from './normalization/simple';
import type { QueueOption } from './upstash/types';
import type { AlertConfig, SendAlertOptions } from './notifications/types';
import { dispatchWebhookAlert } from './notifications/dispatch';
Expand All @@ -38,9 +36,6 @@ export class WebhookVerificationService {
result.payload as Record<string, any>,
) ?? undefined;

if (config.normalize) {
result.payload = normalizePayload(config.platform, result.payload, config.normalize);
}
}

return result as WebhookVerificationResult<TPayload>;
Expand All @@ -63,13 +58,23 @@ export class WebhookVerificationService {
throw new Error('Signature config is required for algorithm-based verification');
}

const effectiveSignatureConfig: SignatureConfig = {
...signatureConfig,
customConfig: {
...(signatureConfig.customConfig || {}),
...(config.platform === 'twilio' && config.twilioBaseUrl
? { twilioBaseUrl: config.twilioBaseUrl }
: {}),
},
};

// Use custom verifiers for special cases (token-based, etc.)
if (signatureConfig.algorithm === 'custom') {
return createCustomVerifier(secret, signatureConfig, toleranceInSeconds);
if (effectiveSignatureConfig.algorithm === 'custom') {
return createCustomVerifier(secret, effectiveSignatureConfig, toleranceInSeconds);
}

// Use algorithm-based verifiers for standard algorithms
return createAlgorithmVerifier(secret, signatureConfig, config.platform, toleranceInSeconds);
return createAlgorithmVerifier(secret, effectiveSignatureConfig, config.platform, toleranceInSeconds);
}

private static getLegacyVerifier(config: WebhookConfig) {
Expand All @@ -88,16 +93,14 @@ export class WebhookVerificationService {
request: Request,
platform: WebhookPlatform,
secret: string,
toleranceInSeconds: number = 300,
normalize: boolean | NormalizeOptions = false,
toleranceInSeconds: number = 300
): Promise<WebhookVerificationResult<TPayload>> {
const platformConfig = getPlatformAlgorithmConfig(platform);
const config: WebhookConfig = {
platform,
secret,
toleranceInSeconds,
signatureConfig: platformConfig.signatureConfig,
normalize,
signatureConfig: platformConfig.signatureConfig
};

return this.verify<TPayload>(request, config);
Expand All @@ -106,8 +109,7 @@ export class WebhookVerificationService {
static async verifyAny<TPayload = unknown>(
request: Request,
secrets: MultiPlatformSecrets,
toleranceInSeconds: number = 300,
normalize: boolean | NormalizeOptions = false,
toleranceInSeconds: number = 300
): Promise<WebhookVerificationResult<TPayload>> {
const requestClone = request.clone();

Expand All @@ -117,8 +119,7 @@ export class WebhookVerificationService {
requestClone,
detectedPlatform,
secrets[detectedPlatform] as string,
toleranceInSeconds,
normalize,
toleranceInSeconds
);
}

Expand All @@ -137,8 +138,7 @@ export class WebhookVerificationService {
requestClone,
normalizedPlatform,
secret as string,
toleranceInSeconds,
normalize,
toleranceInSeconds
);

return {
Expand Down Expand Up @@ -246,6 +246,10 @@ export class WebhookVerificationService {
case 'workos':
case 'sentry':
case 'vercel':
case 'linear':
case 'pagerduty':
case 'twilio':
case 'svix':
return this.pickString(payload?.id) || null;
case 'doppler':
return this.pickString(payload?.event?.id, metadata?.id) || null;
Expand Down Expand Up @@ -287,7 +291,10 @@ export class WebhookVerificationService {

if (headers.has('stripe-signature')) return 'stripe';
if (headers.has('x-hub-signature-256')) return 'github';
if (headers.has('svix-signature')) return 'clerk';
if (headers.has('svix-signature')) return headers.has('svix-id') ? 'svix' : 'clerk';
if (headers.has('linear-signature')) return 'linear';
if (headers.has('x-pagerduty-signature')) return 'pagerduty';
if (headers.has('x-twilio-signature')) return 'twilio';
if (headers.has('workos-signature')) return 'workos';
if (headers.has('webhook-signature')) {
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
Expand Down Expand Up @@ -446,11 +453,6 @@ export {
} from './platforms/algorithms';
export { createAlgorithmVerifier } from './verifiers/algorithms';
export { createCustomVerifier } from './verifiers/custom-algorithms';
export {
normalizePayload,
getPlatformNormalizationCategory,
getPlatformsByCategory,
} from './normalization/simple';
export * from './adapters';
export * from './alerts';

Expand Down
Loading
Loading