diff --git a/README.md b/README.md
index 368e1bb..9874271 100644
--- a/README.md
+++ b/README.md
@@ -1,322 +1,108 @@
-# Tern - Algorithm Agnostic Webhook Verification Framework
+# Tern β Webhook Verification for Every Platform
-A robust, algorithm-agnostic webhook verification framework that supports multiple platforms with accurate signature verification and payload retrieval.
-The same framework that secures webhook verification at [Hookflo](https://hookflo.com).
+**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.
-β Star this repo to support the project and help others discover it!
+[](https://www.npmjs.com/package/@hookflo/tern)
+[](https://www.typescriptlang.org/)
+[](LICENSE)
+[](package.json)
+
+Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms β with one consistent API.
-π¬ Join the discussion & contribute in our Discord: [Hookflo Community](https://discord.com/invite/SNmCjU97nr)
+> 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).
```bash
npm install @hookflo/tern
```
-[](https://www.npmjs.com/package/@hookflo/tern)
-[](https://www.typescriptlang.org/)
-[](LICENSE)
-
-Tern is a zero-dependency TypeScript framework for robust webhook verification across multiple platforms and algorithms.
+> The same framework powering webhook verification at [Hookflo](https://hookflo.com).
-**Runtime requirements:** Node.js 18+ (or any runtime with Web Crypto + Fetch APIs, such as Deno and Cloudflare Workers).
+β Star this repo to help others discover it Β· π¬ [Join our Discord](https://discord.com/invite/SNmCjU97nr)
-
+
-## Features
+**Navigation**
-- **Algorithm Agnostic**: Decouples platform logic from signature verification β verify based on cryptographic algorithm, not hardcoded platform rules.
-Supports HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, and custom algorithms
+[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)
-- **Platform Specific**: Accurate implementations for **Stripe, GitHub, Clerk**, and other platforms
-- **Flexible Configuration**: Custom signature configurations for any webhook format
-- **Type Safe**: Full TypeScript support with comprehensive type definitions
-- **Framework Agnostic**: Works with Express.js, Next.js, Cloudflare Workers, and more
-- **Body-Parser Safe Adapters**: Read raw request bodies correctly to avoid signature mismatch issues
-- **Multi-Provider Verification**: Verify and auto-detect across multiple providers with one API
-- **Payload Normalization**: Opt-in normalized event shape to reduce provider lock-in
-- **Category-aware Migration**: Normalize within provider categories (payment/auth/infrastructure) for safe platform switching
-- **Strong Typed Normalized Schemas**: Category types like `PaymentWebhookNormalized` and `AuthWebhookNormalized` for safe migrations
-- **Foundational Error Taxonomy**: Stable `errorCode` values (`INVALID_SIGNATURE`, `MISSING_SIGNATURE`, etc.)
+## The Problem
-## Why Tern?
+Every webhook provider has a different signature format. You end up writing β and maintaining β the same verification boilerplate over and over:
-Most webhook verifiers are tightly coupled to specific platforms or hardcoded logic. Tern introduces a flexible, scalable, algorithm-first approach that:
-
-- Works across all major platforms
-- Supports custom signing logic
-- Keeps your code clean and modular
-- Avoids unnecessary dependencies
-- Is written in strict, modern TypeScript
+```typescript
+// β Without Tern β different logic for every provider
+const stripeSignature = req.headers['stripe-signature'];
+const parts = stripeSignature.split(',');
+// ... 30 more lines just for Stripe
-## Installation
+const githubSignature = req.headers['x-hub-signature-256'];
+// ... completely different 20 lines for GitHub
+```
-```bash
-npm install @hookflo/tern
+```typescript
+// β
With Tern β one API for everything
+const result = await WebhookVerificationService.verify(request, {
+ platform: 'stripe',
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
+});
```
## Quick Start
-### Basic Usage
+### Verify a single platform
```typescript
import { WebhookVerificationService } from '@hookflo/tern';
const result = await WebhookVerificationService.verify(request, {
platform: 'stripe',
- secret: 'whsec_your_stripe_webhook_secret',
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
toleranceInSeconds: 300,
});
if (result.isValid) {
- console.log('Webhook verified!', result.eventId, result.payload);
+ console.log('Verified!', result.eventId, result.payload);
} else {
- console.log('Verification failed:', result.error);
+ console.log('Failed:', result.error, result.errorCode);
}
```
-### Universal Verification (auto-detect platform)
+### Auto-detect platform
```typescript
-import { WebhookVerificationService } from '@hookflo/tern';
-
const result = await WebhookVerificationService.verifyAny(request, {
stripe: process.env.STRIPE_WEBHOOK_SECRET,
github: process.env.GITHUB_WEBHOOK_SECRET,
clerk: process.env.CLERK_WEBHOOK_SECRET,
});
-if (result.isValid) {
- console.log(`Verified ${result.platform} webhook`);
-}
+console.log(`Verified ${result.platform} webhook`);
```
-### Category-aware Payload Normalization
+### Core SDK (runtime-agnostic)
-### Strongly-Typed Normalized Payloads
+Use Tern without framework adapters in any runtime that supports the Web `Request` API.
```typescript
-import {
- WebhookVerificationService,
- PaymentWebhookNormalized,
-} from '@hookflo/tern';
+import { WebhookVerificationService } from '@hookflo/tern';
-const result = await WebhookVerificationService.verifyWithPlatformConfig(
+const verified = await WebhookVerificationService.verifyWithPlatformConfig(
request,
- 'stripe',
- process.env.STRIPE_WEBHOOK_SECRET!,
+ 'workos',
+ process.env.WORKOS_WEBHOOK_SECRET!,
300,
- { enabled: true, category: 'payment' },
);
-if (result.isValid && result.payload?.event === 'payment.succeeded') {
- // result.payload is strongly typed
- console.log(result.payload.amount, result.payload.customer_id);
+if (!verified.isValid) {
+ return new Response(JSON.stringify({ error: verified.error }), { status: 400 });
}
-```
-
-```typescript
-import { WebhookVerificationService, getPlatformsByCategory } from '@hookflo/tern';
-
-// Discover migration-compatible providers in the same category
-const paymentPlatforms = getPlatformsByCategory('payment');
-// ['stripe', 'polar', ...]
-const result = await WebhookVerificationService.verifyWithPlatformConfig(
- request,
- 'stripe',
- process.env.STRIPE_WEBHOOK_SECRET!,
- 300,
- {
- enabled: true,
- category: 'payment',
- includeRaw: true,
- },
-);
-
-console.log(result.payload);
-// {
-// event: 'payment.succeeded',
-// amount: 5000,
-// currency: 'USD',
-// customer_id: 'cus_123',
-// transaction_id: 'pi_123',
-// provider: 'stripe',
-// category: 'payment',
-// _raw: {...}
-// }
+// verified.payload + verified.metadata available here
```
-### Platform-Specific Configurations
-
-```typescript
-import { WebhookVerificationService } from '@hookflo/tern';
-
-// Stripe webhook
-const stripeConfig = {
- platform: 'stripe',
- secret: 'whsec_your_stripe_webhook_secret',
- toleranceInSeconds: 300,
-};
-
-// GitHub webhook
-const githubConfig = {
- platform: 'github',
- secret: 'your_github_webhook_secret',
- toleranceInSeconds: 300,
-};
-
-// Clerk webhook
-const clerkConfig = {
- platform: 'clerk',
- secret: 'whsec_your_clerk_webhook_secret',
- toleranceInSeconds: 300,
-};
+## Framework Integrations
-const result = await WebhookVerificationService.verify(request, stripeConfig);
-```
-
-## Supported Platforms
-
-### Stripe OK Tested
-- **Signature Format**: `t={timestamp},v1={signature}`
-- **Algorithm**: HMAC-SHA256
-- **Payload Format**: `{timestamp}.{body}`
-
-### GitHub
-- **Signature Format**: `sha256={signature}`
-- **Algorithm**: HMAC-SHA256
-- **Payload Format**: Raw body
-
-### Clerk
-- **Signature Format**: `v1,{signature}` (space-separated)
-- **Algorithm**: HMAC-SHA256 with base64 encoding
-- **Payload Format**: `{id}.{timestamp}.{body}`
-
-### Other Platforms
-- **Dodo Payments**: HMAC-SHA256 OK Tested
-- **Paddle**: HMAC-SHA256 OK Tested
-- **Razorpay**: HMAC-SHA256 Pending
-- **Lemon Squeezy**: HMAC-SHA256 OK Tested
-- **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`) OK Tested
-- **WooCommerce**: HMAC-SHA256 (base64 signature) Pending
-- **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) OK Tested
-- **Sentry**: HMAC-SHA256 (`sentry-hook-signature`) with JSON-stringified payload + issue-alert fallback
-- **Grafana (v12+)**: HMAC-SHA256 (`x-grafana-alerting-signature`) with optional timestamped payload
-- **Doppler**: HMAC-SHA256 (`x-doppler-signature`, `sha256=` prefix)
-- **Sanity**: Stripe-compatible HMAC-SHA256 (`sanity-webhook-signature`, `t=/v1=`)
-- **fal.ai**: ED25519 (`x-fal-webhook-signature`)
-- **Shopify**: HMAC-SHA256 (base64 signature) OK Tested
-- **Vercel**: HMAC-SHA256 Pending
-- **Polar**: HMAC-SHA256 OK Tested
-- **GitLab**: Token-based authentication OK Tested
-
-## Custom Platform Configuration
-
-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.
-
-### Example: Standard HMAC-SHA256 Webhook
-
-```typescript
-import { WebhookVerificationService } from '@hookflo/tern';
-
-const acmeConfig = {
- platform: 'acmepay',
- secret: 'acme_secret',
- signatureConfig: {
- algorithm: 'hmac-sha256',
- headerName: 'x-acme-signature',
- headerFormat: 'raw',
- // Optional: only include when provider sends timestamp in a separate header
- timestampHeader: 'x-acme-timestamp',
- timestampFormat: 'unix',
- payloadFormat: 'timestamped', // signs as {timestamp}.{body}
- }
-};
-
-const result = await WebhookVerificationService.verify(request, acmeConfig);
-```
-
-### Example: Svix/Standard Webhooks (Clerk, Dodo Payments, etc.)
-
-```typescript
-const svixConfig = {
- platform: 'my-svix-platform',
- secret: 'whsec_abc123...',
- signatureConfig: {
- algorithm: 'hmac-sha256',
- headerName: 'webhook-signature',
- headerFormat: 'raw',
- timestampHeader: 'webhook-timestamp',
- timestampFormat: 'unix',
- payloadFormat: 'custom',
- customConfig: {
- payloadFormat: '{id}.{timestamp}.{body}',
- idHeader: 'webhook-id',
- // encoding: 'base64' // only if the provider uses base64, otherwise omit
- }
- }
-};
-
-const result = await WebhookVerificationService.verify(request, svixConfig);
-```
-
-You can configure any combination of algorithm, header, payload, and encoding. See the `SignatureConfig` type for all options.
-
-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.
-
-## Verified Platforms (continuously tested)
-- **Stripe**
-- **GitHub**
-- **Clerk**
-- **Dodo Payments**
-- **GitLab**
-- **WorkOS**
-- **Lemon Squeezy**
-- **Paddle**
-- **Shopify**
-- **Polar**
-- **ReplicateAI**
-
-Other listed platforms are supported but may have lighter coverage depending on release cycle.
-
-
-## Custom Configurations
-
-### Custom HMAC-SHA256
-
-```typescript
-const customConfig = {
- platform: 'custom',
- secret: 'your_custom_secret',
- signatureConfig: {
- algorithm: 'hmac-sha256',
- headerName: 'x-custom-signature',
- headerFormat: 'prefixed',
- prefix: 'sha256=',
- payloadFormat: 'raw',
- },
-};
-```
-
-### Custom Timestamped Payload
-
-```typescript
-const timestampedConfig = {
- platform: 'custom',
- secret: 'your_custom_secret',
- signatureConfig: {
- algorithm: 'hmac-sha256',
- headerName: 'x-webhook-signature',
- headerFormat: 'raw',
- timestampHeader: 'x-webhook-timestamp',
- timestampFormat: 'unix',
- payloadFormat: 'timestamped',
- },
-};
-```
-
-## Framework Integration
-
-### Express.js middleware (body-parser safe)
+### Express.js
```typescript
import express from 'express';
@@ -326,14 +112,14 @@ const app = express();
app.post(
'/webhooks/stripe',
+ express.raw({ type: '*/*' }),
createWebhookMiddleware({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
- normalize: true,
}),
(req, res) => {
- const event = (req as any).webhook.payload;
- res.json({ received: true, event: event.event });
+ const event = (req as any).webhook?.payload;
+ res.json({ received: true, event });
},
);
```
@@ -346,7 +132,7 @@ import { createWebhookHandler } from '@hookflo/tern/nextjs';
export const POST = createWebhookHandler({
platform: 'github',
secret: process.env.GITHUB_WEBHOOK_SECRET!,
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
+ handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }),
});
```
@@ -355,250 +141,282 @@ export const POST = createWebhookHandler({
```typescript
import { createWebhookHandler } from '@hookflo/tern/cloudflare';
-const handleStripe = createWebhookHandler({
+export const onRequestPost = createWebhookHandler({
platform: 'stripe',
secretEnv: 'STRIPE_WEBHOOK_SECRET',
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
+ handler: async (payload) => ({ received: true, payload }),
});
-
-export default {
- async fetch(request: Request, env: Record) {
- if (new URL(request.url).pathname === '/webhooks/stripe') {
- return handleStripe(request, env);
- }
- return new Response('Not Found', { status: 404 });
- },
-};
```
+> All built-in platforms work across Express, Next.js, and Cloudflare adapters. You only change `platform` and `secret` per route.
-### Are new platforms available in framework middlewares automatically?
-
-Yes. All built-in platforms are available in:
-- `createWebhookMiddleware` (`@hookflo/tern/express`)
-- `createWebhookHandler` (`@hookflo/tern/nextjs`)
-- `createWebhookHandler` (`@hookflo/tern/cloudflare`)
-
-You only change `platform` and `secret` per route.
+## Supported Platforms
-### Platform route examples (Express / Next.js / Cloudflare)
+| 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 |
+| **Razorpay** | HMAC-SHA256 | π Pending |
+| **Vercel** | HMAC-SHA256 | π Pending |
+
+> Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues).
+
+### Platform signature notes
+
+- **Standard Webhooks style** platforms (Clerk, Dodo Payments, Polar, ReplicateAI) 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.
```typescript
-// Express (Razorpay)
-app.post('/webhooks/razorpay', createWebhookMiddleware({
- platform: 'razorpay',
- secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
-}), (req, res) => res.json({ ok: true }));
-
-// Next.js (WorkOS)
-export const POST = createWebhookHandler({
- platform: 'workos',
- secret: process.env.WORKOS_WEBHOOK_SECRET!,
- handler: async (payload) => ({ received: true, type: payload.type }),
-});
-
-// Cloudflare (Lemon Squeezy)
-const handleLemonSqueezy = createWebhookHandler({
- platform: 'lemonsqueezy',
- secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET',
- handler: async () => ({ received: true }),
-});
-```
-
-### fal.ai production usage
-
-fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs:
-`{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
-
-Use one of these strategies:
-1. **Public key as `secret`** (recommended for framework adapters).
-2. **JWKS auto-resolution** via the built-in fal.ai config (`x-fal-webhook-key-id` + fal JWKS URL).
+import { createWebhookHandler } from '@hookflo/tern/nextjs';
-```typescript
-// Next.js with explicit public key PEM as secret
export const POST = createWebhookHandler({
platform: 'falai',
- secret: process.env.FAL_PUBLIC_KEY_PEM!,
+ secret: '', // fal.ai resolves the public key automatically
handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
});
```
-## API Reference
-
-### WebhookVerificationService
+## Key Features
-#### `verify(request: Request, config: WebhookConfig): Promise`
+- **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, Deno, 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
-Verifies a webhook using the provided configuration.
+## Reliable Delivery & Alerting
-#### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise`
+Tern supports both immediate and queue-based webhook processing. Queue mode is **optional and opt-in** β bring your own Upstash account (BYOK).
-Simplified verification using platform-specific configurations with optional payload normalization.
+### Non-queue mode (default)
-#### `verifyAny(request: Request, secrets: Record, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise`
-
-Auto-detects platform from headers and verifies against one or more provider secrets.
+```typescript
+import { createWebhookHandler } from '@hookflo/tern/nextjs';
-#### `verifyTokenAuth(request: Request, webhookId: string, webhookToken: string): Promise`
+export const POST = createWebhookHandler({
+ platform: 'stripe',
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
+ handler: async (payload) => {
+ return { ok: true };
+ },
+});
+```
-Verifies token-based webhooks.
+### Queue mode (opt-in)
-> `verifyTokenBased(...)` remains available as a backward-compatible alias and still works for existing integrations.
+```typescript
+import { createWebhookHandler } from '@hookflo/tern/nextjs';
-#### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]`
+export const POST = createWebhookHandler({
+ platform: 'stripe',
+ secret: process.env.STRIPE_WEBHOOK_SECRET!,
+ queue: true,
+ handler: async (payload, metadata) => {
+ return { processed: true, eventId: metadata.id };
+ },
+});
+```
-Returns built-in providers that normalize into a shared schema for the given migration category.
+### Upstash Queue Setup
-### Types
+1. Create a QStash project at [console.upstash.com/qstash](https://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).
-#### `WebhookVerificationResult`
+Direct queue config option:
```typescript
-interface WebhookVerificationResult {
- isValid: boolean;
- error?: string;
- errorCode?: WebhookErrorCode;
- platform: WebhookPlatform;
- payload?: any;
- eventId?: string; // canonical ID, e.g. 'stripe:evt_123'
- metadata?: {
- timestamp?: string;
- id?: string | null; // raw provider ID (legacy)
- [key: string]: any;
- };
+queue: {
+ token: process.env.QSTASH_TOKEN!,
+ signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
+ nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
+ retries: 5,
}
```
-#### `WebhookConfig`
+### Simple alerting
```typescript
-interface WebhookConfig {
- platform: WebhookPlatform;
- secret: string;
- toleranceInSeconds?: number;
- signatureConfig?: SignatureConfig;
- normalize?: boolean | NormalizeOptions;
-}
-```
-
-## Testing
-
-### Run All Tests
-
-```bash
-npm test
-```
-
-### Platform-Specific Testing
-
-```bash
-# Test a specific platform
-npm run test:platform stripe
+import { createWebhookHandler } from '@hookflo/tern/nextjs';
-# Test all platforms
-npm run test:all
+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 }),
+});
```
-### Documentation and Analysis
+### DLQ-aware alerting and replay
-```bash
-# Fetch platform documentation
-npm run docs:fetch
+```typescript
+import { createTernControls } from '@hookflo/tern/upstash';
-# Generate diffs between versions
-npm run docs:diff
+const controls = createTernControls({
+ token: process.env.QSTASH_TOKEN!,
+ notifications: {
+ slackWebhookUrl: process.env.SLACK_WEBHOOK_URL,
+ discordWebhookUrl: process.env.DISCORD_WEBHOOK_URL,
+ },
+});
-# Analyze changes and generate reports
-npm run docs:analyze
+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',
+ });
+}
```
-## Examples
-
-See the [examples.ts](./src/examples.ts) file for comprehensive usage examples.
-
-## Contributing
-
-We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to:
+## Custom Platform Configuration
-- Set up your development environment
-- Add new platforms
-- Write tests
-- Submit pull requests
-- Follow our code style guidelines
+Not built-in? Configure any webhook provider without waiting for a library update.
-### Quick Start for Contributors
+```typescript
+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',
+ },
+});
+```
-1. Fork the repository
-2. Clone your fork: `git clone https://github.com/your-username/tern.git`
-3. Create a feature branch: `git checkout -b feature/your-feature-name`
-4. Make your changes
-5. Run tests: `npm test`
-6. Submit a pull request
+### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.)
-### Adding a New Platform
+```typescript
+const svixConfig = {
+ platform: 'my-svix-platform',
+ secret: 'whsec_abc123...',
+ signatureConfig: {
+ algorithm: 'hmac-sha256',
+ headerName: 'webhook-signature',
+ headerFormat: 'raw',
+ timestampHeader: 'webhook-timestamp',
+ timestampFormat: 'unix',
+ payloadFormat: 'custom',
+ customConfig: {
+ payloadFormat: '{id}.{timestamp}.{body}',
+ idHeader: 'webhook-id',
+ },
+ },
+};
+```
-See our [Platform Development Guide](CONTRIBUTING.md#adding-new-platforms) for step-by-step instructions on adding support for new webhook platforms.
+See the [SignatureConfig type](https://tern.hookflo.com) for all options.
-## Code of Conduct
+## API Reference
-This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing.
+### `WebhookVerificationService`
-## π License
+| 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 |
-MIT License - see [LICENSE](./LICENSE) for details.
+### `@hookflo/tern/upstash`
-## π Links
+| 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 |
-- [Documentation](./USAGE.md)
-- [Framework Summary](./FRAMEWORK_SUMMARY.md)
-- [Architecture Guide](./ARCHITECTURE.md)
-- [Issues](https://github.com/Hookflo/tern/issues)
+### `WebhookVerificationResult`
+```typescript
+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"`
-
-If this happens in a Next.js project, it usually means one of these:
-
-1. You installed an older published package version that does not include subpath exports yet.
-2. Lockfile still points to an old tarball/version.
-3. `node_modules` cache is stale after upgrading.
-
-Fix steps:
+**`Module not found: Can't resolve "@hookflo/tern/nextjs"`**
```bash
-# in your Next.js app
npm i @hookflo/tern@latest
rm -rf node_modules package-lock.json .next
npm i
```
-Then verify resolution:
+**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](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs.
```bash
-node -e "console.log(require.resolve('@hookflo/tern/nextjs'))"
+git clone https://github.com/Hookflo/tern.git
+cd tern
+npm install
+npm test
```
-If you are testing this repo locally before publish:
+## Support
-```bash
-# inside /workspace/tern
-npm run build
-npm pack
+Have a question, running into an issue, or want to request a platform? We're happy to help.
-# inside your other project
-npm i /path/to/hookflo-tern-.tgz
-```
+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.
-Minimal Next.js App Router usage:
+## Links
-```ts
-import { createWebhookHandler } from '@hookflo/tern/nextjs';
+[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)
-export const POST = createWebhookHandler({
- platform: 'stripe',
- secret: process.env.STRIPE_WEBHOOK_SECRET!,
- handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
-});
-```
+## License
+
+MIT Β© [Hookflo](https://hookflo.com)
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 6226de3..9180059 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@hookflo/tern",
- "version": "4.1.1",
+ "version": "4.2.9-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@hookflo/tern",
- "version": "4.1.1",
+ "version": "4.2.9-beta",
"license": "MIT",
"dependencies": {
"@upstash/qstash": "^2.9.0"
diff --git a/package.json b/package.json
index 1841264..efb2ef5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@hookflo/tern",
- "version": "4.1.1",
+ "version": "4.2.9-beta",
"description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
"main": "dist/index.js",
"types": "dist/index.d.ts",
diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts
index efd3ba3..331d4c4 100644
--- a/src/adapters/cloudflare.ts
+++ b/src/adapters/cloudflare.ts
@@ -2,6 +2,8 @@ import { WebhookPlatform, NormalizeOptions } from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
+import { dispatchWebhookAlert } from '../notifications/dispatch';
+import type { AlertConfig, SendAlertOptions } from '../notifications/types';
export interface CloudflareWebhookHandlerOptions, TPayload = any, TMetadata extends Record = Record, TResponse = unknown> {
platform: WebhookPlatform;
@@ -10,6 +12,8 @@ export interface CloudflareWebhookHandlerOptions,
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
queue?: QueueOption;
+ alerts?: AlertConfig;
+ alert?: Omit;
onError?: (error: Error) => void;
handler: (payload: TPayload, env: TEnv, metadata: TMetadata) => Promise | TResponse;
}
@@ -28,13 +32,32 @@ export function createWebhookHandler, TPayload =
if (options.queue) {
const queueConfig = resolveQueueConfig(options.queue);
- return handleQueuedRequest(request, {
+ const response = await handleQueuedRequest(request, {
platform: options.platform,
secret,
queueConfig,
handler: (payload: unknown, metadata: Record) => options.handler(payload as TPayload, env, metadata as TMetadata),
toleranceInSeconds: options.toleranceInSeconds ?? 300,
});
+
+ if (response.ok) {
+ let eventId: string | undefined;
+ try {
+ const body = await response.clone().json() as Record;
+ eventId = typeof body.eventId === 'string' ? body.eventId : undefined;
+ } catch {
+ eventId = undefined;
+ }
+
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId,
+ alert: options.alert,
+ });
+ }
+
+ return response;
}
const result = await WebhookVerificationService.verifyWithPlatformConfig(
@@ -49,6 +72,13 @@ export function createWebhookHandler, TPayload =
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
}
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId: result.eventId,
+ alert: options.alert,
+ });
+
const data = await options.handler(result.payload as TPayload, env, (result.metadata || {}) as TMetadata);
return Response.json(data);
} catch (error) {
diff --git a/src/adapters/express.ts b/src/adapters/express.ts
index e32a6e7..8748c94 100644
--- a/src/adapters/express.ts
+++ b/src/adapters/express.ts
@@ -7,6 +7,8 @@ import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
import { toWebRequest, MinimalNodeRequest, hasParsedBody } from './shared';
+import { dispatchWebhookAlert } from '../notifications/dispatch';
+import type { AlertConfig, SendAlertOptions } from '../notifications/types';
export interface ExpressLikeResponse {
status: (code: number) => ExpressLikeResponse;
@@ -25,6 +27,8 @@ export interface ExpressWebhookMiddlewareOptions {
toleranceInSeconds?: number;
normalize?: boolean | NormalizeOptions;
queue?: QueueOption;
+ alerts?: AlertConfig;
+ alert?: Omit;
onError?: (error: Error) => void;
strictRawBody?: boolean;
}
@@ -70,6 +74,17 @@ export function createWebhookMiddleware(
}
res.status(queueResponse.status).json(body ?? {});
+
+ if (queueResponse.ok) {
+ const queueResult = body && typeof body === 'object' ? body as Record : undefined;
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId: typeof queueResult?.eventId === 'string' ? queueResult.eventId : undefined,
+ alert: options.alert,
+ });
+ }
+
return;
}
@@ -92,6 +107,14 @@ export function createWebhookMiddleware(
}
req.webhook = result;
+
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId: result.eventId,
+ alert: options.alert,
+ });
+
next();
} catch (error) {
options.onError?.(error as Error);
diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts
index f55e6bd..f8ec0bf 100644
--- a/src/adapters/nextjs.ts
+++ b/src/adapters/nextjs.ts
@@ -2,6 +2,8 @@ import { WebhookPlatform, NormalizeOptions } from '../types';
import { WebhookVerificationService } from '../index';
import { handleQueuedRequest, resolveQueueConfig } from '../upstash/queue';
import { QueueOption } from '../upstash/types';
+import { dispatchWebhookAlert } from '../notifications/dispatch';
+import type { AlertConfig, SendAlertOptions } from '../notifications/types';
export interface NextWebhookHandlerOptions = Record, TResponse = unknown> {
platform: WebhookPlatform;
@@ -9,6 +11,8 @@ export interface NextWebhookHandlerOptions;
onError?: (error: Error) => void;
handler: (payload: TPayload, metadata: TMetadata) => Promise | TResponse;
}
@@ -20,13 +24,32 @@ export function createWebhookHandler) => Promise | unknown,
toleranceInSeconds: options.toleranceInSeconds ?? 300,
});
+
+ if (response.ok) {
+ let eventId: string | undefined;
+ try {
+ const body = await response.clone().json() as Record;
+ eventId = typeof body.eventId === 'string' ? body.eventId : undefined;
+ } catch {
+ eventId = undefined;
+ }
+
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId,
+ alert: options.alert,
+ });
+ }
+
+ return response;
}
const result = await WebhookVerificationService.verifyWithPlatformConfig(
@@ -41,6 +64,13 @@ export function createWebhookHandler(
@@ -390,6 +392,8 @@ export class WebhookVerificationService {
platform: WebhookPlatform;
secret: string;
queue: QueueOption;
+ alerts?: AlertConfig;
+ alert?: Omit;
handler: (payload: unknown, metadata: Record) => Promise | unknown;
toleranceInSeconds?: number;
},
@@ -397,13 +401,32 @@ export class WebhookVerificationService {
const { resolveQueueConfig, handleQueuedRequest } = await import('./upstash/queue');
const queueConfig = resolveQueueConfig(options.queue);
- return handleQueuedRequest(request, {
+ const response = await handleQueuedRequest(request, {
platform: options.platform,
secret: options.secret,
queueConfig,
handler: options.handler,
toleranceInSeconds: options.toleranceInSeconds ?? 300,
});
+
+ if (response.ok) {
+ let eventId: string | undefined;
+ try {
+ const body = await response.clone().json() as Record;
+ eventId = typeof body.eventId === 'string' ? body.eventId : undefined;
+ } catch {
+ eventId = undefined;
+ }
+
+ await dispatchWebhookAlert({
+ alerts: options.alerts,
+ source: options.platform,
+ eventId,
+ alert: options.alert,
+ });
+ }
+
+ return response;
}
static async verifyTokenBased(
request: Request,
@@ -429,5 +452,6 @@ export {
getPlatformsByCategory,
} from './normalization/simple';
export * from './adapters';
+export * from './alerts';
export default WebhookVerificationService;
diff --git a/src/notifications/channels/discord/build-payload.ts b/src/notifications/channels/discord/build-payload.ts
new file mode 100644
index 0000000..8406078
--- /dev/null
+++ b/src/notifications/channels/discord/build-payload.ts
@@ -0,0 +1,64 @@
+import type { AlertPayloadBuilderInput } from "../../types";
+import { compactMetadata } from "../../utils";
+import { severityColorMap } from "../../constants";
+
+export function buildDiscordPayload(input: AlertPayloadBuilderInput) {
+ const isDLQ = input.dlq;
+
+ const fallbackTitle = isDLQ ? "Dead Letter Queue β Event Failed" : "Webhook Received";
+ const title = input.title?.trim() ? input.title : fallbackTitle;
+
+ const fallbackDescription = isDLQ
+ ? "Event exhausted all retries. Manual replay required."
+ : "Event verified and queued for processing.";
+ const description = input.message?.trim() ? input.message : fallbackDescription;
+
+ const fields: Array<{ name: string; value: string; inline?: boolean }> = [];
+
+ if (input.source) {
+ fields.push({ name: "Platform", value: input.source, inline: true });
+ }
+
+ fields.push({
+ name: "Severity",
+ value: input.severity.toLowerCase(),
+ inline: true,
+ });
+
+ if (isDLQ) {
+ fields.push({ name: "Queue", value: "dlq", inline: true });
+ }
+
+ if (input.eventId) {
+ fields.push({
+ name: isDLQ ? "DLQ ID" : "Event ID",
+ value: `\`${input.eventId}\``,
+ inline: false,
+ });
+ }
+
+ const metadataString = compactMetadata(input.metadata);
+ if (metadataString) {
+ fields.push({ name: "Details", value: `\`\`\`${metadataString}\`\`\`` });
+ }
+
+ const replayLine = input.replayUrl
+ ? `\n\n[${input.replayLabel ?? "Replay Event"}](${input.replayUrl})`
+ : "";
+
+ const footerText =
+ input.branding === false ? undefined : "Alert from Tern Β· tern.hookflo.com";
+
+ return {
+ embeds: [
+ {
+ title,
+ description: `${description}${replayLine}`,
+ color: parseInt(severityColorMap[input.severity].replace("#", ""), 16),
+ fields,
+ footer: footerText ? { text: footerText } : undefined,
+ timestamp: new Date().toISOString(),
+ },
+ ],
+ };
+}
diff --git a/src/notifications/channels/discord/index.ts b/src/notifications/channels/discord/index.ts
new file mode 100644
index 0000000..540f5ba
--- /dev/null
+++ b/src/notifications/channels/discord/index.ts
@@ -0,0 +1 @@
+export { buildDiscordPayload } from './build-payload';
diff --git a/src/notifications/channels/slack/build-payload.ts b/src/notifications/channels/slack/build-payload.ts
new file mode 100644
index 0000000..84258b1
--- /dev/null
+++ b/src/notifications/channels/slack/build-payload.ts
@@ -0,0 +1,89 @@
+import type { AlertPayloadBuilderInput } from "../../types";
+import { compactMetadata } from "../../utils";
+import { TERN_BRAND_URL } from "../../constants";
+
+export function buildSlackPayload(input: AlertPayloadBuilderInput) {
+ const isDLQ = input.dlq;
+
+ const fallbackTitle = isDLQ ? "Dead Letter Queue β Event Failed" : "Webhook Received";
+ const title = input.title?.trim() ? input.title : fallbackTitle;
+
+ const fallbackMessage = isDLQ
+ ? "Event exhausted all retries. Manual replay required."
+ : "Event verified and queued for processing.";
+ const message = input.message?.trim() ? input.message : fallbackMessage;
+
+ const fields = [
+ input.source
+ ? { type: "mrkdwn", text: `*Platform*\n${input.source}` }
+ : null,
+ {
+ type: "mrkdwn",
+ text: `*Severity*\n${input.severity.toLowerCase()}`,
+ },
+ isDLQ ? { type: "mrkdwn", text: "*Queue*\ndlq" } : null,
+ input.eventId
+ ? {
+ type: "mrkdwn",
+ text: `*${isDLQ ? "DLQ ID" : "Event ID"}*\n\`${input.eventId}\``,
+ }
+ : null,
+ ].filter(Boolean);
+
+ const blocks: Record[] = [
+ {
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: `*${title}*\n${message}`,
+ },
+ fields,
+ },
+ ];
+
+ const metadataString = compactMetadata(input.metadata);
+ if (metadataString) {
+ blocks.push({
+ type: "section",
+ text: {
+ type: "mrkdwn",
+ text: `\`\`\`${metadataString}\`\`\``,
+ },
+ });
+ }
+
+ if (input.replayUrl) {
+ blocks.push({
+ type: "actions",
+ elements: [
+ {
+ type: "button",
+ text: {
+ type: "plain_text",
+ text: input.replayLabel ?? "Replay Event",
+ emoji: false,
+ },
+ url: input.replayUrl,
+ style: "danger",
+ },
+ ],
+ });
+ }
+
+ if (input.branding !== false) {
+ blocks.push({
+ type: "context",
+ elements: [
+ {
+ type: "mrkdwn",
+ text: `Alert from <${TERN_BRAND_URL}|Tern>`,
+ },
+ ],
+ });
+ }
+
+ return {
+ text: title,
+ blocks,
+ };
+}
diff --git a/src/notifications/channels/slack/index.ts b/src/notifications/channels/slack/index.ts
new file mode 100644
index 0000000..6655ba4
--- /dev/null
+++ b/src/notifications/channels/slack/index.ts
@@ -0,0 +1 @@
+export { buildSlackPayload } from './build-payload';
diff --git a/src/notifications/constants.ts b/src/notifications/constants.ts
new file mode 100644
index 0000000..e096ae5
--- /dev/null
+++ b/src/notifications/constants.ts
@@ -0,0 +1,15 @@
+import type { AlertSeverity } from './types';
+
+export const TERN_BRAND_URL = 'https://tern.hookflo.com';
+export const DEFAULT_DLQ_TITLE = 'Dead Letter Queue β Event Failed';
+export const DEFAULT_ALERT_TITLE = 'Webhook Received';
+export const DEFAULT_REPLAY_LABEL = 'Replay DLQ Event';
+export const DEFAULT_DLQ_MESSAGE = 'Event exhausted all retries. Manual replay required.';
+export const DEFAULT_ALERT_MESSAGE = 'Event verified and queued for processing.';
+
+export const severityColorMap: Record = {
+ info: '#3B82F6',
+ warning: '#F59E0B',
+ error: '#EF4444',
+ critical: '#7C3AED',
+};
diff --git a/src/notifications/dispatch.ts b/src/notifications/dispatch.ts
new file mode 100644
index 0000000..1fb629a
--- /dev/null
+++ b/src/notifications/dispatch.ts
@@ -0,0 +1,19 @@
+import type { AlertConfig, SendAlertOptions } from './types';
+import { sendAlert } from './send-alert';
+
+export interface DispatchWebhookAlertInput {
+ alerts?: AlertConfig;
+ source?: string;
+ eventId?: string;
+ alert?: Omit;
+}
+
+export async function dispatchWebhookAlert(input: DispatchWebhookAlertInput): Promise {
+ if (!input.alerts) return;
+
+ await sendAlert(input.alerts, {
+ ...(input.alert || {}),
+ source: input.source,
+ eventId: input.eventId,
+ });
+}
diff --git a/src/notifications/index.ts b/src/notifications/index.ts
new file mode 100644
index 0000000..ae08a57
--- /dev/null
+++ b/src/notifications/index.ts
@@ -0,0 +1,12 @@
+import { normalizeAlertOptions, resolveDestinations } from './utils';
+import { buildSlackPayload } from './channels/slack';
+import { buildDiscordPayload } from './channels/discord';
+
+export * from './types';
+
+export const notificationInternals = {
+ resolveDestinations,
+ normalizeAlertOptions,
+ buildSlackPayload,
+ buildDiscordPayload,
+};
diff --git a/src/notifications/send-alert.ts b/src/notifications/send-alert.ts
new file mode 100644
index 0000000..301b091
--- /dev/null
+++ b/src/notifications/send-alert.ts
@@ -0,0 +1,87 @@
+import type {
+ AlertConfig,
+ SendAlertOptions,
+ SendAlertResult,
+ SendAlertSummary,
+} from './types';
+import { resolveDestinations, normalizeAlertOptions } from './utils';
+import { buildSlackPayload } from './channels/slack';
+import { buildDiscordPayload } from './channels/discord';
+
+async function postWebhook(
+ webhookUrl: string,
+ body: unknown,
+): Promise<{ ok: boolean; status?: number; error?: string }> {
+ try {
+ const response = await fetch(webhookUrl, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ status: response.status,
+ error: `Webhook call failed with ${response.status}`,
+ };
+ }
+
+ return { ok: true, status: response.status };
+ } catch (error) {
+ return {
+ ok: false,
+ error: (error as Error).message,
+ };
+ }
+}
+
+function buildPayload(channel: 'slack' | 'discord', options: SendAlertOptions) {
+ const normalized = normalizeAlertOptions(options);
+ return channel === 'slack' ? buildSlackPayload(normalized) : buildDiscordPayload(normalized);
+}
+
+export async function sendAlert(
+ config: AlertConfig,
+ options: SendAlertOptions,
+): Promise {
+ const destinations = resolveDestinations(config);
+
+ if (destinations.length === 0) {
+ return {
+ success: false,
+ total: 0,
+ delivered: 0,
+ results: [{
+ channel: 'slack',
+ webhookUrl: '',
+ ok: false,
+ error: 'No valid alert webhook destinations configured',
+ }],
+ };
+ }
+
+ const results = await Promise.all(destinations.map(async (destination): Promise => {
+ const payload = buildPayload(destination.channel, options);
+ const response = await postWebhook(destination.webhookUrl, payload);
+
+ return {
+ channel: destination.channel,
+ webhookUrl: destination.webhookUrl,
+ ok: response.ok,
+ status: response.status,
+ error: response.error,
+ };
+ }));
+
+ const delivered = results.filter((result) => result.ok).length;
+
+ return {
+ success: delivered === results.length,
+ total: results.length,
+ delivered,
+ results,
+ };
+}
diff --git a/src/notifications/types.ts b/src/notifications/types.ts
new file mode 100644
index 0000000..1c3dc57
--- /dev/null
+++ b/src/notifications/types.ts
@@ -0,0 +1,57 @@
+export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical';
+export type AlertChannel = 'slack' | 'discord';
+
+export interface AlertChannelConfig {
+ webhookUrl: string;
+ enabled?: boolean;
+}
+
+export interface AlertConfig {
+ slack?: AlertChannelConfig;
+ discord?: AlertChannelConfig;
+}
+
+export interface SendAlertOptions {
+ dlq?: boolean;
+ dlqId?: string;
+ eventId?: string;
+ source?: string;
+
+ // Optional overrides when needed.
+ title?: string;
+ message?: string;
+ severity?: AlertSeverity;
+ replayUrl?: string;
+ replayLabel?: string;
+ metadata?: Record;
+ branding?: boolean;
+}
+
+export interface SendAlertResult {
+ channel: AlertChannel;
+ webhookUrl: string;
+ ok: boolean;
+ status?: number;
+ error?: string;
+}
+
+export interface SendAlertSummary {
+ success: boolean;
+ total: number;
+ delivered: number;
+ results: SendAlertResult[];
+}
+
+export interface AlertDestination {
+ channel: AlertChannel;
+ webhookUrl: string;
+}
+
+export interface AlertPayloadBuilderInput extends SendAlertOptions {
+ title: string;
+ message: string;
+ severity: AlertSeverity;
+ replayLabel: string;
+ eventId?: string;
+ metadata?: Record;
+}
diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts
new file mode 100644
index 0000000..8e1c558
--- /dev/null
+++ b/src/notifications/utils.ts
@@ -0,0 +1,141 @@
+import type {
+ AlertConfig,
+ AlertDestination,
+ AlertPayloadBuilderInput,
+ SendAlertOptions,
+} from './types';
+import {
+ DEFAULT_ALERT_MESSAGE,
+ DEFAULT_ALERT_TITLE,
+ DEFAULT_DLQ_MESSAGE,
+ DEFAULT_DLQ_TITLE,
+ DEFAULT_REPLAY_LABEL,
+} from './constants';
+
+export function compactMetadata(metadata?: Record): string | undefined {
+ if (!metadata || Object.keys(metadata).length === 0) return undefined;
+
+ const entries = Object.entries(metadata).slice(0, 8);
+ return entries.map(([key, value]) => `${key}: ${String(value)}`).join('\n');
+}
+
+function asNonEmptyString(value: unknown): string | undefined {
+ return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
+}
+
+function asObject(value: unknown): Record | undefined {
+ return value && typeof value === 'object' ? value as Record : undefined;
+}
+
+function pickNonEmptyString(...values: unknown[]): string | undefined {
+ for (const value of values) {
+ const normalized = asNonEmptyString(value);
+ if (normalized) return normalized;
+ }
+
+ return undefined;
+}
+
+function resolveSourceFromObject(value: unknown): string | undefined {
+ const object = asObject(value);
+ if (!object) return undefined;
+
+ return pickNonEmptyString(
+ object.platform,
+ object.provider,
+ object.source,
+ object.service,
+ object.origin,
+ object.type,
+ );
+}
+
+function resolveEventIdFromObject(value: unknown): string | undefined {
+ const object = asObject(value);
+ if (!object) return undefined;
+
+ return pickNonEmptyString(
+ object.eventId,
+ object.event_id,
+ object.id,
+ object.request_id,
+ object.webhook_id,
+ object.messageId,
+ object.message_id,
+ );
+}
+
+function resolveSource(options: SendAlertOptions): string | undefined {
+ const metadata = options.metadata || {};
+ const metadataPayload = asObject(metadata.payload);
+ const metadataEvent = asObject(metadata.event);
+ const metadataData = asObject(metadata.data);
+ const metadataBody = asObject(metadata.body);
+
+ return pickNonEmptyString(
+ options.source,
+ metadata.source,
+ metadata.platform,
+ metadata.provider,
+ metadata.eventSource,
+ resolveSourceFromObject(metadataPayload),
+ resolveSourceFromObject(metadataEvent),
+ resolveSourceFromObject(metadataData),
+ resolveSourceFromObject(metadataBody),
+ );
+}
+
+function resolveEventId(options: SendAlertOptions): string | undefined {
+ const metadata = options.metadata || {};
+ const metadataPayload = asObject(metadata.payload);
+ const metadataEvent = asObject(metadata.event);
+ const metadataData = asObject(metadata.data);
+
+ return pickNonEmptyString(
+ options.eventId,
+ options.dlqId,
+ metadata.eventId,
+ metadata.event_id,
+ metadata.messageId,
+ metadata.message_id,
+ metadata.webhookId,
+ metadata.webhook_id,
+ metadata.id,
+ resolveEventIdFromObject(metadataPayload),
+ resolveEventIdFromObject(metadataEvent),
+ resolveEventIdFromObject(metadataData),
+ );
+}
+
+export function resolveDestinations(config: AlertConfig): AlertDestination[] {
+ const destinations: AlertDestination[] = [];
+
+ if (config.slack?.enabled !== false && config.slack?.webhookUrl) {
+ destinations.push({ channel: 'slack', webhookUrl: config.slack.webhookUrl });
+ }
+
+ if (config.discord?.enabled !== false && config.discord?.webhookUrl) {
+ destinations.push({ channel: 'discord', webhookUrl: config.discord.webhookUrl });
+ }
+
+ return destinations;
+}
+
+export function normalizeAlertOptions(options: SendAlertOptions): AlertPayloadBuilderInput {
+ const isDlq = options.dlq === true;
+ const severity = options.severity || (isDlq ? 'error' : 'info');
+ const title = options.title || (isDlq ? DEFAULT_DLQ_TITLE : DEFAULT_ALERT_TITLE);
+ const message = options.message || (isDlq ? DEFAULT_DLQ_MESSAGE : DEFAULT_ALERT_MESSAGE);
+ const replayLabel = options.replayLabel || DEFAULT_REPLAY_LABEL;
+
+ return {
+ ...options,
+ dlq: isDlq,
+ source: resolveSource(options) || 'unknown',
+ title,
+ message,
+ severity,
+ replayLabel,
+ eventId: resolveEventId(options),
+ };
+}
diff --git a/src/test.ts b/src/test.ts
index 6271e2c..6252d76 100644
--- a/src/test.ts
+++ b/src/test.ts
@@ -1,5 +1,8 @@
import { createHmac, createHash, generateKeyPairSync, sign } from 'crypto';
import { WebhookVerificationService, getPlatformsByCategory } from './index';
+import { normalizeAlertOptions } from './notifications/utils';
+import { buildSlackPayload } from './notifications/channels/slack';
+import { buildDiscordPayload } from './notifications/channels/discord';
const testSecret = 'whsec_test_secret_key_12345';
const testBody = JSON.stringify({ event: 'test', data: { id: '123' } });
@@ -842,6 +845,73 @@ async function runTests() {
console.log(' β handleWithQueue test failed:', error);
}
+ // Test 22: Custom alert title/message payload passthrough
+ console.log('\n22. Testing custom alert title/message payload...');
+ try {
+ const title = 'Alert Recieved';
+ const message = 'Alert received in handler';
+ const normalized = normalizeAlertOptions({
+ source: 'stripe',
+ eventId: 'evt_123',
+ title,
+ message,
+ });
+
+ const slackPayload = buildSlackPayload(normalized) as Record;
+ const slackBlocks = (slackPayload.blocks || []) as Array>;
+ const slackPrimarySection = (slackBlocks[0]?.text || {}) as Record;
+ const slackText = String(slackPrimarySection.text || '');
+
+ const discordPayload = buildDiscordPayload(normalized) as Record;
+ const discordEmbeds = (discordPayload.embeds || []) as Array>;
+ const discordPrimary = (discordEmbeds[0] || {}) as Record;
+
+ const pass =
+ String(slackPayload.text || '') === title
+ && slackText.includes(title)
+ && slackText.includes(message)
+ && String(discordPrimary.title || '') === title
+ && String(discordPrimary.description || '') === message;
+
+ console.log(' β
Alert custom title/message:', trackCheck('alert custom title/message', pass) ? 'PASSED' : 'FAILED');
+ } catch (error) {
+ console.log(' β Alert custom title/message test failed:', error);
+ }
+
+ // Test 23: Alert payload fallback compatibility when title/message are empty
+ console.log('\n23. Testing alert title/message fallback compatibility...');
+ try {
+ const normalized = normalizeAlertOptions({
+ source: 'stripe',
+ eventId: 'evt_456',
+ title: ' ',
+ message: '',
+ });
+
+ const slackPayload = buildSlackPayload(normalized) as Record;
+ const slackBlocks = (slackPayload.blocks || []) as Array>;
+ const slackPrimarySection = (slackBlocks[0]?.text || {}) as Record;
+ const slackText = String(slackPrimarySection.text || '');
+
+ const discordPayload = buildDiscordPayload(normalized) as Record;
+ const discordEmbeds = (discordPayload.embeds || []) as Array>;
+ const discordPrimary = (discordEmbeds[0] || {}) as Record;
+
+ const fallbackTitle = 'Webhook Received';
+ const fallbackMessage = 'Event verified and queued for processing.';
+
+ const pass =
+ String(slackPayload.text || '') === fallbackTitle
+ && slackText.includes(fallbackTitle)
+ && slackText.includes(fallbackMessage)
+ && String(discordPrimary.title || '') === fallbackTitle
+ && String(discordPrimary.description || '') === fallbackMessage;
+
+ console.log(' β
Alert title/message fallback:', trackCheck('alert title/message fallback', pass) ? 'PASSED' : 'FAILED');
+ } catch (error) {
+ console.log(' β Alert title/message fallback test failed:', error);
+ }
+
if (failedChecks.length > 0) {
throw new Error(`Test checks failed: ${failedChecks.join(', ')}`);
}
diff --git a/src/upstash/controls.ts b/src/upstash/controls.ts
index 40051be..80b7964 100644
--- a/src/upstash/controls.ts
+++ b/src/upstash/controls.ts
@@ -2,9 +2,12 @@ import {
DLQMessage,
EventFilter,
ReplayResult,
+ TernControls,
TernControlsConfig,
TernEvent,
+ ControlAlertOptions,
} from './types';
+import { sendAlert } from '../notifications/send-alert';
const QSTASH_API_BASE = 'https://qstash.upstash.io/v2';
@@ -63,7 +66,13 @@ function deriveStatus(value: string): 'delivered' | 'failed' | 'retrying' {
return 'retrying';
}
-export function createTernControls(config: TernControlsConfig) {
+function withoutUndefined>(value: T): Record {
+ return Object.fromEntries(
+ Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
+ );
+}
+
+export function createTernControls(config: TernControlsConfig): TernControls {
return {
async dlq(): Promise {
const response = await fetch(`${QSTASH_API_BASE}/dlq`, {
@@ -140,5 +149,71 @@ export function createTernControls(config: TernControlsConfig) {
return statusFiltered.slice(0, filter.limit ?? 20);
},
+
+ async alert(options: ControlAlertOptions = {}) {
+ let replayMeta: Record = {};
+ let resolvedSource = options.source;
+ let resolvedEventId = options.eventId;
+
+ if (options.dlq && (!resolvedSource || !resolvedEventId)) {
+ try {
+ const dlqMessages = await this.dlq();
+ const matchingMessage = dlqMessages.find((message) => message.dlqId === options.dlqId);
+
+ resolvedSource = resolvedSource || matchingMessage?.platform;
+ resolvedEventId = resolvedEventId || matchingMessage?.id;
+ } catch (error) {
+ replayMeta = {
+ ...replayMeta,
+ dlqLookupError: (error as Error).message,
+ };
+ }
+ }
+
+ if (options.dlq) {
+ if (!options.dlqId || options.dlqId.trim() === '') {
+ throw new Error('[tern] controls.alert() with dlq: true requires dlqId.');
+ }
+
+ try {
+ const replay = await this.replay(options.dlqId);
+ replayMeta = {
+ replayAttempted: true,
+ replaySuccess: replay.success,
+ replayedAt: replay.replayedAt,
+ replayDlqId: options.dlqId,
+ };
+ } catch (error) {
+ replayMeta = {
+ replayAttempted: true,
+ replaySuccess: false,
+ replayDlqId: options.dlqId,
+ replayError: (error as Error).message,
+ };
+ }
+ }
+
+ return sendAlert(
+ {
+ slack: config.notifications?.slackWebhookUrl
+ ? { webhookUrl: config.notifications.slackWebhookUrl }
+ : undefined,
+ discord: config.notifications?.discordWebhookUrl
+ ? { webhookUrl: config.notifications.discordWebhookUrl }
+ : undefined,
+ },
+ {
+ ...options,
+ source: resolvedSource,
+ eventId: resolvedEventId || options.dlqId,
+ metadata: withoutUndefined({
+ ...(options.metadata || {}),
+ source: resolvedSource || options.metadata?.source,
+ eventId: resolvedEventId || options.metadata?.eventId,
+ ...replayMeta,
+ }),
+ },
+ );
+ },
};
}
diff --git a/src/upstash/index.ts b/src/upstash/index.ts
index c9127be..1e8a25d 100644
--- a/src/upstash/index.ts
+++ b/src/upstash/index.ts
@@ -6,12 +6,14 @@ export {
resolveQueueConfig,
} from './queue';
export type {
+ ControlAlertOptions,
DLQMessage,
EventFilter,
QueueOption,
QueuedMessage,
ReplayResult,
ResolvedQueueConfig,
+ TernControls,
TernControlsConfig,
TernEvent,
} from './types';
diff --git a/src/upstash/queue.ts b/src/upstash/queue.ts
index a12ca4c..880bbff 100644
--- a/src/upstash/queue.ts
+++ b/src/upstash/queue.ts
@@ -256,7 +256,11 @@ export async function handleReceive(
try {
await client.publishJSON(publishPayload);
- return new Response(JSON.stringify({ queued: true }), {
+ return new Response(JSON.stringify({
+ queued: true,
+ platform,
+ eventId: verificationResult.eventId,
+ }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
diff --git a/src/upstash/types.ts b/src/upstash/types.ts
index 965eb11..f7d106d 100644
--- a/src/upstash/types.ts
+++ b/src/upstash/types.ts
@@ -1,4 +1,5 @@
import { WebhookPlatform } from '../types';
+import type { SendAlertOptions, SendAlertSummary } from '../notifications/types';
export type QueueOption =
| true
@@ -18,6 +19,10 @@ export interface ResolvedQueueConfig {
export interface TernControlsConfig {
token: string;
+ notifications?: {
+ slackWebhookUrl?: string;
+ discordWebhookUrl?: string;
+ };
}
export interface DLQMessage {
@@ -55,3 +60,20 @@ export interface QueuedMessage {
payload: unknown;
metadata: Record;
}
+
+export type ControlAlertOptions =
+ | (SendAlertOptions & {
+ dlq: true;
+ dlqId: string;
+ })
+ | (SendAlertOptions & {
+ dlq?: false;
+ dlqId?: never;
+ });
+
+export interface TernControls {
+ dlq: () => Promise;
+ replay: (dlqId: string) => Promise;
+ events: (filter?: EventFilter) => Promise;
+ alert: (options?: ControlAlertOptions) => Promise;
+}