Skip to content
Merged
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
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -171,6 +171,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,14 +192,17 @@ app.post('/webhooks/stripe', createWebhookHandler({
| **Grafana** | HMAC-SHA256 | ✅ Tested |
| **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 |
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
| **Vercel** | HMAC-SHA256 | 🔄 Pending |

> Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues).

### 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.

Expand Down Expand Up @@ -337,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.
Expand Down Expand Up @@ -403,6 +415,7 @@ interface WebhookVerificationResult {

## Troubleshooting


**`Module not found: Can't resolve "@hookflo/tern/nextjs"`**

```bash
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 7 additions & 7 deletions src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
@@ -1,16 +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 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;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -18,7 +17,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 20 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 +59,13 @@
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,
},
);

if (!result.isValid) {
Expand Down
13 changes: 6 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,6 @@ export interface ExpressWebhookMiddlewareOptions {
platform: WebhookPlatform;
secret: string;
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand Down Expand Up @@ -88,12 +86,13 @@ 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,
},
);

if (!result.isValid) {
Expand Down
14 changes: 7 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,13 @@

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;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -31,7 +30,7 @@

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

Check warning on line 33 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 +70,13 @@
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,
},
);

if (!result.isValid) {
Expand Down
14 changes: 7 additions & 7 deletions src/adapters/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
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;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand All @@ -17,7 +16,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 19 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 +51,13 @@
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,
},
);

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
46 changes: 22 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,20 @@ export class WebhookVerificationService {
throw new Error('Signature config is required for algorithm-based verification');
}

const effectiveSignatureConfig: SignatureConfig = {
...signatureConfig,
customConfig: {
...(signatureConfig.customConfig || {}),
},
};

// 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 +90,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 +106,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 +116,7 @@ export class WebhookVerificationService {
requestClone,
detectedPlatform,
secrets[detectedPlatform] as string,
toleranceInSeconds,
normalize,
toleranceInSeconds
);
}

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

return {
Expand Down Expand Up @@ -246,6 +243,9 @@ export class WebhookVerificationService {
case 'workos':
case 'sentry':
case 'vercel':
case 'linear':
case 'svix':
case 'standardwebhooks':
return this.pickString(payload?.id) || null;
case 'doppler':
return this.pickString(payload?.event?.id, metadata?.id) || null;
Expand Down Expand Up @@ -287,7 +287,8 @@ 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('workos-signature')) return 'workos';
if (headers.has('webhook-signature')) {
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
Expand Down Expand Up @@ -443,14 +444,11 @@ export {
platformUsesAlgorithm,
getPlatformsUsingAlgorithm,
validateSignatureConfig,
STANDARD_WEBHOOKS_BASE,
createStandardWebhooksConfig,
} 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