Skip to content

Commit ed2510d

Browse files
authored
Add canonical standardwebhooks platform and remove twilio/pagerduty (#54)
1 parent 37f8163 commit ed2510d

10 files changed

Lines changed: 115 additions & 238 deletions

File tree

README.md

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
88
[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json)
99

10-
Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API.
10+
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.
1111

1212
> 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).
1313
@@ -79,27 +79,6 @@ const result = await WebhookVerificationService.verifyAny(request, {
7979
console.log(`Verified ${result.platform} webhook`);
8080
```
8181

82-
### Twilio example
83-
84-
```typescript
85-
import { WebhookVerificationService } from '@hookflo/tern';
86-
87-
export async function POST(request: Request) {
88-
const result = await WebhookVerificationService.verify(request, {
89-
platform: 'twilio',
90-
secret: process.env.TWILIO_AUTH_TOKEN!,
91-
// Optional when behind proxies/CDNs if request.url differs from the public Twilio URL:
92-
twilioBaseUrl: 'https://yourdomain.com/api/webhooks/twilio',
93-
});
94-
95-
if (!result.isValid) {
96-
return Response.json({ error: result.error }, { status: 400 });
97-
}
98-
99-
return Response.json({ ok: true });
100-
}
101-
```
102-
10382
### Core SDK (runtime-agnostic)
10483

10584
Use Tern without framework adapters in any runtime that supports the Web `Request` API.
@@ -214,17 +193,16 @@ app.post('/webhooks/stripe', createWebhookHandler({
214193
| **Doppler** | HMAC-SHA256 | ✅ Tested |
215194
| **Sanity** | HMAC-SHA256 | ✅ Tested |
216195
| **Svix** | HMAC-SHA256 | ⚠️ Untested for now |
196+
| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested |
217197
| **Linear** | HMAC-SHA256 | ⚠️ Untested for now |
218-
| **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now |
219-
| **Twilio** | HMAC-SHA1 | ⚠️ Untested for now |
220198
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
221199
| **Vercel** | HMAC-SHA256 | 🔄 Pending |
222200

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

227-
- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`.
205+
- **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_...`.
228206
- **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`.
229207
- **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly.
230208

@@ -365,25 +343,31 @@ const result = await WebhookVerificationService.verify(request, {
365343
});
366344
```
367345

368-
### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
346+
### Standard Webhooks config helpers (Svix-style and webhook-* headers)
369347

370348
```typescript
371-
const svixConfig = {
372-
platform: 'my-svix-platform',
373-
secret: 'whsec_abc123...',
349+
import {
350+
createStandardWebhooksConfig,
351+
STANDARD_WEBHOOKS_BASE,
352+
} from '@hookflo/tern';
353+
354+
const signatureConfig = createStandardWebhooksConfig({
355+
id: 'webhook-id',
356+
timestamp: 'webhook-timestamp',
357+
signature: 'webhook-signature',
358+
idAliases: ['svix-id'],
359+
timestampAliases: ['svix-timestamp'],
360+
signatureAliases: ['svix-signature'],
361+
});
362+
363+
const result = await WebhookVerificationService.verify(request, {
364+
platform: 'standardwebhooks',
365+
secret: process.env.STANDARD_WEBHOOKS_SECRET!,
374366
signatureConfig: {
375-
algorithm: 'hmac-sha256',
376-
headerName: 'webhook-signature',
377-
headerFormat: 'raw',
378-
timestampHeader: 'webhook-timestamp',
379-
timestampFormat: 'unix',
380-
payloadFormat: 'custom',
381-
customConfig: {
382-
payloadFormat: '{id}.{timestamp}.{body}',
383-
idHeader: 'webhook-id',
384-
},
367+
...STANDARD_WEBHOOKS_BASE,
368+
...signatureConfig,
385369
},
386-
};
370+
});
387371
```
388372

389373
See the [SignatureConfig type](https://tern.hookflo.com) for all options.
@@ -431,8 +415,6 @@ interface WebhookVerificationResult {
431415

432416
## Troubleshooting
433417

434-
- **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`.
435-
436418

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

src/adapters/cloudflare.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ export interface CloudflareWebhookHandlerOptions<TEnv = Record<string, unknown>,
1010
secret?: string;
1111
secretEnv?: string;
1212
toleranceInSeconds?: number;
13-
twilioBaseUrl?: string;
1413
queue?: QueueOption;
1514
alerts?: AlertConfig;
1615
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
@@ -66,7 +65,6 @@ export function createWebhookHandler<TEnv = Record<string, unknown>, TPayload =
6665
platform: options.platform,
6766
secret,
6867
toleranceInSeconds: options.toleranceInSeconds,
69-
twilioBaseUrl: options.twilioBaseUrl,
7068
},
7169
);
7270

src/adapters/express.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ export interface ExpressWebhookMiddlewareOptions {
2424
platform: WebhookPlatform;
2525
secret: string;
2626
toleranceInSeconds?: number;
27-
twilioBaseUrl?: string;
2827
queue?: QueueOption;
2928
alerts?: AlertConfig;
3029
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
@@ -93,7 +92,6 @@ export function createWebhookMiddleware(
9392
platform: options.platform,
9493
secret: options.secret,
9594
toleranceInSeconds: options.toleranceInSeconds,
96-
twilioBaseUrl: options.twilioBaseUrl,
9795
},
9896
);
9997

src/adapters/hono.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ export interface HonoWebhookHandlerOptions<
2121
platform: WebhookPlatform;
2222
secret: string;
2323
toleranceInSeconds?: number;
24-
twilioBaseUrl?: string;
2524
queue?: QueueOption;
2625
alerts?: AlertConfig;
2726
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
@@ -77,7 +76,6 @@ export function createWebhookHandler<
7776
platform: options.platform,
7877
secret: options.secret,
7978
toleranceInSeconds: options.toleranceInSeconds,
80-
twilioBaseUrl: options.twilioBaseUrl,
8179
},
8280
);
8381

src/adapters/nextjs.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export interface NextWebhookHandlerOptions<TPayload = any, TMetadata extends Rec
99
platform: WebhookPlatform;
1010
secret: string;
1111
toleranceInSeconds?: number;
12-
twilioBaseUrl?: string;
1312
queue?: QueueOption;
1413
alerts?: AlertConfig;
1514
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
@@ -58,7 +57,6 @@ export function createWebhookHandler<TPayload = any, TMetadata extends Record<st
5857
platform: options.platform,
5958
secret: options.secret,
6059
toleranceInSeconds: options.toleranceInSeconds,
61-
twilioBaseUrl: options.twilioBaseUrl,
6260
},
6361
);
6462

src/index.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,6 @@ export class WebhookVerificationService {
6262
...signatureConfig,
6363
customConfig: {
6464
...(signatureConfig.customConfig || {}),
65-
...(config.platform === 'twilio' && config.twilioBaseUrl
66-
? { twilioBaseUrl: config.twilioBaseUrl }
67-
: {}),
6865
},
6966
};
7067

@@ -247,9 +244,8 @@ export class WebhookVerificationService {
247244
case 'sentry':
248245
case 'vercel':
249246
case 'linear':
250-
case 'pagerduty':
251-
case 'twilio':
252247
case 'svix':
248+
case 'standardwebhooks':
253249
return this.pickString(payload?.id) || null;
254250
case 'doppler':
255251
return this.pickString(payload?.event?.id, metadata?.id) || null;
@@ -293,8 +289,6 @@ export class WebhookVerificationService {
293289
if (headers.has('x-hub-signature-256')) return 'github';
294290
if (headers.has('svix-signature')) return headers.has('svix-id') ? 'svix' : 'clerk';
295291
if (headers.has('linear-signature')) return 'linear';
296-
if (headers.has('x-pagerduty-signature')) return 'pagerduty';
297-
if (headers.has('x-twilio-signature')) return 'twilio';
298292
if (headers.has('workos-signature')) return 'workos';
299293
if (headers.has('webhook-signature')) {
300294
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
@@ -450,6 +444,8 @@ export {
450444
platformUsesAlgorithm,
451445
getPlatformsUsingAlgorithm,
452446
validateSignatureConfig,
447+
STANDARD_WEBHOOKS_BASE,
448+
createStandardWebhooksConfig,
453449
} from './platforms/algorithms';
454450
export { createAlgorithmVerifier } from './verifiers/algorithms';
455451
export { createCustomVerifier } from './verifiers/custom-algorithms';

src/platforms/algorithms.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,41 @@ import {
44
SignatureConfig,
55
} from "../types";
66

7+
export const STANDARD_WEBHOOKS_BASE = {
8+
algorithm: "hmac-sha256" as const,
9+
headerFormat: "raw" as const,
10+
timestampFormat: "unix" as const,
11+
payloadFormat: "custom" as const,
12+
customConfig: {
13+
signatureFormat: "v1={signature}",
14+
payloadFormat: "{id}.{timestamp}.{body}",
15+
encoding: "base64",
16+
secretEncoding: "base64",
17+
},
18+
};
19+
20+
export function createStandardWebhooksConfig(headers: {
21+
id: string;
22+
timestamp: string;
23+
signature: string;
24+
idAliases?: string[];
25+
timestampAliases?: string[];
26+
signatureAliases?: string[];
27+
}): SignatureConfig {
28+
return {
29+
...STANDARD_WEBHOOKS_BASE,
30+
headerName: headers.signature,
31+
timestampHeader: headers.timestamp,
32+
customConfig: {
33+
...STANDARD_WEBHOOKS_BASE.customConfig,
34+
idHeader: headers.id,
35+
...(headers.idAliases && { idHeaderAliases: headers.idAliases }),
36+
...(headers.timestampAliases && { timestampHeaderAliases: headers.timestampAliases }),
37+
...(headers.signatureAliases && { signatureHeaderAliases: headers.signatureAliases }),
38+
},
39+
};
40+
}
41+
742
export const platformAlgorithmConfigs: Record<
843
WebhookPlatform,
944
PlatformAlgorithmConfig
@@ -358,37 +393,19 @@ export const platformAlgorithmConfigs: Record<
358393
description: 'Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window',
359394
},
360395

361-
pagerduty: {
362-
platform: 'pagerduty',
396+
standardwebhooks: {
397+
platform: 'standardwebhooks',
363398
signatureConfig: {
364-
algorithm: 'hmac-sha256',
365-
headerName: 'x-pagerduty-signature',
366-
headerFormat: 'raw',
367-
payloadFormat: 'raw',
368-
prefix: 'v1=',
369-
customConfig: {
370-
signatureFormat: 'v1={signature}',
371-
comparePrefixed: true,
372-
},
373-
},
374-
description: 'PagerDuty webhooks use HMAC-SHA256 with v1=<hex> signatures',
375-
},
376-
377-
twilio: {
378-
platform: 'twilio',
379-
signatureConfig: {
380-
algorithm: 'hmac-sha1',
381-
headerName: 'x-twilio-signature',
382-
headerFormat: 'raw',
383-
payloadFormat: 'custom',
384-
customConfig: {
385-
payloadFormat: '{url}',
386-
encoding: 'base64',
387-
secretEncoding: 'utf8',
388-
validateBodySHA256: true,
389-
},
399+
...createStandardWebhooksConfig({
400+
id: 'webhook-id',
401+
timestamp: 'webhook-timestamp',
402+
signature: 'webhook-signature',
403+
idAliases: ['svix-id'],
404+
timestampAliases: ['svix-timestamp'],
405+
signatureAliases: ['svix-signature'],
406+
}),
390407
},
391-
description: 'Twilio webhooks use HMAC-SHA1 with base64 signatures (URL canonicalization required)',
408+
description: 'Canonical Standard Webhooks implementation. Works for any platform using v1= HMAC-SHA256 signing regardless of header names.',
392409
},
393410

394411
custom: {

0 commit comments

Comments
 (0)