@hookflo/tern 4.2.7-beta → 4.3.0-beta-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +283 -481
  2. package/dist/adapters/cloudflare.d.ts +2 -2
  3. package/dist/adapters/cloudflare.js +6 -1
  4. package/dist/adapters/express.d.ts +2 -2
  5. package/dist/adapters/express.js +6 -1
  6. package/dist/adapters/hono.d.ts +21 -0
  7. package/dist/adapters/hono.js +74 -0
  8. package/dist/adapters/index.d.ts +1 -0
  9. package/dist/adapters/index.js +3 -1
  10. package/dist/adapters/nextjs.d.ts +2 -2
  11. package/dist/adapters/nextjs.js +6 -1
  12. package/dist/adapters/shared.js +5 -2
  13. package/dist/hono.d.ts +2 -0
  14. package/dist/hono.js +5 -0
  15. package/dist/index.d.ts +3 -4
  16. package/dist/index.js +29 -19
  17. package/dist/platforms/algorithms.js +65 -0
  18. package/dist/types.d.ts +6 -65
  19. package/dist/types.js +4 -0
  20. package/dist/verifiers/algorithms.d.ts +8 -0
  21. package/dist/verifiers/algorithms.js +145 -17
  22. package/package.json +13 -2
  23. package/dist/normalization/index.d.ts +0 -20
  24. package/dist/normalization/index.js +0 -78
  25. package/dist/normalization/providers/payment/paypal.d.ts +0 -2
  26. package/dist/normalization/providers/payment/paypal.js +0 -12
  27. package/dist/normalization/providers/payment/razorpay.d.ts +0 -2
  28. package/dist/normalization/providers/payment/razorpay.js +0 -13
  29. package/dist/normalization/providers/payment/stripe.d.ts +0 -2
  30. package/dist/normalization/providers/payment/stripe.js +0 -13
  31. package/dist/normalization/providers/registry.d.ts +0 -5
  32. package/dist/normalization/providers/registry.js +0 -21
  33. package/dist/normalization/simple.d.ts +0 -4
  34. package/dist/normalization/simple.js +0 -126
  35. package/dist/normalization/storage/interface.d.ts +0 -13
  36. package/dist/normalization/storage/interface.js +0 -2
  37. package/dist/normalization/storage/memory.d.ts +0 -12
  38. package/dist/normalization/storage/memory.js +0 -39
  39. package/dist/normalization/templates/base/auth.d.ts +0 -2
  40. package/dist/normalization/templates/base/auth.js +0 -22
  41. package/dist/normalization/templates/base/ecommerce.d.ts +0 -2
  42. package/dist/normalization/templates/base/ecommerce.js +0 -25
  43. package/dist/normalization/templates/base/payment.d.ts +0 -2
  44. package/dist/normalization/templates/base/payment.js +0 -25
  45. package/dist/normalization/templates/registry.d.ts +0 -6
  46. package/dist/normalization/templates/registry.js +0 -22
  47. package/dist/normalization/transformer/engine.d.ts +0 -11
  48. package/dist/normalization/transformer/engine.js +0 -86
  49. package/dist/normalization/transformer/validator.d.ts +0 -12
  50. package/dist/normalization/transformer/validator.js +0 -56
  51. package/dist/normalization/types.d.ts +0 -79
  52. package/dist/normalization/types.js +0 -2
package/README.md CHANGED
@@ -1,322 +1,129 @@
1
- # Tern - Algorithm Agnostic Webhook Verification Framework
1
+ # Tern Webhook Verification for Every Platform
2
2
 
3
- A robust, algorithm-agnostic webhook verification framework that supports multiple platforms with accurate signature verification and payload retrieval.
4
- The same framework that secures webhook verification at [Hookflo](https://hookflo.com).
3
+ **When Stripe, Shopify, Clerk or any other platform sends a webhook to your server, how do you know it's real and not a forged request?** Tern checks the signature for you one simplified TypeScript SDK, any provider, no boilerplate.
5
4
 
6
- Star this repo to support the project and help others discover it!
5
+ [![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json)
7
9
 
8
- 💬 Join the discussion & contribute in our Discord: [Hookflo Community](https://discord.com/invite/SNmCjU97nr)
10
+ Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API.
11
+
12
+ > 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).
9
13
 
10
14
  ```bash
11
15
  npm install @hookflo/tern
12
16
  ```
13
17
 
14
- [![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern)
15
- [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/)
16
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
17
-
18
- Tern is a zero-dependency TypeScript framework for robust webhook verification across multiple platforms and algorithms.
19
-
20
- **Runtime requirements:** Node.js 18+ (or any runtime with Web Crypto + Fetch APIs, such as Deno and Cloudflare Workers).
18
+ > The same framework powering webhook verification at [Hookflo](https://hookflo.com).
21
19
 
22
- <img width="1396" height="470" style="border-radius: 10px" alt="tern bird nature" src="https://github.com/user-attachments/assets/5f0da3e6-1aba-4f88-a9d7-9d8698845c39" />
20
+ Star this repo to help others discover it · 💬 [Join our Discord](https://discord.com/invite/SNmCjU97nr)
23
21
 
24
- ## Features
22
+ <img width="1200" height="630" alt="Tern – Webhook Verification Framework" src="https://tern.hookflo.com/og-image.webp" style="border-radius: 10px; margin-top: 16px;" />
25
23
 
26
- - **Algorithm Agnostic**: Decouples platform logic from signature verification — verify based on cryptographic algorithm, not hardcoded platform rules.
27
- Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
24
+ **Navigation**
28
25
 
29
- - **Platform Specific**: Accurate implementations for **Stripe, GitHub, Clerk**, and other platforms
30
- - **Flexible Configuration**: Custom signature configurations for any webhook format
31
- - **Type Safe**: Full TypeScript support with comprehensive type definitions
32
- - **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more
33
- - **Body-Parser Safe Adapters**: Read raw request bodies correctly to avoid signature mismatch issues
34
- - **Multi-Provider Verification**: Verify and auto-detect across multiple providers with one API
35
- - **Payload Normalization**: Opt-in normalized event shape to reduce provider lock-in
36
- - **Category-aware Migration**: Normalize within provider categories (payment/auth/infrastructure) for safe platform switching
37
- - **Strong Typed Normalized Schemas**: Category types like `PaymentWebhookNormalized` and `AuthWebhookNormalized` for safe migrations
38
- - **Foundational Error Taxonomy**: Stable `errorCode` values (`INVALID_SIGNATURE`, `MISSING_SIGNATURE`, etc.)
26
+ [The Problem](#the-problem) · [Quick Start](#quick-start) · [Framework Integrations](#framework-integrations) · [Supported Platforms](#supported-platforms) · [Key Features](#key-features) · [Reliable Delivery & Alerting](#reliable-delivery--alerting) · [Custom Config](#custom-platform-configuration) · [API Reference](#api-reference) · [Troubleshooting](#troubleshooting) · [Contributing](#contributing) · [Support](#support)
39
27
 
40
- ## Why Tern?
28
+ ## The Problem
41
29
 
42
- Most webhook verifiers are tightly coupled to specific platforms or hardcoded logic. Tern introduces a flexible, scalable, algorithm-first approach that:
30
+ Every webhook provider has a different signature format. You end up writing and maintaining the same verification boilerplate over and over:
43
31
 
44
- - Works across all major platforms
45
- - Supports custom signing logic
46
- - Keeps your code clean and modular
47
- - Avoids unnecessary dependencies
48
- - Is written in strict, modern TypeScript
32
+ ```typescript
33
+ // Without Tern — different logic for every provider
34
+ const stripeSignature = req.headers['stripe-signature'];
35
+ const parts = stripeSignature.split(',');
36
+ // ... 30 more lines just for Stripe
49
37
 
50
- ## Installation
38
+ const githubSignature = req.headers['x-hub-signature-256'];
39
+ // ... completely different 20 lines for GitHub
40
+ ```
51
41
 
52
- ```bash
53
- npm install @hookflo/tern
42
+ ```typescript
43
+ // With Tern — one API for everything
44
+ const result = await WebhookVerificationService.verify(request, {
45
+ platform: 'stripe',
46
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
47
+ });
54
48
  ```
55
49
 
56
50
  ## Quick Start
57
51
 
58
- ### Basic Usage
52
+ ### Verify a single platform
59
53
 
60
54
  ```typescript
61
55
  import { WebhookVerificationService } from '@hookflo/tern';
62
56
 
63
57
  const result = await WebhookVerificationService.verify(request, {
64
58
  platform: 'stripe',
65
- secret: 'whsec_your_stripe_webhook_secret',
59
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
66
60
  toleranceInSeconds: 300,
67
61
  });
68
62
 
69
63
  if (result.isValid) {
70
- console.log('Webhook verified!', result.eventId, result.payload);
64
+ console.log('Verified!', result.eventId, result.payload);
71
65
  } else {
72
- console.log('Verification failed:', result.error);
66
+ console.log('Failed:', result.error, result.errorCode);
73
67
  }
74
68
  ```
75
69
 
76
- ### Universal Verification (auto-detect platform)
70
+ ### Auto-detect platform
77
71
 
78
72
  ```typescript
79
- import { WebhookVerificationService } from '@hookflo/tern';
80
-
81
73
  const result = await WebhookVerificationService.verifyAny(request, {
82
74
  stripe: process.env.STRIPE_WEBHOOK_SECRET,
83
75
  github: process.env.GITHUB_WEBHOOK_SECRET,
84
76
  clerk: process.env.CLERK_WEBHOOK_SECRET,
85
77
  });
86
78
 
87
- if (result.isValid) {
88
- console.log(`Verified ${result.platform} webhook`);
89
- }
90
- ```
91
-
92
- ### Category-aware Payload Normalization
93
-
94
- ### Strongly-Typed Normalized Payloads
95
-
96
- ```typescript
97
- import {
98
- WebhookVerificationService,
99
- PaymentWebhookNormalized,
100
- } from '@hookflo/tern';
101
-
102
- const result = await WebhookVerificationService.verifyWithPlatformConfig<PaymentWebhookNormalized>(
103
- request,
104
- 'stripe',
105
- process.env.STRIPE_WEBHOOK_SECRET!,
106
- 300,
107
- { enabled: true, category: 'payment' },
108
- );
109
-
110
- if (result.isValid && result.payload?.event === 'payment.succeeded') {
111
- // result.payload is strongly typed
112
- console.log(result.payload.amount, result.payload.customer_id);
113
- }
114
- ```
115
-
116
- ```typescript
117
- import { WebhookVerificationService, getPlatformsByCategory } from '@hookflo/tern';
118
-
119
- // Discover migration-compatible providers in the same category
120
- const paymentPlatforms = getPlatformsByCategory('payment');
121
- // ['stripe', 'polar', ...]
122
-
123
- const result = await WebhookVerificationService.verifyWithPlatformConfig(
124
- request,
125
- 'stripe',
126
- process.env.STRIPE_WEBHOOK_SECRET!,
127
- 300,
128
- {
129
- enabled: true,
130
- category: 'payment',
131
- includeRaw: true,
132
- },
133
- );
134
-
135
- console.log(result.payload);
136
- // {
137
- // event: 'payment.succeeded',
138
- // amount: 5000,
139
- // currency: 'USD',
140
- // customer_id: 'cus_123',
141
- // transaction_id: 'pi_123',
142
- // provider: 'stripe',
143
- // category: 'payment',
144
- // _raw: {...}
145
- // }
79
+ console.log(`Verified ${result.platform} webhook`);
146
80
  ```
147
81
 
148
- ### Platform-Specific Configurations
82
+ ### Twilio example
149
83
 
150
84
  ```typescript
151
85
  import { WebhookVerificationService } from '@hookflo/tern';
152
86
 
153
- // Stripe webhook
154
- const stripeConfig = {
155
- platform: 'stripe',
156
- secret: 'whsec_your_stripe_webhook_secret',
157
- toleranceInSeconds: 300,
158
- };
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
+ });
159
94
 
160
- // GitHub webhook
161
- const githubConfig = {
162
- platform: 'github',
163
- secret: 'your_github_webhook_secret',
164
- toleranceInSeconds: 300,
165
- };
166
-
167
- // Clerk webhook
168
- const clerkConfig = {
169
- platform: 'clerk',
170
- secret: 'whsec_your_clerk_webhook_secret',
171
- toleranceInSeconds: 300,
172
- };
95
+ if (!result.isValid) {
96
+ return Response.json({ error: result.error }, { status: 400 });
97
+ }
173
98
 
174
- const result = await WebhookVerificationService.verify(request, stripeConfig);
99
+ return Response.json({ ok: true });
100
+ }
175
101
  ```
176
102
 
177
- ## Supported Platforms
103
+ ### Core SDK (runtime-agnostic)
178
104
 
179
- ### Stripe OK Tested
180
- - **Signature Format**: `t={timestamp},v1={signature}`
181
- - **Algorithm**: HMAC-SHA256
182
- - **Payload Format**: `{timestamp}.{body}`
183
-
184
- ### GitHub
185
- - **Signature Format**: `sha256={signature}`
186
- - **Algorithm**: HMAC-SHA256
187
- - **Payload Format**: Raw body
188
-
189
- ### Clerk
190
- - **Signature Format**: `v1,{signature}` (space-separated)
191
- - **Algorithm**: HMAC-SHA256 with base64 encoding
192
- - **Payload Format**: `{id}.{timestamp}.{body}`
193
-
194
- ### Other Platforms
195
- - **Dodo Payments**: HMAC-SHA256 OK Tested
196
- - **Paddle**: HMAC-SHA256 OK Tested
197
- - **Razorpay**: HMAC-SHA256 Pending
198
- - **Lemon Squeezy**: HMAC-SHA256 OK Tested
199
- - **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`) OK Tested
200
- - **WooCommerce**: HMAC-SHA256 (base64 signature) Pending
201
- - **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) OK Tested
202
- - **Sentry**: HMAC-SHA256 (`sentry-hook-signature`) with JSON-stringified payload + issue-alert fallback
203
- - **Grafana (v12+)**: HMAC-SHA256 (`x-grafana-alerting-signature`) with optional timestamped payload
204
- - **Doppler**: HMAC-SHA256 (`x-doppler-signature`, `sha256=` prefix)
205
- - **Sanity**: Stripe-compatible HMAC-SHA256 (`sanity-webhook-signature`, `t=/v1=`)
206
- - **fal.ai**: ED25519 (`x-fal-webhook-signature`)
207
- - **Shopify**: HMAC-SHA256 (base64 signature) OK Tested
208
- - **Vercel**: HMAC-SHA256 Pending
209
- - **Polar**: HMAC-SHA256 OK Tested
210
- - **GitLab**: Token-based authentication OK Tested
211
-
212
- ## Custom Platform Configuration
213
-
214
- This framework is fully configuration-driven. `timestampHeader` is optional and only needed for providers that send timestamp separately from the signature. You can verify webhooks from any provider—even if it is not built-in—by supplying a custom configuration object. This allows you to support new or proprietary platforms instantly, without waiting for a library update.
215
-
216
- ### Example: Standard HMAC-SHA256 Webhook
105
+ Use Tern without framework adapters in any runtime that supports the Web `Request` API.
217
106
 
218
107
  ```typescript
219
108
  import { WebhookVerificationService } from '@hookflo/tern';
220
109
 
221
- const acmeConfig = {
222
- platform: 'acmepay',
223
- secret: 'acme_secret',
224
- signatureConfig: {
225
- algorithm: 'hmac-sha256',
226
- headerName: 'x-acme-signature',
227
- headerFormat: 'raw',
228
- // Optional: only include when provider sends timestamp in a separate header
229
- timestampHeader: 'x-acme-timestamp',
230
- timestampFormat: 'unix',
231
- payloadFormat: 'timestamped', // signs as {timestamp}.{body}
232
- }
233
- };
234
-
235
- const result = await WebhookVerificationService.verify(request, acmeConfig);
236
- ```
237
-
238
- ### Example: Svix/Standard Webhooks (Clerk, Dodo Payments, etc.)
239
-
240
- ```typescript
241
- const svixConfig = {
242
- platform: 'my-svix-platform',
243
- secret: 'whsec_abc123...',
244
- signatureConfig: {
245
- algorithm: 'hmac-sha256',
246
- headerName: 'webhook-signature',
247
- headerFormat: 'raw',
248
- timestampHeader: 'webhook-timestamp',
249
- timestampFormat: 'unix',
250
- payloadFormat: 'custom',
251
- customConfig: {
252
- payloadFormat: '{id}.{timestamp}.{body}',
253
- idHeader: 'webhook-id',
254
- // encoding: 'base64' // only if the provider uses base64, otherwise omit
255
- }
256
- }
257
- };
258
-
259
- const result = await WebhookVerificationService.verify(request, svixConfig);
260
- ```
261
-
262
- You can configure any combination of algorithm, header, payload, and encoding. See the `SignatureConfig` type for all options.
263
-
264
- For `platform: 'custom'`, default config remains compatible with token-style providers through `signatureConfig.customConfig` (`type: 'token-based'`, `idHeader: 'x-webhook-id'`), and you can override it per provider.
265
-
266
- ## Verified Platforms (continuously tested)
267
- - **Stripe**
268
- - **GitHub**
269
- - **Clerk**
270
- - **Dodo Payments**
271
- - **GitLab**
272
- - **WorkOS**
273
- - **Lemon Squeezy**
274
- - **Paddle**
275
- - **Shopify**
276
- - **Polar**
277
- - **ReplicateAI**
278
-
279
- Other listed platforms are supported but may have lighter coverage depending on release cycle.
280
-
281
-
282
- ## Custom Configurations
283
-
284
- ### Custom HMAC-SHA256
285
-
286
- ```typescript
287
- const customConfig = {
288
- platform: 'custom',
289
- secret: 'your_custom_secret',
290
- signatureConfig: {
291
- algorithm: 'hmac-sha256',
292
- headerName: 'x-custom-signature',
293
- headerFormat: 'prefixed',
294
- prefix: 'sha256=',
295
- payloadFormat: 'raw',
296
- },
297
- };
298
- ```
110
+ const verified = await WebhookVerificationService.verifyWithPlatformConfig(
111
+ request,
112
+ 'workos',
113
+ process.env.WORKOS_WEBHOOK_SECRET!,
114
+ 300,
115
+ );
299
116
 
300
- ### Custom Timestamped Payload
117
+ if (!verified.isValid) {
118
+ return new Response(JSON.stringify({ error: verified.error }), { status: 400 });
119
+ }
301
120
 
302
- ```typescript
303
- const timestampedConfig = {
304
- platform: 'custom',
305
- secret: 'your_custom_secret',
306
- signatureConfig: {
307
- algorithm: 'hmac-sha256',
308
- headerName: 'x-webhook-signature',
309
- headerFormat: 'raw',
310
- timestampHeader: 'x-webhook-timestamp',
311
- timestampFormat: 'unix',
312
- payloadFormat: 'timestamped',
313
- },
314
- };
121
+ // verified.payload + verified.metadata available here
315
122
  ```
316
123
 
317
- ## Framework Integration
124
+ ## Framework Integrations
318
125
 
319
- ### Express.js middleware (body-parser safe)
126
+ ### Express.js
320
127
 
321
128
  ```typescript
322
129
  import express from 'express';
@@ -326,14 +133,14 @@ const app = express();
326
133
 
327
134
  app.post(
328
135
  '/webhooks/stripe',
136
+ express.raw({ type: '*/*' }),
329
137
  createWebhookMiddleware({
330
138
  platform: 'stripe',
331
139
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
332
- normalize: true,
333
140
  }),
334
141
  (req, res) => {
335
- const event = (req as any).webhook.payload;
336
- res.json({ received: true, event: event.event });
142
+ const event = (req as any).webhook?.payload;
143
+ res.json({ received: true, event });
337
144
  },
338
145
  );
339
146
  ```
@@ -346,7 +153,7 @@ import { createWebhookHandler } from '@hookflo/tern/nextjs';
346
153
  export const POST = createWebhookHandler({
347
154
  platform: 'github',
348
155
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
349
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
156
+ handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }),
350
157
  });
351
158
  ```
352
159
 
@@ -355,171 +162,153 @@ export const POST = createWebhookHandler({
355
162
  ```typescript
356
163
  import { createWebhookHandler } from '@hookflo/tern/cloudflare';
357
164
 
358
- const handleStripe = createWebhookHandler({
165
+ export const onRequestPost = createWebhookHandler({
359
166
  platform: 'stripe',
360
167
  secretEnv: 'STRIPE_WEBHOOK_SECRET',
361
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
168
+ handler: async (payload) => ({ received: true, payload }),
362
169
  });
363
-
364
- export default {
365
- async fetch(request: Request, env: Record<string, string>) {
366
- if (new URL(request.url).pathname === '/webhooks/stripe') {
367
- return handleStripe(request, env);
368
- }
369
- return new Response('Not Found', { status: 404 });
370
- },
371
- };
372
170
  ```
373
171
 
374
-
375
- ### Are new platforms available in framework middlewares automatically?
376
-
377
- Yes. All built-in platforms are available in:
378
- - `createWebhookMiddleware` (`@hookflo/tern/express`)
379
- - `createWebhookHandler` (`@hookflo/tern/nextjs`)
380
- - `createWebhookHandler` (`@hookflo/tern/cloudflare`)
381
-
382
- You only change `platform` and `secret` per route.
383
-
384
- ### Platform route examples (Express / Next.js / Cloudflare)
172
+ ### Hono (Edge Runtimes)
385
173
 
386
174
  ```typescript
387
- // Express (Razorpay)
388
- app.post('/webhooks/razorpay', createWebhookMiddleware({
389
- platform: 'razorpay',
390
- secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
391
- }), (req, res) => res.json({ ok: true }));
175
+ import { Hono } from 'hono';
176
+ import { createWebhookHandler } from '@hookflo/tern/hono';
392
177
 
393
- // Next.js (WorkOS)
394
- export const POST = createWebhookHandler({
395
- platform: 'workos',
396
- secret: process.env.WORKOS_WEBHOOK_SECRET!,
397
- handler: async (payload) => ({ received: true, type: payload.type }),
398
- });
178
+ const app = new Hono();
399
179
 
400
- // Cloudflare (Lemon Squeezy)
401
- const handleLemonSqueezy = createWebhookHandler({
402
- platform: 'lemonsqueezy',
403
- secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET',
404
- handler: async () => ({ received: true }),
405
- });
180
+ app.post('/webhooks/stripe', createWebhookHandler({
181
+ platform: 'stripe',
182
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
183
+ handler: async (payload, metadata, c) => c.json({
184
+ received: true,
185
+ eventId: metadata.id,
186
+ payload,
187
+ }),
188
+ }));
406
189
  ```
407
190
 
408
- ### fal.ai production usage
191
+ > All built-in platforms work across Express, Next.js, Cloudflare, and Hono adapters. You only change `platform` and `secret` per route.
409
192
 
410
- fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs:
411
- `{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
193
+ ## Supported Platforms
412
194
 
413
- Use one of these strategies:
414
- 1. **Public key as `secret`** (recommended for framework adapters).
415
- 2. **JWKS auto-resolution** via the built-in fal.ai config (`x-fal-webhook-key-id` + fal JWKS URL).
195
+ > ⚠️ Normalization is no longer supported in Tern and has been removed from the public verification APIs.
196
+
197
+
198
+ | Platform | Algorithm | Status |
199
+ |---|---|---|
200
+ | **Stripe** | HMAC-SHA256 | ✅ Tested |
201
+ | **GitHub** | HMAC-SHA256 | ✅ Tested |
202
+ | **Clerk** | HMAC-SHA256 (base64) | ✅ Tested |
203
+ | **Shopify** | HMAC-SHA256 (base64) | ✅ Tested |
204
+ | **Dodo Payments** | HMAC-SHA256 | ✅ Tested |
205
+ | **Paddle** | HMAC-SHA256 | ✅ Tested |
206
+ | **Lemon Squeezy** | HMAC-SHA256 | ✅ Tested |
207
+ | **Polar** | HMAC-SHA256 | ✅ Tested |
208
+ | **WorkOS** | HMAC-SHA256 | ✅ Tested |
209
+ | **ReplicateAI** | HMAC-SHA256 | ✅ Tested |
210
+ | **GitLab** | Token-based | ✅ Tested |
211
+ | **fal.ai** | ED25519 | ✅ Tested |
212
+ | **Sentry** | HMAC-SHA256 | ✅ Tested |
213
+ | **Grafana** | HMAC-SHA256 | ✅ Tested |
214
+ | **Doppler** | HMAC-SHA256 | ✅ Tested |
215
+ | **Sanity** | HMAC-SHA256 | ✅ Tested |
216
+ | **Svix** | HMAC-SHA256 | ⚠️ Untested for now |
217
+ | **Linear** | HMAC-SHA256 | ⚠️ Untested for now |
218
+ | **PagerDuty** | HMAC-SHA256 | ⚠️ Untested for now |
219
+ | **Twilio** | HMAC-SHA1 | ⚠️ Untested for now |
220
+ | **Razorpay** | HMAC-SHA256 | 🔄 Pending |
221
+ | **Vercel** | HMAC-SHA256 | 🔄 Pending |
222
+
223
+ > Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues).
224
+
225
+ ### Platform signature notes
226
+
227
+ - **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`.
228
+ - **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`.
229
+ - **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly.
230
+
231
+ ### Note on fal.ai
232
+
233
+ fal.ai uses **ED25519** signing. Pass an **empty string** as the webhook secret — the public key is resolved automatically via JWKS from fal's infrastructure.
416
234
 
417
235
  ```typescript
418
- // Next.js with explicit public key PEM as secret
236
+ import { createWebhookHandler } from '@hookflo/tern/nextjs';
237
+
419
238
  export const POST = createWebhookHandler({
420
239
  platform: 'falai',
421
- secret: process.env.FAL_PUBLIC_KEY_PEM!,
240
+ secret: '', // fal.ai resolves the public key automatically
422
241
  handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
423
242
  });
424
243
  ```
425
244
 
426
- ## API Reference
427
-
428
- ### WebhookVerificationService
429
-
430
- #### `verify(request: Request, config: WebhookConfig): Promise<WebhookVerificationResult>`
431
-
432
- Verifies a webhook using the provided configuration.
433
-
434
- #### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult>`
435
-
436
- Simplified verification using platform-specific configurations with optional payload normalization.
245
+ ## Key Features
437
246
 
438
- #### `verifyAny(request: Request, secrets: Record<string, string>, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult>`
247
+ - **Queue + Retry Support** optional Upstash QStash-based reliable inbound webhook delivery with automatic retries and deduplication
248
+ - **DLQ + Replay Controls** — list failed events, replay DLQ messages, and trigger replay-aware alerts
249
+ - **Alerting** — built-in Slack + Discord alerts through adapters and controls
250
+ - **Auto Platform Detection** — detect and verify across multiple providers via `verifyAny` with diagnostics on failure
251
+ - **Algorithm Agnostic** — HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms
252
+ - **Zero Dependencies** — no bloat, no supply chain risk
253
+ - **Framework Agnostic** — works with Express, Next.js, Cloudflare Workers, Hono, Deno, Bun, and any runtime with Web Crypto
254
+ - **Body-Parser Safe** — reads raw bodies correctly to prevent signature mismatch
255
+ - **Strong TypeScript** — strict types, full inference, comprehensive type definitions
256
+ - **Stable Error Codes** — `INVALID_SIGNATURE`, `MISSING_SIGNATURE`, `TIMESTAMP_EXPIRED`, and more
439
257
 
440
- Auto-detects platform from headers and verifies against one or more provider secrets.
258
+ ## Reliable Delivery & Alerting
441
259
 
442
- #### `verifyTokenAuth(request: Request, webhookId: string, webhookToken: string): Promise<WebhookVerificationResult>`
260
+ Tern supports both immediate and queue-based webhook processing. Queue mode is **optional and opt-in** — bring your own Upstash account (BYOK).
443
261
 
444
- Verifies token-based webhooks.
445
-
446
- > `verifyTokenBased(...)` remains available as a backward-compatible alias and still works for existing integrations.
447
-
448
- #### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]`
449
-
450
- Returns built-in providers that normalize into a shared schema for the given migration category.
451
-
452
- ### Types
453
-
454
- #### `WebhookVerificationResult`
262
+ ### Non-queue mode (default)
455
263
 
456
264
  ```typescript
457
- interface WebhookVerificationResult {
458
- isValid: boolean;
459
- error?: string;
460
- errorCode?: WebhookErrorCode;
461
- platform: WebhookPlatform;
462
- payload?: any;
463
- eventId?: string; // canonical ID, e.g. 'stripe:evt_123'
464
- metadata?: {
465
- timestamp?: string;
466
- id?: string | null; // raw provider ID (legacy)
467
- [key: string]: any;
468
- };
469
- }
470
- ```
471
-
472
- #### `WebhookConfig`
265
+ import { createWebhookHandler } from '@hookflo/tern/nextjs';
473
266
 
474
- ```typescript
475
- interface WebhookConfig {
476
- platform: WebhookPlatform;
477
- secret: string;
478
- toleranceInSeconds?: number;
479
- signatureConfig?: SignatureConfig;
480
- normalize?: boolean | NormalizeOptions;
481
- }
267
+ export const POST = createWebhookHandler({
268
+ platform: 'stripe',
269
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
270
+ handler: async (payload) => {
271
+ return { ok: true };
272
+ },
273
+ });
482
274
  ```
483
275
 
276
+ ### Queue mode (opt-in)
484
277
 
485
- ## Alerting (Slack + Discord)
486
-
487
- For the simplest DX, configure webhooks once in `createTernControls` and call `controls.alert(...)`.
488
-
489
- ```ts
490
- import { createTernControls } from '@hookflo/tern/upstash';
278
+ ```typescript
279
+ import { createWebhookHandler } from '@hookflo/tern/nextjs';
491
280
 
492
- const controls = createTernControls({
493
- token: process.env.QSTASH_TOKEN!,
494
- notifications: {
495
- slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
496
- discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
281
+ export const POST = createWebhookHandler({
282
+ platform: 'stripe',
283
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
284
+ queue: true,
285
+ handler: async (payload, metadata) => {
286
+ return { processed: true, eventId: metadata.id };
497
287
  },
498
288
  });
499
-
500
- // Non-DLQ event alert with defaults
501
- await controls.alert();
502
-
503
- // DLQ alert + replay flow
504
- await controls.alert({
505
- dlq: true,
506
- dlqId: 'dlq_xxx',
507
- });
508
289
  ```
509
290
 
510
- ### Behavior
291
+ ### Upstash Queue Setup
511
292
 
512
- - `controls.alert()` with no params sends a normal (non-DLQ) alert with internal defaults.
513
- - `controls.alert({ dlq: true, dlqId })` sends a DLQ alert and attempts replay internally via `controls.replay(dlqId)`.
514
- - `eventId` is auto-filled from `dlqId` for DLQ alerts.
515
- - Optional overrides like `title`, `message`, `metadata`, `source`, or `branding` are used directly in Slack/Discord payloads.
293
+ 1. Create a QStash project at [console.upstash.com/qstash](https://console.upstash.com/qstash)
294
+ 2. Copy your keys: `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY`
295
+ 3. Add them to your environment and set `queue: true`
296
+ 4. Enable queue with `queue: true` (or explicit queue config).
516
297
 
517
- ### Adapter-level alerts (works with and without queue)
298
+ Direct queue config option:
518
299
 
519
- If you are not using Upstash controls, you can enable alerting directly in adapters by providing `alerts`.
520
- This works in **both queue and non-queue** modes.
300
+ ```typescript
301
+ queue: {
302
+ token: process.env.QSTASH_TOKEN!,
303
+ signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
304
+ nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
305
+ retries: 5,
306
+ }
307
+ ```
308
+
309
+ ### Simple alerting
521
310
 
522
- ```ts
311
+ ```typescript
523
312
  import { createWebhookHandler } from '@hookflo/tern/nextjs';
524
313
 
525
314
  export const POST = createWebhookHandler({
@@ -529,142 +318,155 @@ export const POST = createWebhookHandler({
529
318
  slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
530
319
  discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! },
531
320
  },
532
- alert: {
533
- title: 'Alert Recieved',
534
- message: 'Alert received in handler',
535
- },
536
- handler: async (payload, metadata) => {
537
- return { ok: true };
538
- },
321
+ handler: async () => ({ ok: true }),
539
322
  });
540
323
  ```
541
324
 
542
- - In non-queue mode, alerts include `source` (platform) and canonical `eventId` from verification.
543
- - In queue mode, normal alerts are sent on successful enqueue (DLQ alerting remains Upstash-controls based).
544
- - Adapter-level alert calls do **not** auto-inject metadata; pass explicit alert fields via `alert` for predictable payloads.
545
-
546
- ### Core SDK queue + alerts
547
-
548
- `WebhookVerificationService.handleWithQueue(...)` also supports alerting through the same `alerts` + `alert` options, so core SDK users get the same behavior as framework adapters.
325
+ ### DLQ-aware alerting and replay
549
326
 
550
- ## Testing
327
+ ```typescript
328
+ import { createTernControls } from '@hookflo/tern/upstash';
551
329
 
552
- ### Run All Tests
330
+ const controls = createTernControls({
331
+ token: process.env.QSTASH_TOKEN!,
332
+ notifications: {
333
+ slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
334
+ discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
335
+ },
336
+ });
553
337
 
554
- ```bash
555
- npm test
338
+ const dlqMessages = await controls.dlq();
339
+ if (dlqMessages.length > 0) {
340
+ await controls.alert({
341
+ dlq: true,
342
+ dlqId: dlqMessages[0].dlqId,
343
+ severity: 'warning',
344
+ message: 'Replay attempted for failed event',
345
+ });
346
+ }
556
347
  ```
557
348
 
558
- ### Platform-Specific Testing
349
+ ## Custom Platform Configuration
559
350
 
560
- ```bash
561
- # Test a specific platform
562
- npm run test:platform stripe
351
+ Not built-in? Configure any webhook provider without waiting for a library update.
563
352
 
564
- # Test all platforms
565
- npm run test:all
353
+ ```typescript
354
+ const result = await WebhookVerificationService.verify(request, {
355
+ platform: 'acmepay',
356
+ secret: 'acme_secret',
357
+ signatureConfig: {
358
+ algorithm: 'hmac-sha256',
359
+ headerName: 'x-acme-signature',
360
+ headerFormat: 'raw',
361
+ timestampHeader: 'x-acme-timestamp',
362
+ timestampFormat: 'unix',
363
+ payloadFormat: 'timestamped',
364
+ },
365
+ });
566
366
  ```
567
367
 
568
- ### Documentation and Analysis
368
+ ### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
569
369
 
570
- ```bash
571
- # Fetch platform documentation
572
- npm run docs:fetch
573
-
574
- # Generate diffs between versions
575
- npm run docs:diff
576
-
577
- # Analyze changes and generate reports
578
- npm run docs:analyze
370
+ ```typescript
371
+ const svixConfig = {
372
+ platform: 'my-svix-platform',
373
+ secret: 'whsec_abc123...',
374
+ 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
+ },
385
+ },
386
+ };
579
387
  ```
580
388
 
581
- ## Examples
582
-
583
- See the [examples.ts](./src/examples.ts) file for comprehensive usage examples.
584
-
585
- ## Contributing
389
+ See the [SignatureConfig type](https://tern.hookflo.com) for all options.
586
390
 
587
- We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to:
588
-
589
- - Set up your development environment
590
- - Add new platforms
591
- - Write tests
592
- - Submit pull requests
593
- - Follow our code style guidelines
594
-
595
- ### Quick Start for Contributors
596
-
597
- 1. Fork the repository
598
- 2. Clone your fork: `git clone https://github.com/your-username/tern.git`
599
- 3. Create a feature branch: `git checkout -b feature/your-feature-name`
600
- 4. Make your changes
601
- 5. Run tests: `npm test`
602
- 6. Submit a pull request
603
-
604
- ### Adding a New Platform
605
-
606
- See our [Platform Development Guide](CONTRIBUTING.md#adding-new-platforms) for step-by-step instructions on adding support for new webhook platforms.
607
-
608
- ## Code of Conduct
391
+ ## API Reference
609
392
 
610
- This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing.
393
+ ### `WebhookVerificationService`
611
394
 
612
- ## 📄 License
395
+ | Method | Description |
396
+ |---|---|
397
+ | `verify(request, config)` | Verify with full config object |
398
+ | `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms |
399
+ | `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify |
400
+ | `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification |
401
+ | `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` |
402
+ | `handleWithQueue(request, options)` | Core SDK helper for queue receive/process |
613
403
 
614
- MIT License - see [LICENSE](./LICENSE) for details.
404
+ ### `@hookflo/tern/upstash`
615
405
 
616
- ## 🔗 Links
406
+ | Export | Description |
407
+ |---|---|
408
+ | `createTernControls(config)` | Read DLQ/events, replay, and send alerts |
409
+ | `handleQueuedRequest(request, options)` | Route request between receive/process modes |
410
+ | `handleReceive(request, platform, secret, queueConfig, tolerance)` | Verify webhook and enqueue to QStash |
411
+ | `handleProcess(request, handler, queueConfig)` | Verify QStash signature and process payload |
412
+ | `resolveQueueConfig(queue)` | Resolve `queue: true` from env or explicit object |
617
413
 
618
- - [Documentation](./USAGE.md)
619
- - [Framework Summary](./FRAMEWORK_SUMMARY.md)
620
- - [Architecture Guide](./ARCHITECTURE.md)
621
- - [Issues](https://github.com/Hookflo/tern/issues)
414
+ ### `WebhookVerificationResult`
622
415
 
416
+ ```typescript
417
+ interface WebhookVerificationResult {
418
+ isValid: boolean;
419
+ error?: string;
420
+ errorCode?: string;
421
+ platform: WebhookPlatform;
422
+ payload?: any;
423
+ eventId?: string;
424
+ metadata?: {
425
+ timestamp?: string;
426
+ id?: string | null;
427
+ [key: string]: any;
428
+ };
429
+ }
430
+ ```
623
431
 
624
432
  ## Troubleshooting
625
433
 
626
- ### `Module not found: Can't resolve "@hookflo/tern/nextjs"`
627
-
628
- If this happens in a Next.js project, it usually means one of these:
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`.
629
435
 
630
- 1. You installed an older published package version that does not include subpath exports yet.
631
- 2. Lockfile still points to an old tarball/version.
632
- 3. `node_modules` cache is stale after upgrading.
633
436
 
634
- Fix steps:
437
+ **`Module not found: Can't resolve "@hookflo/tern/nextjs"`**
635
438
 
636
439
  ```bash
637
- # in your Next.js app
638
440
  npm i @hookflo/tern@latest
639
441
  rm -rf node_modules package-lock.json .next
640
442
  npm i
641
443
  ```
642
444
 
643
- Then verify resolution:
445
+ **Signature verification failing?**
446
+
447
+ Make sure you're passing the **raw** request body — not a parsed JSON object. Tern's framework adapters handle this automatically. If you're using the core service directly, ensure body parsers aren't consuming the stream before Tern does.
448
+
449
+ ## Contributing
450
+
451
+ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs.
644
452
 
645
453
  ```bash
646
- node -e "console.log(require.resolve('@hookflo/tern/nextjs'))"
454
+ git clone https://github.com/Hookflo/tern.git
455
+ cd tern
456
+ npm install
457
+ npm test
647
458
  ```
648
459
 
649
- If you are testing this repo locally before publish:
460
+ ## Support
650
461
 
651
- ```bash
652
- # inside /workspace/tern
653
- npm run build
654
- npm pack
462
+ Have a question, running into an issue, or want to request a platform? We're happy to help.
655
463
 
656
- # inside your other project
657
- npm i /path/to/hookflo-tern-<version>.tgz
658
- ```
464
+ Join the conversation on [Discord](https://discord.com/invite/SNmCjU97nr) or [open an issue](https://github.com/Hookflo/tern/issues) on GitHub — all questions, bug reports, and platform requests are welcome.
659
465
 
660
- Minimal Next.js App Router usage:
466
+ ## Links
661
467
 
662
- ```ts
663
- import { createWebhookHandler } from '@hookflo/tern/nextjs';
468
+ [Detailed Usage & Docs](https://tern.hookflo.com) · [npm Package](https://www.npmjs.com/package/@hookflo/tern) · [Discord Community](https://discord.com/invite/SNmCjU97nr) · [Issues](https://github.com/Hookflo/tern/issues)
664
469
 
665
- export const POST = createWebhookHandler({
666
- platform: 'stripe',
667
- secret: process.env.STRIPE_WEBHOOK_SECRET!,
668
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
669
- });
670
- ```
470
+ ## License
471
+
472
+ MIT © [Hookflo](https://hookflo.com)