@hookflo/tern 4.1.1 → 4.2.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 +256 -438
- package/dist/adapters/cloudflare.d.ts +3 -0
- package/dist/adapters/cloudflare.js +25 -1
- package/dist/adapters/express.d.ts +3 -0
- package/dist/adapters/express.js +16 -0
- package/dist/adapters/nextjs.d.ts +3 -0
- package/dist/adapters/nextjs.js +25 -1
- package/dist/alerts.d.ts +7 -0
- package/dist/alerts.js +21 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -1
- package/dist/notifications/channels/discord/build-payload.d.ts +17 -0
- package/dist/notifications/channels/discord/build-payload.js +53 -0
- package/dist/notifications/channels/discord/index.d.ts +1 -0
- package/dist/notifications/channels/discord/index.js +5 -0
- package/dist/notifications/channels/slack/build-payload.d.ts +5 -0
- package/dist/notifications/channels/slack/build-payload.js +82 -0
- package/dist/notifications/channels/slack/index.d.ts +1 -0
- package/dist/notifications/channels/slack/index.js +5 -0
- package/dist/notifications/constants.d.ts +8 -0
- package/dist/notifications/constants.js +15 -0
- package/dist/notifications/dispatch.d.ts +8 -0
- package/dist/notifications/dispatch.js +13 -0
- package/dist/notifications/index.d.ts +10 -0
- package/dist/notifications/index.js +27 -0
- package/dist/notifications/send-alert.d.ts +2 -0
- package/dist/notifications/send-alert.js +69 -0
- package/dist/notifications/types.d.ts +48 -0
- package/dist/notifications/types.js +2 -0
- package/dist/notifications/utils.d.ts +4 -0
- package/dist/notifications/utils.js +80 -0
- package/dist/upstash/controls.d.ts +2 -6
- package/dist/upstash/controls.js +63 -0
- package/dist/upstash/index.d.ts +1 -1
- package/dist/upstash/queue.js +5 -1
- package/dist/upstash/types.d.ts +18 -0
- package/package.json +1 -1
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)
|
|
9
|
+
|
|
10
|
+
Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API.
|
|
7
11
|
|
|
8
|
-
|
|
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.
|
|
18
|
+
> The same framework powering webhook verification at [Hookflo](https://hookflo.com).
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
⭐ Star this repo to help others discover it · 💬 [Join our Discord](https://discord.com/invite/SNmCjU97nr)
|
|
21
21
|
|
|
22
|
-
<img width="
|
|
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;" />
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
**Navigation**
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
|
|
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)
|
|
28
27
|
|
|
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.)
|
|
28
|
+
## The Problem
|
|
39
29
|
|
|
40
|
-
|
|
30
|
+
Every webhook provider has a different signature format. You end up writing — and maintaining — the same verification boilerplate over and over:
|
|
41
31
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
99
|
|
|
123
|
-
|
|
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
|
-
// }
|
|
100
|
+
// verified.payload + verified.metadata available here
|
|
146
101
|
```
|
|
147
102
|
|
|
148
|
-
|
|
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
|
-
};
|
|
103
|
+
## Framework Integrations
|
|
173
104
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
## Framework Integration
|
|
318
|
-
|
|
319
|
-
### Express.js middleware (body-parser safe)
|
|
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,250 +141,282 @@ 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
|
|
|
151
|
+
> All built-in platforms work across Express, Next.js, and Cloudflare adapters. You only change `platform` and `secret` per route.
|
|
374
152
|
|
|
375
|
-
|
|
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.
|
|
153
|
+
## Supported Platforms
|
|
383
154
|
|
|
384
|
-
|
|
155
|
+
| Platform | Algorithm | Status |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| **Stripe** | HMAC-SHA256 | ✅ Tested |
|
|
158
|
+
| **GitHub** | HMAC-SHA256 | ✅ Tested |
|
|
159
|
+
| **Clerk** | HMAC-SHA256 (base64) | ✅ Tested |
|
|
160
|
+
| **Shopify** | HMAC-SHA256 (base64) | ✅ Tested |
|
|
161
|
+
| **Dodo Payments** | HMAC-SHA256 | ✅ Tested |
|
|
162
|
+
| **Paddle** | HMAC-SHA256 | ✅ Tested |
|
|
163
|
+
| **Lemon Squeezy** | HMAC-SHA256 | ✅ Tested |
|
|
164
|
+
| **Polar** | HMAC-SHA256 | ✅ Tested |
|
|
165
|
+
| **WorkOS** | HMAC-SHA256 | ✅ Tested |
|
|
166
|
+
| **ReplicateAI** | HMAC-SHA256 | ✅ Tested |
|
|
167
|
+
| **GitLab** | Token-based | ✅ Tested |
|
|
168
|
+
| **fal.ai** | ED25519 | ✅ Tested |
|
|
169
|
+
| **Sentry** | HMAC-SHA256 | ✅ Tested |
|
|
170
|
+
| **Grafana** | HMAC-SHA256 | ✅ Tested |
|
|
171
|
+
| **Doppler** | HMAC-SHA256 | ✅ Tested |
|
|
172
|
+
| **Sanity** | HMAC-SHA256 | ✅ Tested |
|
|
173
|
+
| **Razorpay** | HMAC-SHA256 | 🔄 Pending |
|
|
174
|
+
| **Vercel** | HMAC-SHA256 | 🔄 Pending |
|
|
175
|
+
|
|
176
|
+
> Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues).
|
|
177
|
+
|
|
178
|
+
### Platform signature notes
|
|
179
|
+
|
|
180
|
+
- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) commonly use a secret that starts with `whsec_...`.
|
|
181
|
+
- **ReplicateAI**: copy the webhook signing secret from your Replicate webhook settings and pass it directly as `secret`.
|
|
182
|
+
- **fal.ai**: supports JWKS key resolution out of the box — use `secret: ''` for auto key resolution, or pass a PEM public key explicitly.
|
|
183
|
+
|
|
184
|
+
### Note on fal.ai
|
|
185
|
+
|
|
186
|
+
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.
|
|
385
187
|
|
|
386
188
|
```typescript
|
|
387
|
-
|
|
388
|
-
app.post('/webhooks/razorpay', createWebhookMiddleware({
|
|
389
|
-
platform: 'razorpay',
|
|
390
|
-
secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
|
|
391
|
-
}), (req, res) => res.json({ ok: true }));
|
|
392
|
-
|
|
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
|
-
});
|
|
399
|
-
|
|
400
|
-
// Cloudflare (Lemon Squeezy)
|
|
401
|
-
const handleLemonSqueezy = createWebhookHandler({
|
|
402
|
-
platform: 'lemonsqueezy',
|
|
403
|
-
secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET',
|
|
404
|
-
handler: async () => ({ received: true }),
|
|
405
|
-
});
|
|
406
|
-
```
|
|
407
|
-
|
|
408
|
-
### fal.ai production usage
|
|
409
|
-
|
|
410
|
-
fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs:
|
|
411
|
-
`{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
|
|
412
|
-
|
|
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).
|
|
189
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
416
190
|
|
|
417
|
-
```typescript
|
|
418
|
-
// Next.js with explicit public key PEM as secret
|
|
419
191
|
export const POST = createWebhookHandler({
|
|
420
192
|
platform: 'falai',
|
|
421
|
-
secret:
|
|
193
|
+
secret: '', // fal.ai resolves the public key automatically
|
|
422
194
|
handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
|
|
423
195
|
});
|
|
424
196
|
```
|
|
425
197
|
|
|
426
|
-
##
|
|
427
|
-
|
|
428
|
-
### WebhookVerificationService
|
|
198
|
+
## Key Features
|
|
429
199
|
|
|
430
|
-
|
|
200
|
+
- **Queue + Retry Support** — optional Upstash QStash-based reliable inbound webhook delivery with automatic retries and deduplication
|
|
201
|
+
- **DLQ + Replay Controls** — list failed events, replay DLQ messages, and trigger replay-aware alerts
|
|
202
|
+
- **Alerting** — built-in Slack + Discord alerts through adapters and controls
|
|
203
|
+
- **Auto Platform Detection** — detect and verify across multiple providers via `verifyAny` with diagnostics on failure
|
|
204
|
+
- **Algorithm Agnostic** — HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms
|
|
205
|
+
- **Zero Dependencies** — no bloat, no supply chain risk
|
|
206
|
+
- **Framework Agnostic** — works with Express, Next.js, Cloudflare Workers, Deno, and any runtime with Web Crypto
|
|
207
|
+
- **Body-Parser Safe** — reads raw bodies correctly to prevent signature mismatch
|
|
208
|
+
- **Strong TypeScript** — strict types, full inference, comprehensive type definitions
|
|
209
|
+
- **Stable Error Codes** — `INVALID_SIGNATURE`, `MISSING_SIGNATURE`, `TIMESTAMP_EXPIRED`, and more
|
|
431
210
|
|
|
432
|
-
|
|
211
|
+
## Reliable Delivery & Alerting
|
|
433
212
|
|
|
434
|
-
|
|
213
|
+
Tern supports both immediate and queue-based webhook processing. Queue mode is **optional and opt-in** — bring your own Upstash account (BYOK).
|
|
435
214
|
|
|
436
|
-
|
|
215
|
+
### Non-queue mode (default)
|
|
437
216
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
Auto-detects platform from headers and verifies against one or more provider secrets.
|
|
217
|
+
```typescript
|
|
218
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
441
219
|
|
|
442
|
-
|
|
220
|
+
export const POST = createWebhookHandler({
|
|
221
|
+
platform: 'stripe',
|
|
222
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
223
|
+
handler: async (payload) => {
|
|
224
|
+
return { ok: true };
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
```
|
|
443
228
|
|
|
444
|
-
|
|
229
|
+
### Queue mode (opt-in)
|
|
445
230
|
|
|
446
|
-
|
|
231
|
+
```typescript
|
|
232
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
447
233
|
|
|
448
|
-
|
|
234
|
+
export const POST = createWebhookHandler({
|
|
235
|
+
platform: 'stripe',
|
|
236
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
237
|
+
queue: true,
|
|
238
|
+
handler: async (payload, metadata) => {
|
|
239
|
+
return { processed: true, eventId: metadata.id };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
```
|
|
449
243
|
|
|
450
|
-
|
|
244
|
+
### Upstash Queue Setup
|
|
451
245
|
|
|
452
|
-
|
|
246
|
+
1. Create a QStash project at [console.upstash.com/qstash](https://console.upstash.com/qstash)
|
|
247
|
+
2. Copy your keys: `QSTASH_TOKEN`, `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY`
|
|
248
|
+
3. Add them to your environment and set `queue: true`
|
|
249
|
+
4. Enable queue with `queue: true` (or explicit queue config).
|
|
453
250
|
|
|
454
|
-
|
|
251
|
+
Direct queue config option:
|
|
455
252
|
|
|
456
253
|
```typescript
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
};
|
|
254
|
+
queue: {
|
|
255
|
+
token: process.env.QSTASH_TOKEN!,
|
|
256
|
+
signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
|
|
257
|
+
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
|
|
258
|
+
retries: 5,
|
|
469
259
|
}
|
|
470
260
|
```
|
|
471
261
|
|
|
472
|
-
|
|
262
|
+
### Simple alerting
|
|
473
263
|
|
|
474
264
|
```typescript
|
|
475
|
-
|
|
476
|
-
platform: WebhookPlatform;
|
|
477
|
-
secret: string;
|
|
478
|
-
toleranceInSeconds?: number;
|
|
479
|
-
signatureConfig?: SignatureConfig;
|
|
480
|
-
normalize?: boolean | NormalizeOptions;
|
|
481
|
-
}
|
|
482
|
-
```
|
|
483
|
-
|
|
484
|
-
## Testing
|
|
485
|
-
|
|
486
|
-
### Run All Tests
|
|
487
|
-
|
|
488
|
-
```bash
|
|
489
|
-
npm test
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
### Platform-Specific Testing
|
|
493
|
-
|
|
494
|
-
```bash
|
|
495
|
-
# Test a specific platform
|
|
496
|
-
npm run test:platform stripe
|
|
265
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
497
266
|
|
|
498
|
-
|
|
499
|
-
|
|
267
|
+
export const POST = createWebhookHandler({
|
|
268
|
+
platform: 'stripe',
|
|
269
|
+
secret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
270
|
+
alerts: {
|
|
271
|
+
slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
|
|
272
|
+
discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! },
|
|
273
|
+
},
|
|
274
|
+
handler: async () => ({ ok: true }),
|
|
275
|
+
});
|
|
500
276
|
```
|
|
501
277
|
|
|
502
|
-
###
|
|
278
|
+
### DLQ-aware alerting and replay
|
|
503
279
|
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
npm run docs:fetch
|
|
280
|
+
```typescript
|
|
281
|
+
import { createTernControls } from '@hookflo/tern/upstash';
|
|
507
282
|
|
|
508
|
-
|
|
509
|
-
|
|
283
|
+
const controls = createTernControls({
|
|
284
|
+
token: process.env.QSTASH_TOKEN!,
|
|
285
|
+
notifications: {
|
|
286
|
+
slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
|
|
287
|
+
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
510
290
|
|
|
511
|
-
|
|
512
|
-
|
|
291
|
+
const dlqMessages = await controls.dlq();
|
|
292
|
+
if (dlqMessages.length > 0) {
|
|
293
|
+
await controls.alert({
|
|
294
|
+
dlq: true,
|
|
295
|
+
dlqId: dlqMessages[0].dlqId,
|
|
296
|
+
severity: 'warning',
|
|
297
|
+
message: 'Replay attempted for failed event',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
513
300
|
```
|
|
514
301
|
|
|
515
|
-
##
|
|
516
|
-
|
|
517
|
-
See the [examples.ts](./src/examples.ts) file for comprehensive usage examples.
|
|
518
|
-
|
|
519
|
-
## Contributing
|
|
520
|
-
|
|
521
|
-
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to:
|
|
302
|
+
## Custom Platform Configuration
|
|
522
303
|
|
|
523
|
-
-
|
|
524
|
-
- Add new platforms
|
|
525
|
-
- Write tests
|
|
526
|
-
- Submit pull requests
|
|
527
|
-
- Follow our code style guidelines
|
|
304
|
+
Not built-in? Configure any webhook provider without waiting for a library update.
|
|
528
305
|
|
|
529
|
-
|
|
306
|
+
```typescript
|
|
307
|
+
const result = await WebhookVerificationService.verify(request, {
|
|
308
|
+
platform: 'acmepay',
|
|
309
|
+
secret: 'acme_secret',
|
|
310
|
+
signatureConfig: {
|
|
311
|
+
algorithm: 'hmac-sha256',
|
|
312
|
+
headerName: 'x-acme-signature',
|
|
313
|
+
headerFormat: 'raw',
|
|
314
|
+
timestampHeader: 'x-acme-timestamp',
|
|
315
|
+
timestampFormat: 'unix',
|
|
316
|
+
payloadFormat: 'timestamped',
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
```
|
|
530
320
|
|
|
531
|
-
|
|
532
|
-
2. Clone your fork: `git clone https://github.com/your-username/tern.git`
|
|
533
|
-
3. Create a feature branch: `git checkout -b feature/your-feature-name`
|
|
534
|
-
4. Make your changes
|
|
535
|
-
5. Run tests: `npm test`
|
|
536
|
-
6. Submit a pull request
|
|
321
|
+
### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
|
|
537
322
|
|
|
538
|
-
|
|
323
|
+
```typescript
|
|
324
|
+
const svixConfig = {
|
|
325
|
+
platform: 'my-svix-platform',
|
|
326
|
+
secret: 'whsec_abc123...',
|
|
327
|
+
signatureConfig: {
|
|
328
|
+
algorithm: 'hmac-sha256',
|
|
329
|
+
headerName: 'webhook-signature',
|
|
330
|
+
headerFormat: 'raw',
|
|
331
|
+
timestampHeader: 'webhook-timestamp',
|
|
332
|
+
timestampFormat: 'unix',
|
|
333
|
+
payloadFormat: 'custom',
|
|
334
|
+
customConfig: {
|
|
335
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
336
|
+
idHeader: 'webhook-id',
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
```
|
|
539
341
|
|
|
540
|
-
See
|
|
342
|
+
See the [SignatureConfig type](https://tern.hookflo.com) for all options.
|
|
541
343
|
|
|
542
|
-
##
|
|
344
|
+
## API Reference
|
|
543
345
|
|
|
544
|
-
|
|
346
|
+
### `WebhookVerificationService`
|
|
545
347
|
|
|
546
|
-
|
|
348
|
+
| Method | Description |
|
|
349
|
+
|---|---|
|
|
350
|
+
| `verify(request, config)` | Verify with full config object |
|
|
351
|
+
| `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms |
|
|
352
|
+
| `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify |
|
|
353
|
+
| `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification |
|
|
354
|
+
| `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` |
|
|
355
|
+
| `handleWithQueue(request, options)` | Core SDK helper for queue receive/process |
|
|
547
356
|
|
|
548
|
-
|
|
357
|
+
### `@hookflo/tern/upstash`
|
|
549
358
|
|
|
550
|
-
|
|
359
|
+
| Export | Description |
|
|
360
|
+
|---|---|
|
|
361
|
+
| `createTernControls(config)` | Read DLQ/events, replay, and send alerts |
|
|
362
|
+
| `handleQueuedRequest(request, options)` | Route request between receive/process modes |
|
|
363
|
+
| `handleReceive(request, platform, secret, queueConfig, tolerance)` | Verify webhook and enqueue to QStash |
|
|
364
|
+
| `handleProcess(request, handler, queueConfig)` | Verify QStash signature and process payload |
|
|
365
|
+
| `resolveQueueConfig(queue)` | Resolve `queue: true` from env or explicit object |
|
|
551
366
|
|
|
552
|
-
|
|
553
|
-
- [Framework Summary](./FRAMEWORK_SUMMARY.md)
|
|
554
|
-
- [Architecture Guide](./ARCHITECTURE.md)
|
|
555
|
-
- [Issues](https://github.com/Hookflo/tern/issues)
|
|
367
|
+
### `WebhookVerificationResult`
|
|
556
368
|
|
|
369
|
+
```typescript
|
|
370
|
+
interface WebhookVerificationResult {
|
|
371
|
+
isValid: boolean;
|
|
372
|
+
error?: string;
|
|
373
|
+
errorCode?: string;
|
|
374
|
+
platform: WebhookPlatform;
|
|
375
|
+
payload?: any;
|
|
376
|
+
eventId?: string;
|
|
377
|
+
metadata?: {
|
|
378
|
+
timestamp?: string;
|
|
379
|
+
id?: string | null;
|
|
380
|
+
[key: string]: any;
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
```
|
|
557
384
|
|
|
558
385
|
## Troubleshooting
|
|
559
386
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
If this happens in a Next.js project, it usually means one of these:
|
|
563
|
-
|
|
564
|
-
1. You installed an older published package version that does not include subpath exports yet.
|
|
565
|
-
2. Lockfile still points to an old tarball/version.
|
|
566
|
-
3. `node_modules` cache is stale after upgrading.
|
|
567
|
-
|
|
568
|
-
Fix steps:
|
|
387
|
+
**`Module not found: Can't resolve "@hookflo/tern/nextjs"`**
|
|
569
388
|
|
|
570
389
|
```bash
|
|
571
|
-
# in your Next.js app
|
|
572
390
|
npm i @hookflo/tern@latest
|
|
573
391
|
rm -rf node_modules package-lock.json .next
|
|
574
392
|
npm i
|
|
575
393
|
```
|
|
576
394
|
|
|
577
|
-
|
|
395
|
+
**Signature verification failing?**
|
|
396
|
+
|
|
397
|
+
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.
|
|
398
|
+
|
|
399
|
+
## Contributing
|
|
400
|
+
|
|
401
|
+
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs.
|
|
578
402
|
|
|
579
403
|
```bash
|
|
580
|
-
|
|
404
|
+
git clone https://github.com/Hookflo/tern.git
|
|
405
|
+
cd tern
|
|
406
|
+
npm install
|
|
407
|
+
npm test
|
|
581
408
|
```
|
|
582
409
|
|
|
583
|
-
|
|
410
|
+
## Support
|
|
584
411
|
|
|
585
|
-
|
|
586
|
-
# inside /workspace/tern
|
|
587
|
-
npm run build
|
|
588
|
-
npm pack
|
|
412
|
+
Have a question, running into an issue, or want to request a platform? We're happy to help.
|
|
589
413
|
|
|
590
|
-
|
|
591
|
-
npm i /path/to/hookflo-tern-<version>.tgz
|
|
592
|
-
```
|
|
414
|
+
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.
|
|
593
415
|
|
|
594
|
-
|
|
416
|
+
## Links
|
|
595
417
|
|
|
596
|
-
|
|
597
|
-
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
418
|
+
[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)
|
|
598
419
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
|
|
603
|
-
});
|
|
604
|
-
```
|
|
420
|
+
## License
|
|
421
|
+
|
|
422
|
+
MIT © [Hookflo](https://hookflo.com)
|