@hookflo/tern 4.2.9-beta → 4.3.0-beta.0
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.
- package/README.md +259 -488
- package/dist/adapters/hono.d.ts +21 -0
- package/dist/adapters/hono.js +69 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +3 -1
- package/dist/hono.d.ts +2 -0
- package/dist/hono.js +5 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -1,322 +1,108 @@
|
|
|
1
|
-
# Tern
|
|
1
|
+
# Tern — Webhook Verification for Every Platform
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@hookflo/tern)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](package.json)
|
|
7
9
|
|
|
8
|
-
|
|
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
|
-
[
|
|
15
|
-
[](https://www.typescriptlang.org/)
|
|
16
|
-
[](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
|
-
|
|
20
|
+
⭐ Star this repo to help others discover it · 💬 [Join our Discord](https://discord.com/invite/SNmCjU97nr)
|
|
23
21
|
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
|
|
24
|
+
**Navigation**
|
|
28
25
|
|
|
29
|
-
-
|
|
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
|
-
##
|
|
28
|
+
## The Problem
|
|
41
29
|
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
38
|
+
const githubSignature = req.headers['x-hub-signature-256'];
|
|
39
|
+
// ... completely different 20 lines for GitHub
|
|
40
|
+
```
|
|
51
41
|
|
|
52
|
-
```
|
|
53
|
-
|
|
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
|
-
###
|
|
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:
|
|
59
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
66
60
|
toleranceInSeconds: 300,
|
|
67
61
|
});
|
|
68
62
|
|
|
69
63
|
if (result.isValid) {
|
|
70
|
-
console.log('
|
|
64
|
+
console.log('Verified!', result.eventId, result.payload);
|
|
71
65
|
} else {
|
|
72
|
-
console.log('
|
|
66
|
+
console.log('Failed:', result.error, result.errorCode);
|
|
73
67
|
}
|
|
74
68
|
```
|
|
75
69
|
|
|
76
|
-
###
|
|
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
|
-
|
|
88
|
-
console.log(`Verified ${result.platform} webhook`);
|
|
89
|
-
}
|
|
79
|
+
console.log(`Verified ${result.platform} webhook`);
|
|
90
80
|
```
|
|
91
81
|
|
|
92
|
-
###
|
|
82
|
+
### Core SDK (runtime-agnostic)
|
|
93
83
|
|
|
94
|
-
|
|
84
|
+
Use Tern without framework adapters in any runtime that supports the Web `Request` API.
|
|
95
85
|
|
|
96
86
|
```typescript
|
|
97
|
-
import {
|
|
98
|
-
WebhookVerificationService,
|
|
99
|
-
PaymentWebhookNormalized,
|
|
100
|
-
} from '@hookflo/tern';
|
|
87
|
+
import { WebhookVerificationService } from '@hookflo/tern';
|
|
101
88
|
|
|
102
|
-
const
|
|
89
|
+
const verified = await WebhookVerificationService.verifyWithPlatformConfig(
|
|
103
90
|
request,
|
|
104
|
-
'
|
|
105
|
-
process.env.
|
|
91
|
+
'workos',
|
|
92
|
+
process.env.WORKOS_WEBHOOK_SECRET!,
|
|
106
93
|
300,
|
|
107
|
-
{ enabled: true, category: 'payment' },
|
|
108
94
|
);
|
|
109
95
|
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
console.log(result.payload.amount, result.payload.customer_id);
|
|
96
|
+
if (!verified.isValid) {
|
|
97
|
+
return new Response(JSON.stringify({ error: verified.error }), { status: 400 });
|
|
113
98
|
}
|
|
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
|
-
// }
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
### Platform-Specific Configurations
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
import { WebhookVerificationService } from '@hookflo/tern';
|
|
152
|
-
|
|
153
|
-
// Stripe webhook
|
|
154
|
-
const stripeConfig = {
|
|
155
|
-
platform: 'stripe',
|
|
156
|
-
secret: 'whsec_your_stripe_webhook_secret',
|
|
157
|
-
toleranceInSeconds: 300,
|
|
158
|
-
};
|
|
159
|
-
|
|
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
|
-
};
|
|
173
|
-
|
|
174
|
-
const result = await WebhookVerificationService.verify(request, stripeConfig);
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
## Supported Platforms
|
|
178
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
```typescript
|
|
219
|
-
import { WebhookVerificationService } from '@hookflo/tern';
|
|
220
|
-
|
|
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
|
-
```
|
|
299
|
-
|
|
300
|
-
### Custom Timestamped Payload
|
|
301
99
|
|
|
302
|
-
|
|
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
|
-
};
|
|
100
|
+
// verified.payload + verified.metadata available here
|
|
315
101
|
```
|
|
316
102
|
|
|
317
|
-
## Framework
|
|
103
|
+
## Framework Integrations
|
|
318
104
|
|
|
319
|
-
### Express.js
|
|
105
|
+
### Express.js
|
|
320
106
|
|
|
321
107
|
```typescript
|
|
322
108
|
import express from 'express';
|
|
@@ -326,14 +112,14 @@ const app = express();
|
|
|
326
112
|
|
|
327
113
|
app.post(
|
|
328
114
|
'/webhooks/stripe',
|
|
115
|
+
express.raw({ type: '*/*' }),
|
|
329
116
|
createWebhookMiddleware({
|
|
330
117
|
platform: 'stripe',
|
|
331
118
|
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
332
|
-
normalize: true,
|
|
333
119
|
}),
|
|
334
120
|
(req, res) => {
|
|
335
|
-
const event = (req as any).webhook
|
|
336
|
-
res.json({ received: true, event
|
|
121
|
+
const event = (req as any).webhook?.payload;
|
|
122
|
+
res.json({ received: true, event });
|
|
337
123
|
},
|
|
338
124
|
);
|
|
339
125
|
```
|
|
@@ -346,7 +132,7 @@ import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
|
346
132
|
export const POST = createWebhookHandler({
|
|
347
133
|
platform: 'github',
|
|
348
134
|
secret: process.env.GITHUB_WEBHOOK_SECRET!,
|
|
349
|
-
handler: async (payload) => ({ received: true,
|
|
135
|
+
handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }),
|
|
350
136
|
});
|
|
351
137
|
```
|
|
352
138
|
|
|
@@ -355,171 +141,146 @@ export const POST = createWebhookHandler({
|
|
|
355
141
|
```typescript
|
|
356
142
|
import { createWebhookHandler } from '@hookflo/tern/cloudflare';
|
|
357
143
|
|
|
358
|
-
const
|
|
144
|
+
export const onRequestPost = createWebhookHandler({
|
|
359
145
|
platform: 'stripe',
|
|
360
146
|
secretEnv: 'STRIPE_WEBHOOK_SECRET',
|
|
361
|
-
handler: async (payload) => ({ received: true,
|
|
147
|
+
handler: async (payload) => ({ received: true, payload }),
|
|
362
148
|
});
|
|
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
149
|
```
|
|
373
150
|
|
|
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)
|
|
151
|
+
### Hono (Edge Runtimes)
|
|
385
152
|
|
|
386
153
|
```typescript
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
platform: 'razorpay',
|
|
390
|
-
secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
|
|
391
|
-
}), (req, res) => res.json({ ok: true }));
|
|
154
|
+
import { Hono } from 'hono';
|
|
155
|
+
import { createWebhookHandler } from '@hookflo/tern/hono';
|
|
392
156
|
|
|
393
|
-
|
|
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
|
-
});
|
|
157
|
+
const app = new Hono();
|
|
399
158
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
159
|
+
app.post('/webhooks/stripe', createWebhookHandler({
|
|
160
|
+
platform: 'stripe',
|
|
161
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
162
|
+
handler: async (payload, metadata, c) => c.json({
|
|
163
|
+
received: true,
|
|
164
|
+
eventId: metadata.id,
|
|
165
|
+
payload,
|
|
166
|
+
}),
|
|
167
|
+
}));
|
|
406
168
|
```
|
|
407
169
|
|
|
408
|
-
|
|
170
|
+
> All built-in platforms work across Express, Next.js, Cloudflare, and Hono adapters. You only change `platform` and `secret` per route.
|
|
409
171
|
|
|
410
|
-
|
|
411
|
-
`{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
|
|
172
|
+
## Supported Platforms
|
|
412
173
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
174
|
+
| Platform | Algorithm | Status |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| **Stripe** | HMAC-SHA256 | ✅ Tested |
|
|
177
|
+
| **GitHub** | HMAC-SHA256 | ✅ Tested |
|
|
178
|
+
| **Clerk** | HMAC-SHA256 (base64) | ✅ Tested |
|
|
179
|
+
| **Shopify** | HMAC-SHA256 (base64) | ✅ Tested |
|
|
180
|
+
| **Dodo Payments** | HMAC-SHA256 | ✅ Tested |
|
|
181
|
+
| **Paddle** | HMAC-SHA256 | ✅ Tested |
|
|
182
|
+
| **Lemon Squeezy** | HMAC-SHA256 | ✅ Tested |
|
|
183
|
+
| **Polar** | HMAC-SHA256 | ✅ Tested |
|
|
184
|
+
| **WorkOS** | HMAC-SHA256 | ✅ Tested |
|
|
185
|
+
| **ReplicateAI** | HMAC-SHA256 | ✅ Tested |
|
|
186
|
+
| **GitLab** | Token-based | ✅ Tested |
|
|
187
|
+
| **fal.ai** | ED25519 | ✅ Tested |
|
|
188
|
+
| **Sentry** | HMAC-SHA256 | ✅ Tested |
|
|
189
|
+
| **Grafana** | HMAC-SHA256 | ✅ Tested |
|
|
190
|
+
| **Doppler** | HMAC-SHA256 | ✅ Tested |
|
|
191
|
+
| **Sanity** | HMAC-SHA256 | ✅ Tested |
|
|
192
|
+
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
|
|
193
|
+
| **Vercel** | HMAC-SHA256 | 🔄 Pending |
|
|
194
|
+
|
|
195
|
+
> Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues).
|
|
196
|
+
|
|
197
|
+
### Platform signature notes
|
|
198
|
+
|
|
199
|
+
- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`.
|
|
200
|
+
- **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`.
|
|
201
|
+
- **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly.
|
|
202
|
+
|
|
203
|
+
### Note on fal.ai
|
|
204
|
+
|
|
205
|
+
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
206
|
|
|
417
207
|
```typescript
|
|
418
|
-
|
|
208
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
209
|
+
|
|
419
210
|
export const POST = createWebhookHandler({
|
|
420
211
|
platform: 'falai',
|
|
421
|
-
secret:
|
|
212
|
+
secret: '', // fal.ai resolves the public key automatically
|
|
422
213
|
handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
|
|
423
214
|
});
|
|
424
215
|
```
|
|
425
216
|
|
|
426
|
-
##
|
|
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.
|
|
437
|
-
|
|
438
|
-
#### `verifyAny(request: Request, secrets: Record<string, string>, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise<WebhookVerificationResult>`
|
|
439
|
-
|
|
440
|
-
Auto-detects platform from headers and verifies against one or more provider secrets.
|
|
217
|
+
## Key Features
|
|
441
218
|
|
|
442
|
-
|
|
219
|
+
- **Queue + Retry Support** — optional Upstash QStash-based reliable inbound webhook delivery with automatic retries and deduplication
|
|
220
|
+
- **DLQ + Replay Controls** — list failed events, replay DLQ messages, and trigger replay-aware alerts
|
|
221
|
+
- **Alerting** — built-in Slack + Discord alerts through adapters and controls
|
|
222
|
+
- **Auto Platform Detection** — detect and verify across multiple providers via `verifyAny` with diagnostics on failure
|
|
223
|
+
- **Algorithm Agnostic** — HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms
|
|
224
|
+
- **Zero Dependencies** — no bloat, no supply chain risk
|
|
225
|
+
- **Framework Agnostic** — works with Express, Next.js, Cloudflare Workers, Hono, Deno, Bun, and any runtime with Web Crypto
|
|
226
|
+
- **Body-Parser Safe** — reads raw bodies correctly to prevent signature mismatch
|
|
227
|
+
- **Strong TypeScript** — strict types, full inference, comprehensive type definitions
|
|
228
|
+
- **Stable Error Codes** — `INVALID_SIGNATURE`, `MISSING_SIGNATURE`, `TIMESTAMP_EXPIRED`, and more
|
|
443
229
|
|
|
444
|
-
|
|
230
|
+
## Reliable Delivery & Alerting
|
|
445
231
|
|
|
446
|
-
|
|
232
|
+
Tern supports both immediate and queue-based webhook processing. Queue mode is **optional and opt-in** — bring your own Upstash account (BYOK).
|
|
447
233
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
Returns built-in providers that normalize into a shared schema for the given migration category.
|
|
451
|
-
|
|
452
|
-
### Types
|
|
453
|
-
|
|
454
|
-
#### `WebhookVerificationResult`
|
|
234
|
+
### Non-queue mode (default)
|
|
455
235
|
|
|
456
236
|
```typescript
|
|
457
|
-
|
|
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`
|
|
237
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
473
238
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
}
|
|
239
|
+
export const POST = createWebhookHandler({
|
|
240
|
+
platform: 'stripe',
|
|
241
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
242
|
+
handler: async (payload) => {
|
|
243
|
+
return { ok: true };
|
|
244
|
+
},
|
|
245
|
+
});
|
|
482
246
|
```
|
|
483
247
|
|
|
248
|
+
### Queue mode (opt-in)
|
|
484
249
|
|
|
485
|
-
|
|
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';
|
|
250
|
+
```typescript
|
|
251
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
491
252
|
|
|
492
|
-
const
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
253
|
+
export const POST = createWebhookHandler({
|
|
254
|
+
platform: 'stripe',
|
|
255
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
256
|
+
queue: true,
|
|
257
|
+
handler: async (payload, metadata) => {
|
|
258
|
+
return { processed: true, eventId: metadata.id };
|
|
497
259
|
},
|
|
498
260
|
});
|
|
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
261
|
```
|
|
509
262
|
|
|
510
|
-
###
|
|
263
|
+
### Upstash Queue Setup
|
|
511
264
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
265
|
+
1. Create a QStash project at [console.upstash.com/qstash](https://console.upstash.com/qstash)
|
|
266
|
+
2. Copy your keys: `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY`
|
|
267
|
+
3. Add them to your environment and set `queue: true`
|
|
268
|
+
4. Enable queue with `queue: true` (or explicit queue config).
|
|
516
269
|
|
|
517
|
-
|
|
270
|
+
Direct queue config option:
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
queue: {
|
|
274
|
+
token: process.env.QSTASH_TOKEN!,
|
|
275
|
+
signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
|
|
276
|
+
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
|
|
277
|
+
retries: 5,
|
|
278
|
+
}
|
|
279
|
+
```
|
|
518
280
|
|
|
519
|
-
|
|
520
|
-
This works in **both queue and non-queue** modes.
|
|
281
|
+
### Simple alerting
|
|
521
282
|
|
|
522
|
-
```
|
|
283
|
+
```typescript
|
|
523
284
|
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
524
285
|
|
|
525
286
|
export const POST = createWebhookHandler({
|
|
@@ -529,142 +290,152 @@ export const POST = createWebhookHandler({
|
|
|
529
290
|
slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
|
|
530
291
|
discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! },
|
|
531
292
|
},
|
|
532
|
-
|
|
533
|
-
title: 'Alert Recieved',
|
|
534
|
-
message: 'Alert received in handler',
|
|
535
|
-
},
|
|
536
|
-
handler: async (payload, metadata) => {
|
|
537
|
-
return { ok: true };
|
|
538
|
-
},
|
|
293
|
+
handler: async () => ({ ok: true }),
|
|
539
294
|
});
|
|
540
295
|
```
|
|
541
296
|
|
|
542
|
-
|
|
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
|
|
297
|
+
### DLQ-aware alerting and replay
|
|
547
298
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
## Testing
|
|
299
|
+
```typescript
|
|
300
|
+
import { createTernControls } from '@hookflo/tern/upstash';
|
|
551
301
|
|
|
552
|
-
|
|
302
|
+
const controls = createTernControls({
|
|
303
|
+
token: process.env.QSTASH_TOKEN!,
|
|
304
|
+
notifications: {
|
|
305
|
+
slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
|
|
306
|
+
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
553
309
|
|
|
554
|
-
|
|
555
|
-
|
|
310
|
+
const dlqMessages = await controls.dlq();
|
|
311
|
+
if (dlqMessages.length > 0) {
|
|
312
|
+
await controls.alert({
|
|
313
|
+
dlq: true,
|
|
314
|
+
dlqId: dlqMessages[0].dlqId,
|
|
315
|
+
severity: 'warning',
|
|
316
|
+
message: 'Replay attempted for failed event',
|
|
317
|
+
});
|
|
318
|
+
}
|
|
556
319
|
```
|
|
557
320
|
|
|
558
|
-
|
|
321
|
+
## Custom Platform Configuration
|
|
559
322
|
|
|
560
|
-
|
|
561
|
-
# Test a specific platform
|
|
562
|
-
npm run test:platform stripe
|
|
323
|
+
Not built-in? Configure any webhook provider without waiting for a library update.
|
|
563
324
|
|
|
564
|
-
|
|
565
|
-
|
|
325
|
+
```typescript
|
|
326
|
+
const result = await WebhookVerificationService.verify(request, {
|
|
327
|
+
platform: 'acmepay',
|
|
328
|
+
secret: 'acme_secret',
|
|
329
|
+
signatureConfig: {
|
|
330
|
+
algorithm: 'hmac-sha256',
|
|
331
|
+
headerName: 'x-acme-signature',
|
|
332
|
+
headerFormat: 'raw',
|
|
333
|
+
timestampHeader: 'x-acme-timestamp',
|
|
334
|
+
timestampFormat: 'unix',
|
|
335
|
+
payloadFormat: 'timestamped',
|
|
336
|
+
},
|
|
337
|
+
});
|
|
566
338
|
```
|
|
567
339
|
|
|
568
|
-
###
|
|
569
|
-
|
|
570
|
-
```bash
|
|
571
|
-
# Fetch platform documentation
|
|
572
|
-
npm run docs:fetch
|
|
573
|
-
|
|
574
|
-
# Generate diffs between versions
|
|
575
|
-
npm run docs:diff
|
|
340
|
+
### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
|
|
576
341
|
|
|
577
|
-
|
|
578
|
-
|
|
342
|
+
```typescript
|
|
343
|
+
const svixConfig = {
|
|
344
|
+
platform: 'my-svix-platform',
|
|
345
|
+
secret: 'whsec_abc123...',
|
|
346
|
+
signatureConfig: {
|
|
347
|
+
algorithm: 'hmac-sha256',
|
|
348
|
+
headerName: 'webhook-signature',
|
|
349
|
+
headerFormat: 'raw',
|
|
350
|
+
timestampHeader: 'webhook-timestamp',
|
|
351
|
+
timestampFormat: 'unix',
|
|
352
|
+
payloadFormat: 'custom',
|
|
353
|
+
customConfig: {
|
|
354
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
355
|
+
idHeader: 'webhook-id',
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
};
|
|
579
359
|
```
|
|
580
360
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
See the [examples.ts](./src/examples.ts) file for comprehensive usage examples.
|
|
584
|
-
|
|
585
|
-
## Contributing
|
|
586
|
-
|
|
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
|
|
361
|
+
See the [SignatureConfig type](https://tern.hookflo.com) for all options.
|
|
594
362
|
|
|
595
|
-
|
|
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
|
|
363
|
+
## API Reference
|
|
609
364
|
|
|
610
|
-
|
|
365
|
+
### `WebhookVerificationService`
|
|
611
366
|
|
|
612
|
-
|
|
367
|
+
| Method | Description |
|
|
368
|
+
|---|---|
|
|
369
|
+
| `verify(request, config)` | Verify with full config object |
|
|
370
|
+
| `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms |
|
|
371
|
+
| `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify |
|
|
372
|
+
| `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification |
|
|
373
|
+
| `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` |
|
|
374
|
+
| `handleWithQueue(request, options)` | Core SDK helper for queue receive/process |
|
|
613
375
|
|
|
614
|
-
|
|
376
|
+
### `@hookflo/tern/upstash`
|
|
615
377
|
|
|
616
|
-
|
|
378
|
+
| Export | Description |
|
|
379
|
+
|---|---|
|
|
380
|
+
| `createTernControls(config)` | Read DLQ/events, replay, and send alerts |
|
|
381
|
+
| `handleQueuedRequest(request, options)` | Route request between receive/process modes |
|
|
382
|
+
| `handleReceive(request, platform, secret, queueConfig, tolerance)` | Verify webhook and enqueue to QStash |
|
|
383
|
+
| `handleProcess(request, handler, queueConfig)` | Verify QStash signature and process payload |
|
|
384
|
+
| `resolveQueueConfig(queue)` | Resolve `queue: true` from env or explicit object |
|
|
617
385
|
|
|
618
|
-
|
|
619
|
-
- [Framework Summary](./FRAMEWORK_SUMMARY.md)
|
|
620
|
-
- [Architecture Guide](./ARCHITECTURE.md)
|
|
621
|
-
- [Issues](https://github.com/Hookflo/tern/issues)
|
|
386
|
+
### `WebhookVerificationResult`
|
|
622
387
|
|
|
388
|
+
```typescript
|
|
389
|
+
interface WebhookVerificationResult {
|
|
390
|
+
isValid: boolean;
|
|
391
|
+
error?: string;
|
|
392
|
+
errorCode?: string;
|
|
393
|
+
platform: WebhookPlatform;
|
|
394
|
+
payload?: any;
|
|
395
|
+
eventId?: string;
|
|
396
|
+
metadata?: {
|
|
397
|
+
timestamp?: string;
|
|
398
|
+
id?: string | null;
|
|
399
|
+
[key: string]: any;
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
```
|
|
623
403
|
|
|
624
404
|
## Troubleshooting
|
|
625
405
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
If this happens in a Next.js project, it usually means one of these:
|
|
629
|
-
|
|
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
|
-
|
|
634
|
-
Fix steps:
|
|
406
|
+
**`Module not found: Can't resolve "@hookflo/tern/nextjs"`**
|
|
635
407
|
|
|
636
408
|
```bash
|
|
637
|
-
# in your Next.js app
|
|
638
409
|
npm i @hookflo/tern@latest
|
|
639
410
|
rm -rf node_modules package-lock.json .next
|
|
640
411
|
npm i
|
|
641
412
|
```
|
|
642
413
|
|
|
643
|
-
|
|
414
|
+
**Signature verification failing?**
|
|
415
|
+
|
|
416
|
+
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.
|
|
417
|
+
|
|
418
|
+
## Contributing
|
|
419
|
+
|
|
420
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs.
|
|
644
421
|
|
|
645
422
|
```bash
|
|
646
|
-
|
|
423
|
+
git clone https://github.com/Hookflo/tern.git
|
|
424
|
+
cd tern
|
|
425
|
+
npm install
|
|
426
|
+
npm test
|
|
647
427
|
```
|
|
648
428
|
|
|
649
|
-
|
|
429
|
+
## Support
|
|
650
430
|
|
|
651
|
-
|
|
652
|
-
# inside /workspace/tern
|
|
653
|
-
npm run build
|
|
654
|
-
npm pack
|
|
431
|
+
Have a question, running into an issue, or want to request a platform? We're happy to help.
|
|
655
432
|
|
|
656
|
-
|
|
657
|
-
npm i /path/to/hookflo-tern-<version>.tgz
|
|
658
|
-
```
|
|
433
|
+
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
434
|
|
|
660
|
-
|
|
435
|
+
## Links
|
|
661
436
|
|
|
662
|
-
|
|
663
|
-
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
437
|
+
[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
438
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
|
|
669
|
-
});
|
|
670
|
-
```
|
|
439
|
+
## License
|
|
440
|
+
|
|
441
|
+
MIT © [Hookflo](https://hookflo.com)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { WebhookPlatform, NormalizeOptions } from '../types';
|
|
2
|
+
import { QueueOption } from '../upstash/types';
|
|
3
|
+
import type { AlertConfig, SendAlertOptions } from '../notifications/types';
|
|
4
|
+
export interface HonoContextLike {
|
|
5
|
+
req: {
|
|
6
|
+
raw: Request;
|
|
7
|
+
};
|
|
8
|
+
json: (payload: unknown, status?: number) => Response;
|
|
9
|
+
}
|
|
10
|
+
export interface HonoWebhookHandlerOptions<TContext extends HonoContextLike = HonoContextLike, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown> {
|
|
11
|
+
platform: WebhookPlatform;
|
|
12
|
+
secret: string;
|
|
13
|
+
toleranceInSeconds?: number;
|
|
14
|
+
normalize?: boolean | NormalizeOptions;
|
|
15
|
+
queue?: QueueOption;
|
|
16
|
+
alerts?: AlertConfig;
|
|
17
|
+
alert?: Omit<SendAlertOptions, 'dlq' | 'dlqId' | 'source' | 'eventId'>;
|
|
18
|
+
onError?: (error: Error) => void;
|
|
19
|
+
handler: (payload: TPayload, metadata: TMetadata, c: TContext) => Promise<TResponse> | TResponse;
|
|
20
|
+
}
|
|
21
|
+
export declare function createWebhookHandler<TContext extends HonoContextLike = HonoContextLike, TPayload = any, TMetadata extends Record<string, unknown> = Record<string, unknown>, TResponse = unknown>(options: HonoWebhookHandlerOptions<TContext, TPayload, TMetadata, TResponse>): (c: TContext) => Promise<Response>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createWebhookHandler = createWebhookHandler;
|
|
4
|
+
const index_1 = require("../index");
|
|
5
|
+
const queue_1 = require("../upstash/queue");
|
|
6
|
+
const dispatch_1 = require("../notifications/dispatch");
|
|
7
|
+
function createWebhookHandler(options) {
|
|
8
|
+
return async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const request = c.req.raw;
|
|
11
|
+
if (options.queue) {
|
|
12
|
+
const queueConfig = (0, queue_1.resolveQueueConfig)(options.queue);
|
|
13
|
+
const response = await (0, queue_1.handleQueuedRequest)(request, {
|
|
14
|
+
platform: options.platform,
|
|
15
|
+
secret: options.secret,
|
|
16
|
+
queueConfig,
|
|
17
|
+
handler: (payload, metadata) => options.handler(payload, metadata, c),
|
|
18
|
+
toleranceInSeconds: options.toleranceInSeconds ?? 300,
|
|
19
|
+
});
|
|
20
|
+
if (response.ok) {
|
|
21
|
+
let eventId;
|
|
22
|
+
try {
|
|
23
|
+
const body = await response.clone().json();
|
|
24
|
+
eventId = typeof body.eventId === 'string' ? body.eventId : undefined;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
eventId = undefined;
|
|
28
|
+
}
|
|
29
|
+
await (0, dispatch_1.dispatchWebhookAlert)({
|
|
30
|
+
alerts: options.alerts,
|
|
31
|
+
source: options.platform,
|
|
32
|
+
eventId,
|
|
33
|
+
alert: options.alert,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
|
|
39
|
+
if (!result.isValid) {
|
|
40
|
+
return c.json({
|
|
41
|
+
error: result.error,
|
|
42
|
+
errorCode: result.errorCode,
|
|
43
|
+
platform: result.platform,
|
|
44
|
+
metadata: result.metadata,
|
|
45
|
+
}, 400);
|
|
46
|
+
}
|
|
47
|
+
await (0, dispatch_1.dispatchWebhookAlert)({
|
|
48
|
+
alerts: options.alerts,
|
|
49
|
+
source: options.platform,
|
|
50
|
+
eventId: result.eventId,
|
|
51
|
+
alert: options.alert,
|
|
52
|
+
});
|
|
53
|
+
const data = await options.handler(result.payload, (result.metadata || {}), c);
|
|
54
|
+
if (data instanceof Response) {
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
return c.json(data);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (options.onError) {
|
|
61
|
+
options.onError(error);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.error('[tern/hono]', error);
|
|
65
|
+
}
|
|
66
|
+
return c.json({ error: error.message }, 500);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { createWebhookMiddleware, ExpressWebhookMiddlewareOptions, ExpressLikeRe
|
|
|
2
2
|
export { createWebhookHandler as createNextjsWebhookHandler, NextWebhookHandlerOptions, } from './nextjs';
|
|
3
3
|
export { createWebhookHandler as createCloudflareWebhookHandler, CloudflareWebhookHandlerOptions, } from './cloudflare';
|
|
4
4
|
export { toWebRequest, extractRawBody } from './shared';
|
|
5
|
+
export { createWebhookHandler as createHonoWebhookHandler, HonoWebhookHandlerOptions, HonoContextLike, } from './hono';
|
package/dist/adapters/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.extractRawBody = exports.toWebRequest = exports.createCloudflareWebhookHandler = exports.createNextjsWebhookHandler = exports.createWebhookMiddleware = void 0;
|
|
3
|
+
exports.createHonoWebhookHandler = exports.extractRawBody = exports.toWebRequest = exports.createCloudflareWebhookHandler = exports.createNextjsWebhookHandler = exports.createWebhookMiddleware = void 0;
|
|
4
4
|
var express_1 = require("./express");
|
|
5
5
|
Object.defineProperty(exports, "createWebhookMiddleware", { enumerable: true, get: function () { return express_1.createWebhookMiddleware; } });
|
|
6
6
|
var nextjs_1 = require("./nextjs");
|
|
@@ -10,3 +10,5 @@ Object.defineProperty(exports, "createCloudflareWebhookHandler", { enumerable: t
|
|
|
10
10
|
var shared_1 = require("./shared");
|
|
11
11
|
Object.defineProperty(exports, "toWebRequest", { enumerable: true, get: function () { return shared_1.toWebRequest; } });
|
|
12
12
|
Object.defineProperty(exports, "extractRawBody", { enumerable: true, get: function () { return shared_1.extractRawBody; } });
|
|
13
|
+
var hono_1 = require("./hono");
|
|
14
|
+
Object.defineProperty(exports, "createHonoWebhookHandler", { enumerable: true, get: function () { return hono_1.createWebhookHandler; } });
|
package/dist/hono.d.ts
ADDED
package/dist/hono.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createWebhookHandler = void 0;
|
|
4
|
+
var hono_1 = require("./adapters/hono");
|
|
5
|
+
Object.defineProperty(exports, "createWebhookHandler", { enumerable: true, get: function () { return hono_1.createWebhookHandler; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hookflo/tern",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0-beta.0",
|
|
4
4
|
"description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
"typescript",
|
|
33
33
|
"node",
|
|
34
34
|
"express",
|
|
35
|
-
"nextjs"
|
|
35
|
+
"nextjs",
|
|
36
|
+
"cloudflare",
|
|
37
|
+
"hono"
|
|
36
38
|
],
|
|
37
39
|
"author": "Prateek Jain",
|
|
38
40
|
"license": "MIT",
|
|
@@ -102,6 +104,12 @@
|
|
|
102
104
|
"require": "./dist/upstash/index.js",
|
|
103
105
|
"import": "./dist/upstash/index.js",
|
|
104
106
|
"default": "./dist/upstash/index.js"
|
|
107
|
+
},
|
|
108
|
+
"./hono": {
|
|
109
|
+
"types": "./dist/hono.d.ts",
|
|
110
|
+
"require": "./dist/hono.js",
|
|
111
|
+
"import": "./dist/hono.js",
|
|
112
|
+
"default": "./dist/hono.js"
|
|
105
113
|
}
|
|
106
114
|
},
|
|
107
115
|
"typesVersions": {
|
|
@@ -120,6 +128,9 @@
|
|
|
120
128
|
],
|
|
121
129
|
"upstash": [
|
|
122
130
|
"dist/upstash/index.d.ts"
|
|
131
|
+
],
|
|
132
|
+
"hono": [
|
|
133
|
+
"dist/hono.d.ts"
|
|
123
134
|
]
|
|
124
135
|
}
|
|
125
136
|
},
|