@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.
- package/README.md +283 -481
- package/dist/adapters/cloudflare.d.ts +2 -2
- package/dist/adapters/cloudflare.js +6 -1
- package/dist/adapters/express.d.ts +2 -2
- package/dist/adapters/express.js +6 -1
- package/dist/adapters/hono.d.ts +21 -0
- package/dist/adapters/hono.js +74 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +3 -1
- package/dist/adapters/nextjs.d.ts +2 -2
- package/dist/adapters/nextjs.js +6 -1
- package/dist/adapters/shared.js +5 -2
- package/dist/hono.d.ts +2 -0
- package/dist/hono.js +5 -0
- package/dist/index.d.ts +3 -4
- package/dist/index.js +29 -19
- package/dist/platforms/algorithms.js +65 -0
- package/dist/types.d.ts +6 -65
- package/dist/types.js +4 -0
- package/dist/verifiers/algorithms.d.ts +8 -0
- package/dist/verifiers/algorithms.js +145 -17
- package/package.json +13 -2
- package/dist/normalization/index.d.ts +0 -20
- package/dist/normalization/index.js +0 -78
- package/dist/normalization/providers/payment/paypal.d.ts +0 -2
- package/dist/normalization/providers/payment/paypal.js +0 -12
- package/dist/normalization/providers/payment/razorpay.d.ts +0 -2
- package/dist/normalization/providers/payment/razorpay.js +0 -13
- package/dist/normalization/providers/payment/stripe.d.ts +0 -2
- package/dist/normalization/providers/payment/stripe.js +0 -13
- package/dist/normalization/providers/registry.d.ts +0 -5
- package/dist/normalization/providers/registry.js +0 -21
- package/dist/normalization/simple.d.ts +0 -4
- package/dist/normalization/simple.js +0 -126
- package/dist/normalization/storage/interface.d.ts +0 -13
- package/dist/normalization/storage/interface.js +0 -2
- package/dist/normalization/storage/memory.d.ts +0 -12
- package/dist/normalization/storage/memory.js +0 -39
- package/dist/normalization/templates/base/auth.d.ts +0 -2
- package/dist/normalization/templates/base/auth.js +0 -22
- package/dist/normalization/templates/base/ecommerce.d.ts +0 -2
- package/dist/normalization/templates/base/ecommerce.js +0 -25
- package/dist/normalization/templates/base/payment.d.ts +0 -2
- package/dist/normalization/templates/base/payment.js +0 -25
- package/dist/normalization/templates/registry.d.ts +0 -6
- package/dist/normalization/templates/registry.js +0 -22
- package/dist/normalization/transformer/engine.d.ts +0 -11
- package/dist/normalization/transformer/engine.js +0 -86
- package/dist/normalization/transformer/validator.d.ts +0 -12
- package/dist/normalization/transformer/validator.js +0 -56
- package/dist/normalization/types.d.ts +0 -79
- package/dist/normalization/types.js +0 -2
package/README.md
CHANGED
|
@@ -1,322 +1,129 @@
|
|
|
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
|
-
}
|
|
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
|
-
###
|
|
82
|
+
### Twilio example
|
|
149
83
|
|
|
150
84
|
```typescript
|
|
151
85
|
import { WebhookVerificationService } from '@hookflo/tern';
|
|
152
86
|
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
99
|
+
return Response.json({ ok: true });
|
|
100
|
+
}
|
|
175
101
|
```
|
|
176
102
|
|
|
177
|
-
|
|
103
|
+
### Core SDK (runtime-agnostic)
|
|
178
104
|
|
|
179
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
117
|
+
if (!verified.isValid) {
|
|
118
|
+
return new Response(JSON.stringify({ error: verified.error }), { status: 400 });
|
|
119
|
+
}
|
|
301
120
|
|
|
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
|
-
};
|
|
121
|
+
// verified.payload + verified.metadata available here
|
|
315
122
|
```
|
|
316
123
|
|
|
317
|
-
## Framework
|
|
124
|
+
## Framework Integrations
|
|
318
125
|
|
|
319
|
-
### Express.js
|
|
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
|
|
336
|
-
res.json({ received: true, 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,
|
|
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
|
|
165
|
+
export const onRequestPost = createWebhookHandler({
|
|
359
166
|
platform: 'stripe',
|
|
360
167
|
secretEnv: 'STRIPE_WEBHOOK_SECRET',
|
|
361
|
-
handler: async (payload) => ({ received: true,
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
`{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
|
|
193
|
+
## Supported Platforms
|
|
412
194
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
236
|
+
import { createWebhookHandler } from '@hookflo/tern/nextjs';
|
|
237
|
+
|
|
419
238
|
export const POST = createWebhookHandler({
|
|
420
239
|
platform: 'falai',
|
|
421
|
-
secret:
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
258
|
+
## Reliable Delivery & Alerting
|
|
441
259
|
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
###
|
|
291
|
+
### Upstash Queue Setup
|
|
511
292
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
298
|
+
Direct queue config option:
|
|
518
299
|
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
+
```typescript
|
|
328
|
+
import { createTernControls } from '@hookflo/tern/upstash';
|
|
551
329
|
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
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
|
-
|
|
349
|
+
## Custom Platform Configuration
|
|
559
350
|
|
|
560
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
-
###
|
|
368
|
+
### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
|
|
569
369
|
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
+
### `WebhookVerificationService`
|
|
611
394
|
|
|
612
|
-
|
|
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
|
-
|
|
404
|
+
### `@hookflo/tern/upstash`
|
|
615
405
|
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
+
git clone https://github.com/Hookflo/tern.git
|
|
455
|
+
cd tern
|
|
456
|
+
npm install
|
|
457
|
+
npm test
|
|
647
458
|
```
|
|
648
459
|
|
|
649
|
-
|
|
460
|
+
## Support
|
|
650
461
|
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
+
## Links
|
|
661
467
|
|
|
662
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
|
|
669
|
-
});
|
|
670
|
-
```
|
|
470
|
+
## License
|
|
471
|
+
|
|
472
|
+
MIT © [Hookflo](https://hookflo.com)
|