From 5a10cc1071175e63ca6c3614bd367359467b1fc6 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:12:07 +0530 Subject: [PATCH 01/12] refactor: make controls.alert primary API for dlq and non-dlq alerts --- README.md | 33 +++++++ src/alerts.ts | 6 ++ src/index.ts | 1 + .../channels/discord/build-payload.ts | 46 ++++++++++ src/notifications/channels/discord/index.ts | 1 + .../channels/slack/build-payload.ts | 80 +++++++++++++++++ src/notifications/channels/slack/index.ts | 1 + src/notifications/constants.ts | 15 ++++ src/notifications/index.ts | 12 +++ src/notifications/send-alert.ts | 87 +++++++++++++++++++ src/notifications/types.ts | 57 ++++++++++++ src/notifications/utils.ts | 52 +++++++++++ src/upstash/controls.ts | 51 ++++++++++- src/upstash/index.ts | 2 + src/upstash/types.ts | 22 +++++ 15 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 src/alerts.ts create mode 100644 src/notifications/channels/discord/build-payload.ts create mode 100644 src/notifications/channels/discord/index.ts create mode 100644 src/notifications/channels/slack/build-payload.ts create mode 100644 src/notifications/channels/slack/index.ts create mode 100644 src/notifications/constants.ts create mode 100644 src/notifications/index.ts create mode 100644 src/notifications/send-alert.ts create mode 100644 src/notifications/types.ts create mode 100644 src/notifications/utils.ts diff --git a/README.md b/README.md index 368e1bb..0896a7f 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,39 @@ interface WebhookConfig { } ``` + +## Alerting (Slack + Discord) + +For the simplest DX, configure webhooks once in `createTernControls` and call `controls.alert(...)`. + +```ts +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, + }, +}); + +// Non-DLQ event alert with defaults +await controls.alert(); + +// DLQ alert + replay flow +await controls.alert({ + dlq: true, + dlqId: 'dlq_xxx', +}); +``` + +### Behavior + +- `controls.alert()` with no params sends a normal (non-DLQ) alert with internal defaults. +- `controls.alert({ dlq: true, dlqId })` sends a DLQ alert and attempts replay internally via `controls.replay(dlqId)`. +- `eventId` is auto-filled from `dlqId` for DLQ alerts. +- Optional overrides like `message`, `metadata`, `source`, or `branding` can still be passed. + ## Testing ### Run All Tests diff --git a/src/alerts.ts b/src/alerts.ts new file mode 100644 index 0000000..27ea2ff --- /dev/null +++ b/src/alerts.ts @@ -0,0 +1,6 @@ +export * from './notifications'; + +import { __notificationInternals } from './notifications'; + +// Backward-compatible alias used by previous version internals. +export const __alertInternals = __notificationInternals; diff --git a/src/index.ts b/src/index.ts index 1fee4b4..278f896 100644 --- a/src/index.ts +++ b/src/index.ts @@ -429,5 +429,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..e220e5d --- /dev/null +++ b/src/notifications/channels/discord/build-payload.ts @@ -0,0 +1,46 @@ +import type { AlertPayloadBuilderInput } from '../../types'; +import { compactMetadata } from '../../utils'; +import { severityColorMap } from '../../constants'; + +export function buildDiscordPayload(input: AlertPayloadBuilderInput) { + const fields: Array<{ name: string; value: string; inline?: boolean }> = [ + { + name: 'Severity', + value: input.severity.toUpperCase(), + inline: true, + }, + ]; + + if (input.eventId) { + fields.push({ name: 'Event ID', value: `\`${input.eventId}\``, inline: true }); + } + + if (input.source) { + fields.push({ name: 'Source', value: input.source, inline: true }); + } + + if (input.dlq) { + fields.push({ name: 'Queue', value: 'DLQ', inline: true }); + } + + const metadataString = compactMetadata(input.metadata); + if (metadataString) { + fields.push({ name: 'Details', value: `\`\`\`${metadataString}\`\`\`` }); + } + + const replayLine = input.replayUrl ? `\n\n[${input.replayLabel}](${input.replayUrl})` : ''; + const footerText = input.branding === false ? undefined : 'Alert from Tern • tern.hookflo.com'; + + return { + embeds: [ + { + title: input.title, + description: `${input.message}${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..1d06219 --- /dev/null +++ b/src/notifications/channels/slack/build-payload.ts @@ -0,0 +1,80 @@ +import type { AlertPayloadBuilderInput } from '../../types'; +import { compactMetadata } from '../../utils'; +import { TERN_BRAND_URL } from '../../constants'; + +export function buildSlackPayload(input: AlertPayloadBuilderInput) { + const fields = [ + input.eventId ? { type: 'mrkdwn', text: `*Event ID*\n\`${input.eventId}\`` } : null, + input.source ? { type: 'mrkdwn', text: `*Source*\n${input.source}` } : null, + { + type: 'mrkdwn', + text: `*Severity*\n${input.severity.toUpperCase()}`, + }, + input.dlq ? { type: 'mrkdwn', text: '*Queue*\nDLQ' } : null, + ].filter(Boolean); + + const blocks: Record[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: input.title, + emoji: true, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: input.message, + }, + fields, + }, + ]; + + const metadataString = compactMetadata(input.metadata); + if (metadataString) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: `*Details*\n\`\`\`${metadataString}\`\`\``, + }, + }); + } + + if (input.replayUrl) { + blocks.push({ + type: 'actions', + elements: [ + { + type: 'button', + text: { + type: 'plain_text', + text: input.replayLabel, + emoji: true, + }, + url: input.replayUrl, + style: 'primary', + }, + ], + }); + } + + if (input.branding !== false) { + blocks.push({ + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Alert from <${TERN_BRAND_URL}|Tern>`, + }, + ], + }); + } + + return { + text: `${input.title} - ${input.message}`, + 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..5d3f0b7 --- /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 = 'DLQ Event Alert'; +export const DEFAULT_ALERT_TITLE = 'Webhook Event Alert'; +export const DEFAULT_REPLAY_LABEL = 'Replay DLQ Event'; +export const DEFAULT_DLQ_MESSAGE = 'Webhook event moved to DLQ after retry attempts.'; +export const DEFAULT_ALERT_MESSAGE = 'Webhook event received.'; + +export const severityColorMap: Record = { + info: '#3B82F6', + warning: '#F59E0B', + error: '#EF4444', + critical: '#7C3AED', +}; diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 0000000..795e252 --- /dev/null +++ b/src/notifications/index.ts @@ -0,0 +1,12 @@ +export * from './types'; + +import { normalizeAlertOptions, resolveDestinations } from './utils'; +import { buildSlackPayload } from './channels/slack'; +import { buildDiscordPayload } from './channels/discord'; + +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..dd34e14 --- /dev/null +++ b/src/notifications/utils.ts @@ -0,0 +1,52 @@ +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'); +} + +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, + title, + message, + severity, + replayLabel, + eventId: options.eventId || options.dlqId, + }; +} diff --git a/src/upstash/controls.ts b/src/upstash/controls.ts index 40051be..f1a8617 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,7 @@ function deriveStatus(value: string): 'delivered' | 'failed' | 'retrying' { return 'retrying'; } -export function createTernControls(config: TernControlsConfig) { +export function createTernControls(config: TernControlsConfig): TernControls { return { async dlq(): Promise { const response = await fetch(`${QSTASH_API_BASE}/dlq`, { @@ -140,5 +143,51 @@ export function createTernControls(config: TernControlsConfig) { return statusFiltered.slice(0, filter.limit ?? 20); }, + + async alert(options: ControlAlertOptions = {}) { + let replayMeta: Record = {}; + + 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, + eventId: options.eventId || options.dlqId, + metadata: { + ...(options.metadata || {}), + ...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/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; +} From 07bfe3b41b5fc3d18ba193ca49e2a837b0eaa207 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 13:40:18 +0530 Subject: [PATCH 02/12] payload updated --- package-lock.json | 4 +- package.json | 2 +- .../channels/discord/build-payload.ts | 60 ++++++++++------ .../channels/slack/build-payload.ts | 69 ++++++++++--------- 4 files changed, 79 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6226de3..ec6ca90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.3-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.3-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 1841264..59f6535 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.1.1", + "version": "4.2.3-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/notifications/channels/discord/build-payload.ts b/src/notifications/channels/discord/build-payload.ts index e220e5d..4c772d8 100644 --- a/src/notifications/channels/discord/build-payload.ts +++ b/src/notifications/channels/discord/build-payload.ts @@ -1,42 +1,58 @@ -import type { AlertPayloadBuilderInput } from '../../types'; -import { compactMetadata } from '../../utils'; -import { severityColorMap } from '../../constants'; +import type { AlertPayloadBuilderInput } from "../../types"; +import { compactMetadata } from "../../utils"; +import { severityColorMap } from "../../constants"; export function buildDiscordPayload(input: AlertPayloadBuilderInput) { - const fields: Array<{ name: string; value: string; inline?: boolean }> = [ - { - name: 'Severity', - value: input.severity.toUpperCase(), - inline: true, - }, - ]; + const isDLQ = input.dlq; - if (input.eventId) { - fields.push({ name: 'Event ID', value: `\`${input.eventId}\``, inline: true }); - } + const title = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + + const description = isDLQ + ? "Event exhausted all retries. Manual replay required." + : "Event verified and queued for processing."; + + const fields: Array<{ name: string; value: string; inline?: boolean }> = []; if (input.source) { - fields.push({ name: 'Source', value: input.source, inline: true }); + fields.push({ name: "Platform", value: input.source, inline: true }); } - if (input.dlq) { - fields.push({ name: 'Queue', value: 'DLQ', 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}\`\`\`` }); + fields.push({ name: "Details", value: `\`\`\`${metadataString}\`\`\`` }); } - const replayLine = input.replayUrl ? `\n\n[${input.replayLabel}](${input.replayUrl})` : ''; - const footerText = input.branding === false ? undefined : 'Alert from Tern • tern.hookflo.com'; + 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: input.title, - description: `${input.message}${replayLine}`, - color: parseInt(severityColorMap[input.severity].replace('#', ''), 16), + 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/slack/build-payload.ts b/src/notifications/channels/slack/build-payload.ts index 1d06219..0ea603d 100644 --- a/src/notifications/channels/slack/build-payload.ts +++ b/src/notifications/channels/slack/build-payload.ts @@ -1,32 +1,39 @@ -import type { AlertPayloadBuilderInput } from '../../types'; -import { compactMetadata } from '../../utils'; -import { TERN_BRAND_URL } from '../../constants'; +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 title = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + + const message = isDLQ + ? "Event exhausted all retries. Manual replay required." + : "Event verified and queued for processing."; + const fields = [ - input.eventId ? { type: 'mrkdwn', text: `*Event ID*\n\`${input.eventId}\`` } : null, - input.source ? { type: 'mrkdwn', text: `*Source*\n${input.source}` } : null, + input.source + ? { type: "mrkdwn", text: `*Platform*\n${input.source}` } + : null, { - type: 'mrkdwn', - text: `*Severity*\n${input.severity.toUpperCase()}`, + type: "mrkdwn", + text: `*Severity*\n${input.severity.toLowerCase()}`, }, - input.dlq ? { type: 'mrkdwn', text: '*Queue*\nDLQ' } : null, + 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: 'header', - text: { - type: 'plain_text', - text: input.title, - emoji: true, - }, - }, - { - type: 'section', + type: "section", text: { - type: 'mrkdwn', - text: input.message, + type: "mrkdwn", + text: `*${title}*\n${message}`, }, fields, }, @@ -35,27 +42,27 @@ export function buildSlackPayload(input: AlertPayloadBuilderInput) { const metadataString = compactMetadata(input.metadata); if (metadataString) { blocks.push({ - type: 'section', + type: "section", text: { - type: 'mrkdwn', - text: `*Details*\n\`\`\`${metadataString}\`\`\``, + type: "mrkdwn", + text: `\`\`\`${metadataString}\`\`\``, }, }); } if (input.replayUrl) { blocks.push({ - type: 'actions', + type: "actions", elements: [ { - type: 'button', + type: "button", text: { - type: 'plain_text', - text: input.replayLabel, - emoji: true, + type: "plain_text", + text: input.replayLabel ?? "Replay Event", + emoji: false, }, url: input.replayUrl, - style: 'primary', + style: "danger", }, ], }); @@ -63,10 +70,10 @@ export function buildSlackPayload(input: AlertPayloadBuilderInput) { if (input.branding !== false) { blocks.push({ - type: 'context', + type: "context", elements: [ { - type: 'mrkdwn', + type: "mrkdwn", text: `Alert from <${TERN_BRAND_URL}|Tern>`, }, ], @@ -74,7 +81,7 @@ export function buildSlackPayload(input: AlertPayloadBuilderInput) { } return { - text: `${input.title} - ${input.message}`, + text: title, blocks, }; } From 20d6709f5d418d97554e62e1f0f9d4282465b097 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:04:17 +0530 Subject: [PATCH 03/12] Fix missing alert context fields in Slack/Discord notifications (#47) * Fix alert context propagation for Slack/Discord payloads * Fix lint errors in notification internals exports --- src/alerts.ts | 6 +++--- src/notifications/index.ts | 6 +++--- src/notifications/utils.ts | 39 +++++++++++++++++++++++++++++++++++++- src/upstash/controls.ts | 22 ++++++++++++++++++++- 4 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/alerts.ts b/src/alerts.ts index 27ea2ff..47f2320 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -1,6 +1,6 @@ -export * from './notifications'; +import { notificationInternals } from './notifications'; -import { __notificationInternals } from './notifications'; +export * from './notifications'; // Backward-compatible alias used by previous version internals. -export const __alertInternals = __notificationInternals; +export const alertInternals = notificationInternals; diff --git a/src/notifications/index.ts b/src/notifications/index.ts index 795e252..ae08a57 100644 --- a/src/notifications/index.ts +++ b/src/notifications/index.ts @@ -1,10 +1,10 @@ -export * from './types'; - import { normalizeAlertOptions, resolveDestinations } from './utils'; import { buildSlackPayload } from './channels/slack'; import { buildDiscordPayload } from './channels/discord'; -export const __notificationInternals = { +export * from './types'; + +export const notificationInternals = { resolveDestinations, normalizeAlertOptions, buildSlackPayload, diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts index dd34e14..c248469 100644 --- a/src/notifications/utils.ts +++ b/src/notifications/utils.ts @@ -19,6 +19,42 @@ export function compactMetadata(metadata?: Record): string | un 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 resolveSource(options: SendAlertOptions): string | undefined { + const metadata = options.metadata || {}; + + return asNonEmptyString(options.source) + || asNonEmptyString(metadata.source) + || asNonEmptyString(metadata.platform) + || asNonEmptyString(metadata.provider); +} + +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 asNonEmptyString(options.eventId) + || asNonEmptyString(options.dlqId) + || asNonEmptyString(metadata.eventId) + || asNonEmptyString(metadata.messageId) + || asNonEmptyString(metadata.webhookId) + || asNonEmptyString(metadata.id) + || asNonEmptyString(metadataPayload?.id) + || asNonEmptyString(metadataPayload?.eventId) + || asNonEmptyString(metadataPayload?.request_id) + || asNonEmptyString(metadataEvent?.id) + || asNonEmptyString(metadataData?.id); +} + export function resolveDestinations(config: AlertConfig): AlertDestination[] { const destinations: AlertDestination[] = []; @@ -43,10 +79,11 @@ export function normalizeAlertOptions(options: SendAlertOptions): AlertPayloadBu return { ...options, dlq: isDlq, + source: resolveSource(options), title, message, severity, replayLabel, - eventId: options.eventId || options.dlqId, + eventId: resolveEventId(options), }; } diff --git a/src/upstash/controls.ts b/src/upstash/controls.ts index f1a8617..cbab196 100644 --- a/src/upstash/controls.ts +++ b/src/upstash/controls.ts @@ -146,6 +146,23 @@ export function createTernControls(config: TernControlsConfig): TernControls { 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() === '') { @@ -181,9 +198,12 @@ export function createTernControls(config: TernControlsConfig): TernControls { }, { ...options, - eventId: options.eventId || options.dlqId, + source: resolvedSource, + eventId: resolvedEventId || options.dlqId, metadata: { ...(options.metadata || {}), + source: resolvedSource || options.metadata?.source, + eventId: resolvedEventId || options.metadata?.eventId, ...replayMeta, }, }, From 1ce52324fadbac7496196b74b098c9d6879ed3cd Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 14:06:53 +0530 Subject: [PATCH 04/12] payload updated --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec6ca90..01774ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.2.3-beta", + "version": "4.2.5-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.2.3-beta", + "version": "4.2.5-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 59f6535..652e25e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.2.3-beta", + "version": "4.2.5-beta", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", From f42f52899f4fcf7679148b0cc5139a76ca206b82 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:11:52 +0530 Subject: [PATCH 05/12] Centralize alerts and restore backward-compatible payload defaults (#48) --- README.md | 35 +++++++- src/adapters/cloudflare.ts | 32 ++++++- src/adapters/express.ts | 23 +++++ src/adapters/nextjs.ts | 32 ++++++- src/index.ts | 25 +++++- .../channels/discord/build-payload.ts | 6 +- .../channels/slack/build-payload.ts | 6 +- src/notifications/constants.ts | 8 +- src/notifications/dispatch.ts | 19 +++++ src/notifications/utils.ts | 84 +++++++++++++++---- src/test.ts | 70 ++++++++++++++++ src/upstash/controls.ts | 10 ++- src/upstash/queue.ts | 6 +- 13 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 src/notifications/dispatch.ts diff --git a/README.md b/README.md index 0896a7f..c88f984 100644 --- a/README.md +++ b/README.md @@ -512,7 +512,40 @@ await controls.alert({ - `controls.alert()` with no params sends a normal (non-DLQ) alert with internal defaults. - `controls.alert({ dlq: true, dlqId })` sends a DLQ alert and attempts replay internally via `controls.replay(dlqId)`. - `eventId` is auto-filled from `dlqId` for DLQ alerts. -- Optional overrides like `message`, `metadata`, `source`, or `branding` can still be passed. +- Optional overrides like `title`, `message`, `metadata`, `source`, or `branding` are used directly in Slack/Discord payloads. + +### Adapter-level alerts (works with and without queue) + +If you are not using Upstash controls, you can enable alerting directly in adapters by providing `alerts`. +This works in **both queue and non-queue** modes. + +```ts +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! }, + }, + alert: { + title: 'Alert Recieved', + message: 'Alert received in handler', + }, + handler: async (payload, metadata) => { + return { ok: true }; + }, +}); +``` + +- In non-queue mode, alerts include `source` (platform) and canonical `eventId` from verification. +- In queue mode, normal alerts are sent on successful enqueue (DLQ alerting remains Upstash-controls based). +- Adapter-level alert calls do **not** auto-inject metadata; pass explicit alert fields via `alert` for predictable payloads. + +### Core SDK queue + alerts + +`WebhookVerificationService.handleWithQueue(...)` also supports alerting through the same `alerts` + `alert` options, so core SDK users get the same behavior as framework adapters. ## Testing 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, diff --git a/src/notifications/channels/discord/build-payload.ts b/src/notifications/channels/discord/build-payload.ts index 4c772d8..8406078 100644 --- a/src/notifications/channels/discord/build-payload.ts +++ b/src/notifications/channels/discord/build-payload.ts @@ -5,11 +5,13 @@ import { severityColorMap } from "../../constants"; export function buildDiscordPayload(input: AlertPayloadBuilderInput) { const isDLQ = input.dlq; - const title = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + const fallbackTitle = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + const title = input.title?.trim() ? input.title : fallbackTitle; - const description = isDLQ + 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 }> = []; diff --git a/src/notifications/channels/slack/build-payload.ts b/src/notifications/channels/slack/build-payload.ts index 0ea603d..84258b1 100644 --- a/src/notifications/channels/slack/build-payload.ts +++ b/src/notifications/channels/slack/build-payload.ts @@ -5,11 +5,13 @@ import { TERN_BRAND_URL } from "../../constants"; export function buildSlackPayload(input: AlertPayloadBuilderInput) { const isDLQ = input.dlq; - const title = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + const fallbackTitle = isDLQ ? "Dead Letter Queue — Event Failed" : "Webhook Received"; + const title = input.title?.trim() ? input.title : fallbackTitle; - const message = isDLQ + 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 diff --git a/src/notifications/constants.ts b/src/notifications/constants.ts index 5d3f0b7..e096ae5 100644 --- a/src/notifications/constants.ts +++ b/src/notifications/constants.ts @@ -1,11 +1,11 @@ import type { AlertSeverity } from './types'; export const TERN_BRAND_URL = 'https://tern.hookflo.com'; -export const DEFAULT_DLQ_TITLE = 'DLQ Event Alert'; -export const DEFAULT_ALERT_TITLE = 'Webhook Event Alert'; +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 = 'Webhook event moved to DLQ after retry attempts.'; -export const DEFAULT_ALERT_MESSAGE = 'Webhook event received.'; +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', 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/utils.ts b/src/notifications/utils.ts index c248469..8e1c558 100644 --- a/src/notifications/utils.ts +++ b/src/notifications/utils.ts @@ -27,13 +27,62 @@ 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 asNonEmptyString(options.source) - || asNonEmptyString(metadata.source) - || asNonEmptyString(metadata.platform) - || asNonEmptyString(metadata.provider); + 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 { @@ -42,17 +91,20 @@ function resolveEventId(options: SendAlertOptions): string | undefined { const metadataEvent = asObject(metadata.event); const metadataData = asObject(metadata.data); - return asNonEmptyString(options.eventId) - || asNonEmptyString(options.dlqId) - || asNonEmptyString(metadata.eventId) - || asNonEmptyString(metadata.messageId) - || asNonEmptyString(metadata.webhookId) - || asNonEmptyString(metadata.id) - || asNonEmptyString(metadataPayload?.id) - || asNonEmptyString(metadataPayload?.eventId) - || asNonEmptyString(metadataPayload?.request_id) - || asNonEmptyString(metadataEvent?.id) - || asNonEmptyString(metadataData?.id); + 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[] { @@ -79,7 +131,7 @@ export function normalizeAlertOptions(options: SendAlertOptions): AlertPayloadBu return { ...options, dlq: isDlq, - source: resolveSource(options), + source: resolveSource(options) || 'unknown', title, message, severity, 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 cbab196..80b7964 100644 --- a/src/upstash/controls.ts +++ b/src/upstash/controls.ts @@ -66,6 +66,12 @@ function deriveStatus(value: string): 'delivered' | 'failed' | 'retrying' { return 'retrying'; } +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 { @@ -200,12 +206,12 @@ export function createTernControls(config: TernControlsConfig): TernControls { ...options, source: resolvedSource, eventId: resolvedEventId || options.dlqId, - metadata: { + metadata: withoutUndefined({ ...(options.metadata || {}), source: resolvedSource || options.metadata?.source, eventId: resolvedEventId || options.metadata?.eventId, ...replayMeta, - }, + }), }, ); }, 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' }, }); From 976a733dfdac430fd822661f8d0aa222547140b4 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 15:13:50 +0530 Subject: [PATCH 06/12] payload updated --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01774ce..15c6841 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.2.5-beta", + "version": "4.2.7-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.2.5-beta", + "version": "4.2.7-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 652e25e..42eef6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.2.5-beta", + "version": "4.2.7-beta", "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms", "main": "dist/index.js", "types": "dist/index.d.ts", From 3ca7af1cb8006cdb481ebe737963925008c04d23 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 15:27:43 +0530 Subject: [PATCH 07/12] version upgraded --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15c6841..9180059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.2.7-beta", + "version": "4.2.9-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.2.7-beta", + "version": "4.2.9-beta", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index 42eef6a..efb2ef5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.2.7-beta", + "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", From 75f603ffad305c0f82c661bb406fa043ac5faaaf Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 16:09:41 +0530 Subject: [PATCH 08/12] updated readme --- README.md | 676 ++++++++++++------------------------------------------ 1 file changed, 153 insertions(+), 523 deletions(-) diff --git a/README.md b/README.md index c88f984..35a641b 100644 --- a/README.md +++ b/README.md @@ -1,241 +1,198 @@ -# 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! +[![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern) +[![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](package.json) -💬 Join the discussion & contribute in our Discord: [Hookflo Community](https://discord.com/invite/SNmCjU97nr) +Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. ```bash npm install @hookflo/tern ``` -[![npm version](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern) -[![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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) -tern bird nature +Tern – Webhook Verification Framework -## 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) · [Custom Config](#custom-platform-configuration) · [API Reference](#api-reference) · [Contributing](#contributing) -- **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.) +--- -## Why Tern? +## The Problem -Most webhook verifiers are tightly coupled to specific platforms or hardcoded logic. Tern introduces a flexible, scalable, algorithm-first approach that: +Every webhook provider has a different signature format. You end up writing — and maintaining — the same verification boilerplate over and over: -- 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 +## Framework Integrations -### Strongly-Typed Normalized Payloads +### Express.js ```typescript -import { - WebhookVerificationService, - PaymentWebhookNormalized, -} from '@hookflo/tern'; - -const result = await WebhookVerificationService.verifyWithPlatformConfig( - request, - 'stripe', - process.env.STRIPE_WEBHOOK_SECRET!, - 300, - { enabled: true, category: 'payment' }, -); +import { createWebhookMiddleware } from '@hookflo/tern/express'; -if (result.isValid && result.payload?.event === 'payment.succeeded') { - // result.payload is strongly typed - console.log(result.payload.amount, result.payload.customer_id); -} +app.post( + '/webhooks/stripe', + createWebhookMiddleware({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + }), + (req, res) => { + const event = (req as any).webhook.payload; + res.json({ received: true }); + }, +); ``` +### Next.js App Router + ```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, - }, -); +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -console.log(result.payload); -// { -// event: 'payment.succeeded', -// amount: 5000, -// currency: 'USD', -// customer_id: 'cus_123', -// transaction_id: 'pi_123', -// provider: 'stripe', -// category: 'payment', -// _raw: {...} -// } +export const POST = createWebhookHandler({ + platform: 'github', + secret: process.env.GITHUB_WEBHOOK_SECRET!, + handler: async (payload) => ({ received: true }), +}); ``` -### Platform-Specific Configurations +### Cloudflare Workers ```typescript -import { WebhookVerificationService } from '@hookflo/tern'; +import { createWebhookHandler } from '@hookflo/tern/cloudflare'; -// Stripe webhook -const stripeConfig = { +const handleStripe = createWebhookHandler({ platform: 'stripe', - secret: 'whsec_your_stripe_webhook_secret', - toleranceInSeconds: 300, -}; + secretEnv: 'STRIPE_WEBHOOK_SECRET', + handler: async (payload) => ({ received: true }), +}); +``` -// GitHub webhook -const githubConfig = { - platform: 'github', - secret: 'your_github_webhook_secret', - toleranceInSeconds: 300, -}; +> All built-in platforms work across Express, Next.js, and Cloudflare adapters. You only change `platform` and `secret` per route. -// Clerk webhook -const clerkConfig = { - platform: 'clerk', - secret: 'whsec_your_clerk_webhook_secret', - toleranceInSeconds: 300, -}; +## Supported Platforms + +| 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). + +### Note on fal.ai + +fal.ai uses **ED25519** signing. When using Tern with fal.ai, pass an **empty string** as the webhook secret — the public key is resolved automatically via JWKS from fal's infrastructure. -const result = await WebhookVerificationService.verify(request, stripeConfig); +```typescript +export const POST = createWebhookHandler({ + platform: 'falai', + secret: '', // fal.ai resolves the public key automatically + handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }), +}); ``` -## Supported Platforms +## Key Features -### 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 +- **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_TOO_OLD`, and more +- **Alerting** — built-in Slack + Discord alerts via adapter ## 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 +Not built-in? Configure any webhook provider without waiting for a library update. ```typescript -import { WebhookVerificationService } from '@hookflo/tern'; - -const acmeConfig = { +const result = await WebhookVerificationService.verify(request, { 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', + timestampHeader: 'x-acme-timestamp', // optional — only if provider sends timestamp separately timestampFormat: 'unix', - payloadFormat: 'timestamped', // signs as {timestamp}.{body} - } -}; - -const result = await WebhookVerificationService.verify(request, acmeConfig); + payloadFormat: 'timestamped', + }, +}); ``` -### Example: Svix/Standard Webhooks (Clerk, Dodo Payments, etc.) +### Svix / Standard Webhooks format (Clerk, Dodo Payments, etc.) ```typescript const svixConfig = { @@ -251,420 +208,93 @@ const svixConfig = { 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) - -```typescript -import express from 'express'; -import { createWebhookMiddleware } from '@hookflo/tern/express'; - -const app = express(); - -app.post( - '/webhooks/stripe', - 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 }); - }, -); -``` +See the [SignatureConfig type](https://tern.hookflo.com) for all options. -### Next.js App Router +## Alerting (Slack + Discord) ```typescript 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 }), -}); -``` - -### Cloudflare Workers - -```typescript -import { createWebhookHandler } from '@hookflo/tern/cloudflare'; - -const handleStripe = createWebhookHandler({ platform: 'stripe', - secretEnv: 'STRIPE_WEBHOOK_SECRET', - handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), -}); - -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 }); + secret: process.env.STRIPE_WEBHOOK_SECRET!, + alerts: { + slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! }, + discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! }, }, -}; -``` - - -### 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. - -### Platform route examples (Express / Next.js / Cloudflare) - -```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). - -```typescript -// Next.js with explicit public key PEM as secret -export const POST = createWebhookHandler({ - platform: 'falai', - secret: process.env.FAL_PUBLIC_KEY_PEM!, - handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }), + handler: async (payload) => ({ ok: true }), }); ``` ## API Reference -### WebhookVerificationService - -#### `verify(request: Request, config: WebhookConfig): Promise` - -Verifies a webhook using the provided configuration. - -#### `verifyWithPlatformConfig(request: Request, platform: WebhookPlatform, secret: string, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` +### `WebhookVerificationService` -Simplified verification using platform-specific configurations with optional payload normalization. +| 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 | -#### `verifyAny(request: Request, secrets: Record, toleranceInSeconds?: number, normalize?: boolean | NormalizeOptions): Promise` - -Auto-detects platform from headers and verifies against one or more provider secrets. - -#### `verifyTokenAuth(request: Request, webhookId: string, webhookToken: string): Promise` - -Verifies token-based webhooks. - -> `verifyTokenBased(...)` remains available as a backward-compatible alias and still works for existing integrations. - -#### `getPlatformsByCategory(category: 'payment' | 'auth' | 'ecommerce' | 'infrastructure'): WebhookPlatform[]` - -Returns built-in providers that normalize into a shared schema for the given migration category. - -### Types - -#### `WebhookVerificationResult` +### `WebhookVerificationResult` ```typescript interface WebhookVerificationResult { isValid: boolean; error?: string; - errorCode?: WebhookErrorCode; + errorCode?: 'INVALID_SIGNATURE' | 'MISSING_SIGNATURE' | 'TIMESTAMP_TOO_OLD' | string; platform: WebhookPlatform; payload?: any; - eventId?: string; // canonical ID, e.g. 'stripe:evt_123' + eventId?: string; // canonical: 'stripe:evt_123' metadata?: { timestamp?: string; - id?: string | null; // raw provider ID (legacy) + id?: string | null; [key: string]: any; }; } ``` -#### `WebhookConfig` - -```typescript -interface WebhookConfig { - platform: WebhookPlatform; - secret: string; - toleranceInSeconds?: number; - signatureConfig?: SignatureConfig; - normalize?: boolean | NormalizeOptions; -} -``` - - -## Alerting (Slack + Discord) - -For the simplest DX, configure webhooks once in `createTernControls` and call `controls.alert(...)`. - -```ts -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, - }, -}); - -// Non-DLQ event alert with defaults -await controls.alert(); - -// DLQ alert + replay flow -await controls.alert({ - dlq: true, - dlqId: 'dlq_xxx', -}); -``` - -### Behavior - -- `controls.alert()` with no params sends a normal (non-DLQ) alert with internal defaults. -- `controls.alert({ dlq: true, dlqId })` sends a DLQ alert and attempts replay internally via `controls.replay(dlqId)`. -- `eventId` is auto-filled from `dlqId` for DLQ alerts. -- Optional overrides like `title`, `message`, `metadata`, `source`, or `branding` are used directly in Slack/Discord payloads. - -### Adapter-level alerts (works with and without queue) - -If you are not using Upstash controls, you can enable alerting directly in adapters by providing `alerts`. -This works in **both queue and non-queue** modes. - -```ts -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! }, - }, - alert: { - title: 'Alert Recieved', - message: 'Alert received in handler', - }, - handler: async (payload, metadata) => { - return { ok: true }; - }, -}); -``` - -- In non-queue mode, alerts include `source` (platform) and canonical `eventId` from verification. -- In queue mode, normal alerts are sent on successful enqueue (DLQ alerting remains Upstash-controls based). -- Adapter-level alert calls do **not** auto-inject metadata; pass explicit alert fields via `alert` for predictable payloads. - -### Core SDK queue + alerts - -`WebhookVerificationService.handleWithQueue(...)` also supports alerting through the same `alerts` + `alert` options, so core SDK users get the same behavior as framework adapters. - -## Testing - -### Run All Tests - -```bash -npm test -``` - -### Platform-Specific Testing - -```bash -# Test a specific platform -npm run test:platform stripe - -# Test all platforms -npm run test:all -``` +## Troubleshooting -### Documentation and Analysis +**`Module not found: Can't resolve "@hookflo/tern/nextjs"`** ```bash -# Fetch platform documentation -npm run docs:fetch - -# Generate diffs between versions -npm run docs:diff - -# Analyze changes and generate reports -npm run docs:analyze +npm i @hookflo/tern@latest +rm -rf node_modules package-lock.json .next +npm i ``` -## Examples +**Signature verification failing?** -See the [examples.ts](./src/examples.ts) file for comprehensive usage examples. +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 -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for detailed information on how to: - -- Set up your development environment -- Add new platforms -- Write tests -- Submit pull requests -- Follow our code style guidelines - -### Quick Start for Contributors - -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 - -### Adding a New Platform - -See our [Platform Development Guide](CONTRIBUTING.md#adding-new-platforms) for step-by-step instructions on adding support for new webhook platforms. - -## Code of Conduct - -This project adheres to our [Code of Conduct](CODE_OF_CONDUCT.md). Please read it before contributing. - -## 📄 License - -MIT License - see [LICENSE](./LICENSE) for details. - -## 🔗 Links - -- [Documentation](./USAGE.md) -- [Framework Summary](./FRAMEWORK_SUMMARY.md) -- [Architecture Guide](./ARCHITECTURE.md) -- [Issues](https://github.com/Hookflo/tern/issues) - - -## 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: +Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add platforms, write tests, and submit PRs. ```bash -# in your Next.js app -npm i @hookflo/tern@latest -rm -rf node_modules package-lock.json .next -npm i +git clone https://github.com/Hookflo/tern.git +cd tern +npm install +npm test ``` -Then verify resolution: - -```bash -node -e "console.log(require.resolve('@hookflo/tern/nextjs'))" -``` +## Support -If you are testing this repo locally before publish: +Have a question, running into an issue, or want to request a platform? We're happy to help. -```bash -# inside /workspace/tern -npm run build -npm pack +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. -# inside your other project -npm i /path/to/hookflo-tern-.tgz -``` +## Links -Minimal Next.js App Router usage: +[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) -```ts -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +## License -export const POST = createWebhookHandler({ - platform: 'stripe', - secret: process.env.STRIPE_WEBHOOK_SECRET!, - handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }), -}); -``` +MIT © [Hookflo](https://hookflo.com) \ No newline at end of file From a75d2b272a4e0972915b871aa53840fd207f7d19 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 16:23:12 +0530 Subject: [PATCH 09/12] updated readme --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 35a641b..59fec53 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # 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. +**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](https://img.shields.io/npm/v/@hookflo/tern)](https://www.npmjs.com/package/@hookflo/tern) [![TypeScript](https://img.shields.io/badge/TypeScript-Strict-blue)](https://www.typescriptlang.org/) @@ -18,7 +17,7 @@ npm install @hookflo/tern ⭐ Star this repo to help others discover it · 💬 [Join our Discord](https://discord.com/invite/SNmCjU97nr) -Tern – Webhook Verification Framework +Tern – Webhook Verification Framework **Navigation** From 01a93a215a4497e847cbb9c0ae58384d37b1ae89 Mon Sep 17 00:00:00 2001 From: Prateek Jain <49508975+Prateek32177@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:52:11 +0530 Subject: [PATCH 10/12] docs: README updates for queue support, Upstash QStash, examples, and API reference (#49) * docs: restore full README sections and keep queue updates * docs: strengthen README positioning and core SDK usage --- README.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 151 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 59fec53..dcdb3b4 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ npm install @hookflo/tern Tern – Webhook Verification Framework +**Built for modern webhook pipelines:** cross-platform signature verification out of the box, optional Upstash-powered reliable event delivery, and Slack/Discord alerting to close the loop for inbound webhook operations at scale. + **Navigation** -[The Problem](#the-problem) · [Quick Start](#quick-start) · [Framework Integrations](#framework-integrations) · [Supported Platforms](#supported-platforms) · [Custom Config](#custom-platform-configuration) · [API Reference](#api-reference) · [Contributing](#contributing) +[The Problem](#the-problem) · [Quick Start](#quick-start) · [Framework Integrations](#framework-integrations) · [Supported Platforms](#supported-platforms) · [Queue vs Non-Queue Delivery](#queue-vs-non-queue-delivery) · [Upstash Queue Setup](#upstash-queue-setup) · [Key Features](#key-features) · [Custom Config](#custom-platform-configuration) · [Alerting](#alerting-simple--dlq) · [API Reference](#api-reference) · [Troubleshooting](#troubleshooting) · [Contributing](#contributing) · [Support](#support) --- @@ -79,22 +81,47 @@ const result = await WebhookVerificationService.verifyAny(request, { console.log(`Verified ${result.platform} webhook`); ``` +### Core SDK (runtime-agnostic) + +Use Tern without framework adapters in any runtime that supports the Web `Request` API. + +```typescript +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 ```typescript +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 }); + const event = (req as any).webhook?.payload; + res.json({ received: true, event }); }, ); ``` @@ -107,7 +134,7 @@ import { createWebhookHandler } from '@hookflo/tern/nextjs'; export const POST = createWebhookHandler({ platform: 'github', secret: process.env.GITHUB_WEBHOOK_SECRET!, - handler: async (payload) => ({ received: true }), + handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }), }); ``` @@ -116,10 +143,10 @@ 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 }), + handler: async (payload) => ({ received: true, payload }), }); ``` @@ -150,11 +177,19 @@ const handleStripe = createWebhookHandler({ > Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues). +### Platform signature notes (important) + +- **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: ''` if you want auto key resolution, or pass a PEM public key explicitly. + ### Note on fal.ai fal.ai uses **ED25519** signing. When using Tern with fal.ai, pass an **empty string** as the webhook secret — the public key is resolved automatically via JWKS from fal's infrastructure. ```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; + export const POST = createWebhookHandler({ platform: 'falai', secret: '', // fal.ai resolves the public key automatically @@ -162,6 +197,64 @@ export const POST = createWebhookHandler({ }); ``` +## Queue vs Non-Queue Delivery + +Tern supports both modes. Queue mode is **optional / opt-in**. + +### Non-queue mode (default) + +```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; + +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + handler: async (payload) => { + // immediate handling + return { ok: true }; + }, +}); +``` + +### Queue mode (opt-in) + +```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; + +export const POST = createWebhookHandler({ + platform: 'stripe', + secret: process.env.STRIPE_WEBHOOK_SECRET!, + queue: true, + handler: async (payload, metadata) => { + // invoked by QStash delivery + return { processed: true, eventId: metadata.id }; + }, +}); +``` + +## Upstash Queue Setup + +1. Create a QStash project in Upstash: https://console.upstash.com/qstash +2. Copy keys from your project: + - `QSTASH_TOKEN` + - `QSTASH_CURRENT_SIGNING_KEY` + - `QSTASH_NEXT_SIGNING_KEY` +3. Add those keys to your environment. +4. Enable queue with `queue: true` (or explicit queue config). + +Direct queue config option: + +```typescript +queue: { + token: process.env.QSTASH_TOKEN!, + signingKey: process.env.QSTASH_CURRENT_SIGNING_KEY!, + nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!, + retries: 5, +} +``` + +Get started + signature docs: https://upstash.com/docs/qstash/howto/signature + ## Key Features - **Algorithm Agnostic** — HMAC-SHA256, HMAC-SHA1, HMAC-SHA512, ED25519, and custom algorithms @@ -169,8 +262,11 @@ export const POST = createWebhookHandler({ - **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_TOO_OLD`, and more -- **Alerting** — built-in Slack + Discord alerts via adapter +- **Stable Error Codes** — `INVALID_SIGNATURE`, `MISSING_SIGNATURE`, `TIMESTAMP_EXPIRED`, and more +- **Auto Platform Detection** — detect and verify via `verifyAny` with diagnostics on failure +- **Queue + Retry Support** — optional Upstash QStash-based enqueue/process flow with 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 ## Custom Platform Configuration @@ -184,14 +280,14 @@ const result = await WebhookVerificationService.verify(request, { algorithm: 'hmac-sha256', headerName: 'x-acme-signature', headerFormat: 'raw', - timestampHeader: 'x-acme-timestamp', // optional — only if provider sends timestamp separately + timestampHeader: 'x-acme-timestamp', timestampFormat: 'unix', payloadFormat: 'timestamped', }, }); ``` -### Svix / Standard Webhooks format (Clerk, Dodo Payments, etc.) +### Svix / Standard Webhooks format (Clerk, Dodo Payments, ReplicateAI, etc.) ```typescript const svixConfig = { @@ -214,7 +310,9 @@ const svixConfig = { See the [SignatureConfig type](https://tern.hookflo.com) for all options. -## Alerting (Slack + Discord) +## Alerting (Simple + DLQ) + +### Adapter-level simple alerting ```typescript import { createWebhookHandler } from '@hookflo/tern/nextjs'; @@ -226,10 +324,34 @@ export const POST = createWebhookHandler({ slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! }, discord: { webhookUrl: process.env.DISCORD_WEBHOOK_URL! }, }, - handler: async (payload) => ({ ok: true }), + handler: async () => ({ ok: true }), }); ``` +### DLQ-aware alerting and replay + +```typescript +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', + }); +} +``` + ## API Reference ### `WebhookVerificationService` @@ -237,9 +359,21 @@ export const POST = createWebhookHandler({ | 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 | +| `verifyWithPlatformConfig(request, platform, secret, tolerance?, normalize?)` | Shorthand for built-in platforms | +| `verifyAny(request, secrets, tolerance?, normalize?)` | 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` @@ -247,10 +381,10 @@ export const POST = createWebhookHandler({ interface WebhookVerificationResult { isValid: boolean; error?: string; - errorCode?: 'INVALID_SIGNATURE' | 'MISSING_SIGNATURE' | 'TIMESTAMP_TOO_OLD' | string; + errorCode?: string; platform: WebhookPlatform; payload?: any; - eventId?: string; // canonical: 'stripe:evt_123' + eventId?: string; metadata?: { timestamp?: string; id?: string | null; @@ -296,4 +430,4 @@ Join the conversation on [Discord](https://discord.com/invite/SNmCjU97nr) or [op ## License -MIT © [Hookflo](https://hookflo.com) \ No newline at end of file +MIT © [Hookflo](https://hookflo.com) From d19858272f94ba3b2f1ec9bcb04d42492d4cd870 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 17:00:19 +0530 Subject: [PATCH 11/12] updated readme --- README.md | 141 +++++++++++++++++++++++++----------------------------- 1 file changed, 64 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index dcdb3b4..8dc8f27 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](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. +Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. Need more than just verification? Tern also supports **reliable inbound webhook delivery** via Upstash QStash — automatic retries, DLQ management, replay controls, and Slack/Discord alerting. Bring your own Upstash account (BYOK) and close the full loop on inbound webhook operations. ```bash npm install @hookflo/tern @@ -19,13 +19,9 @@ npm install @hookflo/tern Tern – Webhook Verification Framework -**Built for modern webhook pipelines:** cross-platform signature verification out of the box, optional Upstash-powered reliable event delivery, and Slack/Discord alerting to close the loop for inbound webhook operations at scale. - **Navigation** -[The Problem](#the-problem) · [Quick Start](#quick-start) · [Framework Integrations](#framework-integrations) · [Supported Platforms](#supported-platforms) · [Queue vs Non-Queue Delivery](#queue-vs-non-queue-delivery) · [Upstash Queue Setup](#upstash-queue-setup) · [Key Features](#key-features) · [Custom Config](#custom-platform-configuration) · [Alerting](#alerting-simple--dlq) · [API Reference](#api-reference) · [Troubleshooting](#troubleshooting) · [Contributing](#contributing) · [Support](#support) - ---- +[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) ## The Problem @@ -177,15 +173,15 @@ export const onRequestPost = createWebhookHandler({ > Don't see your platform? [Use custom config](#custom-platform-configuration) or [open an issue](https://github.com/Hookflo/tern/issues). -### Platform signature notes (important) +### 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: ''` if you want auto key resolution, or pass a PEM public key explicitly. +- **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. When using Tern with fal.ai, pass an **empty string** as the webhook secret — the public key is resolved automatically via JWKS from fal's infrastructure. +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 import { createWebhookHandler } from '@hookflo/tern/nextjs'; @@ -197,9 +193,22 @@ export const POST = createWebhookHandler({ }); ``` -## Queue vs Non-Queue Delivery +## 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, 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 + +## Reliable Delivery & Alerting -Tern supports both modes. Queue mode is **optional / opt-in**. +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) @@ -210,7 +219,6 @@ export const POST = createWebhookHandler({ platform: 'stripe', secret: process.env.STRIPE_WEBHOOK_SECRET!, handler: async (payload) => { - // immediate handling return { ok: true }; }, }); @@ -226,20 +234,16 @@ export const POST = createWebhookHandler({ secret: process.env.STRIPE_WEBHOOK_SECRET!, queue: true, handler: async (payload, metadata) => { - // invoked by QStash delivery return { processed: true, eventId: metadata.id }; }, }); ``` -## Upstash Queue Setup +### Upstash Queue Setup -1. Create a QStash project in Upstash: https://console.upstash.com/qstash -2. Copy keys from your project: - - `QSTASH_TOKEN` - - `QSTASH_CURRENT_SIGNING_KEY` - - `QSTASH_NEXT_SIGNING_KEY` -3. Add those keys to your environment. +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). Direct queue config option: @@ -253,20 +257,45 @@ queue: { } ``` -Get started + signature docs: https://upstash.com/docs/qstash/howto/signature +### Simple alerting -## Key Features +```typescript +import { createWebhookHandler } from '@hookflo/tern/nextjs'; -- **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 -- **Auto Platform Detection** — detect and verify via `verifyAny` with diagnostics on failure -- **Queue + Retry Support** — optional Upstash QStash-based enqueue/process flow with 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 +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 + +```typescript +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 @@ -310,48 +339,6 @@ const svixConfig = { See the [SignatureConfig type](https://tern.hookflo.com) for all options. -## Alerting (Simple + DLQ) - -### Adapter-level simple alerting - -```typescript -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 - -```typescript -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', - }); -} -``` - ## API Reference ### `WebhookVerificationService` @@ -359,8 +346,8 @@ if (dlqMessages.length > 0) { | Method | Description | |---|---| | `verify(request, config)` | Verify with full config object | -| `verifyWithPlatformConfig(request, platform, secret, tolerance?, normalize?)` | Shorthand for built-in platforms | -| `verifyAny(request, secrets, tolerance?, normalize?)` | Auto-detect platform and verify | +| `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 | @@ -430,4 +417,4 @@ Join the conversation on [Discord](https://discord.com/invite/SNmCjU97nr) or [op ## License -MIT © [Hookflo](https://hookflo.com) +MIT © [Hookflo](https://hookflo.com) \ No newline at end of file From e7b66f156772303b6e11a6087d39cb73a4d51e49 Mon Sep 17 00:00:00 2001 From: Prateek jain Date: Tue, 3 Mar 2026 17:03:49 +0530 Subject: [PATCH 12/12] updated readme --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8dc8f27..9874271 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](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. Need more than just verification? Tern also supports **reliable inbound webhook delivery** via Upstash QStash — automatic retries, DLQ management, replay controls, and Slack/Discord alerting. Bring your own Upstash account (BYOK) and close the full loop on inbound webhook operations. +Stop writing webhook verification from scratch. **Tern** handles signature verification for Stripe, GitHub, Clerk, Shopify, and 15+ more platforms — with one consistent API. + +> 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