@hookflo/tern
v4.4.0
Published
A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms
Downloads
455
Maintainers
Readme
Tern — Webhook Verification for Every Platform
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.
Stop writing webhook verification from scratch. Tern handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. It also verifies Standard Webhooks (including Svix-style svix-* and canonical webhook-* headers) through a single standardwebhooks platform config.
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).
npm install @hookflo/ternThe same framework powering webhook verification at Hookflo.
⭐ Star this repo to help others discover it · 💬 Join our Discord
Navigation
The Problem · Quick Start · Framework Integrations · Supported Platforms · Key Features · Reliable Delivery & Alerting · Custom Config · API Reference · Troubleshooting · Contributing · Support
The Problem
Every webhook provider has a different signature format. You end up writing — and maintaining — the same verification boilerplate over and over:
// ❌ Without Tern — different logic for every provider
const stripeSignature = req.headers['stripe-signature'];
const parts = stripeSignature.split(',');
// ... 30 more lines just for Stripe
const githubSignature = req.headers['x-hub-signature-256'];
// ... completely different 20 lines for GitHub// ✅ With Tern — one API for everything
const result = await WebhookVerificationService.verify(request, {
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});Quick Start
Verify a single platform
import { WebhookVerificationService } from '@hookflo/tern';
const result = await WebhookVerificationService.verify(request, {
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
toleranceInSeconds: 300,
});
if (result.isValid) {
console.log('Verified!', result.eventId, result.payload);
} else {
console.log('Failed:', result.error, result.errorCode);
}Auto-detect platform
const result = await WebhookVerificationService.verifyAny(request, {
stripe: process.env.STRIPE_WEBHOOK_SECRET,
github: process.env.GITHUB_WEBHOOK_SECRET,
clerk: process.env.CLERK_WEBHOOK_SECRET,
});
console.log(`Verified ${result.platform} webhook`);Core SDK (runtime-agnostic)
Use Tern without framework adapters in any runtime that supports the Web Request API.
import { WebhookVerificationService } from '@hookflo/tern';
const verified = await WebhookVerificationService.verifyWithPlatformConfig(
request,
'workos',
process.env.WORKOS_WEBHOOK_SECRET!,
300,
);
if (!verified.isValid) {
return new Response(JSON.stringify({ error: verified.error }), { status: 400 });
}
// verified.payload + verified.metadata available hereFramework Integrations
Express.js
import express from 'express';
import { createWebhookMiddleware } from '@hookflo/tern/express';
const app = express();
app.post(
'/webhooks/stripe',
express.raw({ type: '*/*' }),
createWebhookMiddleware({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
(req, res) => {
const event = (req as any).webhook?.payload;
res.json({ received: true, event });
},
);Next.js App Router
import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'github',
secret: process.env.GITHUB_WEBHOOK_SECRET!,
handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }),
});Cloudflare Workers
import { createWebhookHandler } from '@hookflo/tern/cloudflare';
export const onRequestPost = createWebhookHandler({
platform: 'stripe',
secretEnv: 'STRIPE_WEBHOOK_SECRET',
handler: async (payload) => ({ received: true, payload }),
});Hono (Edge Runtimes)
import { Hono } from 'hono';
import { createWebhookHandler } from '@hookflo/tern/hono';
const app = new Hono();
app.post('/webhooks/stripe', createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload, metadata, c) => c.json({
received: true,
eventId: metadata.id,
payload,
}),
}));All built-in platforms work across Express, Next.js, Cloudflare, and Hono adapters. You only change
platformandsecretper route.
Supported Platforms
⚠️ Normalization is no longer supported in Tern and has been removed from the public verification APIs.
| Platform | Algorithm | Status |
|---|---|---|
| Stripe | HMAC-SHA256 | ✅ Tested |
| GitHub | HMAC-SHA256 | ✅ Tested |
| Clerk | HMAC-SHA256 (base64) | ✅ Tested |
| Shopify | HMAC-SHA256 (base64) | ✅ Tested |
| Dodo Payments | HMAC-SHA256 | ✅ Tested |
| Paddle | HMAC-SHA256 | ✅ Tested |
| Lemon Squeezy | HMAC-SHA256 | ✅ Tested |
| Polar | HMAC-SHA256 | ✅ Tested |
| WorkOS | HMAC-SHA256 | ✅ Tested |
| ReplicateAI | HMAC-SHA256 | ✅ Tested |
| GitLab | Token-based | ✅ Tested |
| fal.ai | ED25519 | ✅ Tested |
| Sentry | HMAC-SHA256 | ✅ Tested |
| Grafana | HMAC-SHA256 | ✅ Tested |
| Doppler | HMAC-SHA256 | ✅ Tested |
| Sanity | HMAC-SHA256 | ✅ Tested |
| Svix | HMAC-SHA256 | ⚠️ Untested for now |
| Standard Webhooks (standardwebhooks) | HMAC-SHA256 | ✅ Tested |
| Linear | HMAC-SHA256 | ⚠️ Untested for now |
| Razorpay | HMAC-SHA256 | 🔄 Pending |
| Vercel | HMAC-SHA256 | 🔄 Pending |
Don't see your platform? Use custom config or open an issue.
Platform signature notes
- Standard Webhooks style providers are supported via the canonical
standardwebhooksplatform (with aliases for bothwebhook-*andsvix-*headers). Clerk, Dodo Payments, Polar, and ReplicateAI all follow this pattern and commonly use a secret that starts withwhsec_.... - ReplicateAI: copy the webhook signing secret from your Replicate webhook settings and pass it directly as
secret. - fal.ai: supports JWKS key resolution out of the box — use
secret: ''for auto key resolution, or pass a PEM public key explicitly.
Note on fal.ai
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.
import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'falai',
secret: '', // fal.ai resolves the public key automatically
handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
});Key Features
- Queue + Retry Support — optional Upstash QStash-based reliable inbound webhook delivery with automatic retries and deduplication
- DLQ + Replay Controls — list failed events, replay DLQ messages, and trigger replay-aware alerts
- Alerting — built-in Slack + Discord alerts through adapters and controls
- Auto Platform Detection — detect and verify across multiple providers via
verifyAnywith diagnostics on failure - Algorithm Agnostic — HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms
- Zero Dependencies — no bloat, no supply chain risk
- Framework Agnostic — works with Express, Next.js, Cloudflare Workers, Hono, Deno, Bun, and any runtime with Web Crypto
- Body-Parser Safe — reads raw bodies correctly to prevent signature mismatch
- Strong TypeScript — strict types, full inference, comprehensive type definitions
- Stable Error Codes —
INVALID_SIGNATURE,MISSING_SIGNATURE,TIMESTAMP_EXPIRED, and more
Reliable Delivery & Alerting
Tern supports both immediate and queue-based webhook processing. Queue mode is optional and opt-in — bring your own Upstash account (BYOK).
Non-queue mode (default)
import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload) => {
return { ok: true };
},
});Queue mode (opt-in)
import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
queue: true,
handler: async (payload, metadata) => {
return { processed: true, eventId: metadata.id };
},
});Upstash Queue Setup
- Create a QStash project at console.upstash.com/qstash
- Copy your keys:
QSTASH_TOKEN,QSTASH_CURRENT_SIGNING_KEY,QSTASH_NEXT_SIGNING_KEY - Add them to your environment and set
queue: true - Enable queue with
queue: true(or explicit queue config).
Direct queue config option:
queue: {
token: process.env.QSTASH_TOKEN!,
signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
retries: 5,
}Simple alerting
import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
alerts: {
slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! },
discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! },
},
handler: async () => ({ ok: true }),
});DLQ-aware alerting and replay
import { createTernControls } from '@hookflo/tern/upstash';
const controls = createTernControls({
token: process.env.QSTASH_TOKEN!,
notifications: {
slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
},
});
const dlqMessages = await controls.dlq();
if (dlqMessages.length > 0) {
await controls.alert({
dlq: true,
dlqId: dlqMessages[0].dlqId,
severity: 'warning',
message: 'Replay attempted for failed event',
});
}Custom Platform Configuration
Not built-in? Configure any webhook provider without waiting for a library update.
const result = await WebhookVerificationService.verify(request, {
platform: 'acmepay',
secret: 'acme_secret',
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-acme-signature',
headerFormat: 'raw',
timestampHeader: 'x-acme-timestamp',
timestampFormat: 'unix',
payloadFormat: 'timestamped',
},
});Standard Webhooks config helpers (Svix-style and webhook-* headers)
import {
createStandardWebhooksConfig,
STANDARD_WEBHOOKS_BASE,
} from '@hookflo/tern';
const signatureConfig = createStandardWebhooksConfig({
id: 'webhook-id',
timestamp: 'webhook-timestamp',
signature: 'webhook-signature',
idAliases: ['svix-id'],
timestampAliases: ['svix-timestamp'],
signatureAliases: ['svix-signature'],
});
const result = await WebhookVerificationService.verify(request, {
platform: 'standardwebhooks',
secret: process.env.STANDARD_WEBHOOKS_SECRET!,
signatureConfig: {
...STANDARD_WEBHOOKS_BASE,
...signatureConfig,
},
});See the SignatureConfig type for all options.
API Reference
WebhookVerificationService
| Method | Description |
|---|---|
| verify(request, config) | Verify with full config object |
| verifyWithPlatformConfig(request, platform, secret, tolerance?) | Shorthand for built-in platforms |
| verifyAny(request, secrets, tolerance?) | Auto-detect platform and verify |
| verifyTokenAuth(request, webhookId, webhookToken) | Token-based verification |
| verifyTokenBased(request, webhookId, webhookToken) | Alias for verifyTokenAuth |
| handleWithQueue(request, options) | Core SDK helper for queue receive/process |
@hookflo/tern/upstash
| Export | Description |
|---|---|
| createTernControls(config) | Read DLQ/events, replay, and send alerts |
| handleQueuedRequest(request, options) | Route request between receive/process modes |
| handleReceive(request, platform, secret, queueConfig, tolerance) | Verify webhook and enqueue to QStash |
| handleProcess(request, handler, queueConfig) | Verify QStash signature and process payload |
| resolveQueueConfig(queue) | Resolve queue: true from env or explicit object |
WebhookVerificationResult
interface WebhookVerificationResult {
isValid: boolean;
error?: string;
errorCode?: string;
platform: WebhookPlatform;
payload?: any;
eventId?: string;
metadata?: {
timestamp?: string;
id?: string | null;
[key: string]: any;
};
}Troubleshooting
Module not found: Can't resolve "@hookflo/tern/nextjs"
npm i @hookflo/tern@latest
rm -rf node_modules package-lock.json .next
npm iSignature verification failing?
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.
Contributing
Contributions are welcome! See CONTRIBUTING.md for how to add platforms, write tests, and submit PRs.
git clone https://github.com/Hookflo/tern.git
cd tern
npm install
npm testSupport
Have a question, running into an issue, or want to request a platform? We're happy to help.
Join the conversation on Discord or open an issue on GitHub — all questions, bug reports, and platform requests are welcome.
Links
Detailed Usage & Docs · npm Package · Discord Community · Issues
License
MIT © Hookflo
