npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hookflo/tern

v4.4.0

Published

A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms

Downloads

455

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.

npm version TypeScript License: MIT Zero Dependencies

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/tern

The 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 here

Framework 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 platform and secret per 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 standardwebhooks platform (with aliases for both webhook-* and svix-* headers). Clerk, Dodo Payments, Polar, and ReplicateAI all follow this pattern and commonly use a secret that starts with whsec_....
  • 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 verifyAny with 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 CodesINVALID_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

  1. Create a QStash project at console.upstash.com/qstash
  2. Copy your keys: QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY
  3. Add them to your environment and set queue: true
  4. 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 i

Signature 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 test

Support

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