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
66 changes: 24 additions & 42 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 @@ -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.
Expand Down Expand Up @@ -214,17 +193,16 @@ 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 |

> 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 @@ -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.
Expand Down Expand Up @@ -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"`**

Expand Down
2 changes: 0 additions & 2 deletions src/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@
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;
twilioBaseUrl?: string;
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 @@ -66,7 +65,6 @@
platform: options.platform,
secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

Expand Down
2 changes: 0 additions & 2 deletions src/adapters/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ export interface ExpressWebhookMiddlewareOptions {
platform: WebhookPlatform;
secret: string;
toleranceInSeconds?: number;
twilioBaseUrl?: string;
queue?: QueueOption;
alerts?: AlertConfig;
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
Expand Down Expand Up @@ -93,7 +92,6 @@ export function createWebhookMiddleware(
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

Expand Down
2 changes: 0 additions & 2 deletions src/adapters/hono.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
twilioBaseUrl?: string;
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 @@ -77,7 +76,6 @@
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

Expand Down
2 changes: 0 additions & 2 deletions src/adapters/nextjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
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;
twilioBaseUrl?: string;
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 @@ -58,7 +57,6 @@
platform: options.platform,
secret: options.secret,
toleranceInSeconds: options.toleranceInSeconds,
twilioBaseUrl: options.twilioBaseUrl,
},
);

Expand Down
10 changes: 3 additions & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@ export class WebhookVerificationService {
...signatureConfig,
customConfig: {
...(signatureConfig.customConfig || {}),
...(config.platform === 'twilio' && config.twilioBaseUrl
? { twilioBaseUrl: config.twilioBaseUrl }
: {}),
},
};

Expand Down Expand Up @@ -247,9 +244,8 @@ export class WebhookVerificationService {
case 'sentry':
case 'vercel':
case 'linear':
case 'pagerduty':
case 'twilio':
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 @@ -293,8 +289,6 @@ export class WebhookVerificationService {
if (headers.has('x-hub-signature-256')) return 'github';
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 @@ -450,6 +444,8 @@ export {
platformUsesAlgorithm,
getPlatformsUsingAlgorithm,
validateSignatureConfig,
STANDARD_WEBHOOKS_BASE,
createStandardWebhooksConfig,
} from './platforms/algorithms';
export { createAlgorithmVerifier } from './verifiers/algorithms';
export { createCustomVerifier } from './verifiers/custom-algorithms';
Expand Down
75 changes: 46 additions & 29 deletions src/platforms/algorithms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ import {
SignatureConfig,
} from "../types";

export const STANDARD_WEBHOOKS_BASE = {
algorithm: "hmac-sha256" as const,
headerFormat: "raw" as const,
timestampFormat: "unix" as const,
payloadFormat: "custom" as const,
customConfig: {
signatureFormat: "v1={signature}",
payloadFormat: "{id}.{timestamp}.{body}",
encoding: "base64",
secretEncoding: "base64",
},
};

export function createStandardWebhooksConfig(headers: {
id: string;
timestamp: string;
signature: string;
idAliases?: string[];
timestampAliases?: string[];
signatureAliases?: string[];
}): SignatureConfig {
return {
...STANDARD_WEBHOOKS_BASE,
headerName: headers.signature,
timestampHeader: headers.timestamp,
customConfig: {
...STANDARD_WEBHOOKS_BASE.customConfig,
idHeader: headers.id,
...(headers.idAliases && { idHeaderAliases: headers.idAliases }),
...(headers.timestampAliases && { timestampHeaderAliases: headers.timestampAliases }),
...(headers.signatureAliases && { signatureHeaderAliases: headers.signatureAliases }),
},
};
}

export const platformAlgorithmConfigs: Record<
WebhookPlatform,
PlatformAlgorithmConfig
Expand Down Expand Up @@ -358,37 +393,19 @@ export const platformAlgorithmConfigs: Record<
description: 'Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window',
},

pagerduty: {
platform: 'pagerduty',
standardwebhooks: {
platform: 'standardwebhooks',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-pagerduty-signature',
headerFormat: 'raw',
payloadFormat: 'raw',
prefix: 'v1=',
customConfig: {
signatureFormat: 'v1={signature}',
comparePrefixed: true,
},
},
description: 'PagerDuty webhooks use HMAC-SHA256 with v1=<hex> 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: {
Expand Down
Loading
Loading