diff --git a/README.md b/README.md index 071d451..478c418 100644 --- a/README.md +++ b/README.md @@ -31,18 +31,18 @@ Every webhook provider has a different signature format. You end up writing — ```typescript // ❌ Without Tern — different logic for every provider -const stripeSignature = req.headers['stripe-signature']; -const parts = stripeSignature.split(','); +const stripeSignature = req.headers["stripe-signature"]; +const parts = stripeSignature.split(","); // ... 30 more lines just for Stripe -const githubSignature = req.headers['x-hub-signature-256']; +const githubSignature = req.headers["x-hub-signature-256"]; // ... completely different 20 lines for GitHub ``` ```typescript // ✅ With Tern — one API for everything const result = await WebhookVerificationService.verify(request, { - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, }); ``` @@ -52,18 +52,18 @@ const result = await WebhookVerificationService.verify(request, { ### Verify a single platform ```typescript -import { WebhookVerificationService } from '@hookflo/tern'; +import { WebhookVerificationService } from "@hookflo/tern"; const result = await WebhookVerificationService.verify(request, { - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, toleranceInSeconds: 300, }); if (result.isValid) { - console.log('Verified!', result.eventId, result.payload); + console.log("Verified!", result.eventId, result.payload); } else { - console.log('Failed:', result.error, result.errorCode); + console.log("Failed:", result.error, result.errorCode); } ``` @@ -84,17 +84,19 @@ console.log(`Verified ${result.platform} webhook`); Use Tern without framework adapters in any runtime that supports the Web `Request` API. ```typescript -import { WebhookVerificationService } from '@hookflo/tern'; +import { WebhookVerificationService } from "@hookflo/tern"; const verified = await WebhookVerificationService.verifyWithPlatformConfig( request, - 'workos', + "workos", process.env.WORKOS_WEBHOOK_SECRET!, 300, ); if (!verified.isValid) { - return new Response(JSON.stringify({ error: verified.error }), { status: 400 }); + return new Response(JSON.stringify({ error: verified.error }), { + status: 400, + }); } // verified.payload + verified.metadata available here @@ -105,16 +107,16 @@ if (!verified.isValid) { ### Express.js ```typescript -import express from 'express'; -import { createWebhookMiddleware } from '@hookflo/tern/express'; +import express from "express"; +import { createWebhookMiddleware } from "@hookflo/tern/express"; const app = express(); app.post( - '/webhooks/stripe', - express.raw({ type: '*/*' }), + "/webhooks/stripe", + express.raw({ type: "*/*" }), createWebhookMiddleware({ - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, }), (req, res) => { @@ -127,23 +129,26 @@ app.post( ### Next.js App Router ```typescript -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +import { createWebhookHandler } from "@hookflo/tern/nextjs"; export const POST = createWebhookHandler({ - platform: 'github', + platform: "github", secret: process.env.GITHUB_WEBHOOK_SECRET!, - handler: async (payload, metadata) => ({ received: true, delivery: metadata.delivery }), + handler: async (payload, metadata) => ({ + received: true, + delivery: metadata.delivery, + }), }); ``` ### Cloudflare Workers ```typescript -import { createWebhookHandler } from '@hookflo/tern/cloudflare'; +import { createWebhookHandler } from "@hookflo/tern/cloudflare"; export const onRequestPost = createWebhookHandler({ - platform: 'stripe', - secretEnv: 'STRIPE_WEBHOOK_SECRET', + platform: "stripe", + secretEnv: "STRIPE_WEBHOOK_SECRET", handler: async (payload) => ({ received: true, payload }), }); ``` @@ -151,20 +156,24 @@ export const onRequestPost = createWebhookHandler({ ### Hono (Edge Runtimes) ```typescript -import { Hono } from 'hono'; -import { createWebhookHandler } from '@hookflo/tern/hono'; +import { Hono } from "hono"; +import { createWebhookHandler } from "@hookflo/tern/hono"; const app = new Hono(); -app.post('/webhooks/stripe', createWebhookHandler({ - platform: 'stripe', - secret: process.env.STRIPE_WEBHOOK_SECRET!, - handler: async (payload, metadata, c) => c.json({ - received: true, - eventId: metadata.id, - payload, +app.post( + "/webhooks/stripe", + createWebhookHandler({ + platform: "stripe", + secret: process.env.STRIPE_WEBHOOK_SECRET!, + handler: async (payload, metadata, c) => + c.json({ + received: true, + eventId: metadata.id, + payload, + }), }), -})); +); ``` > All built-in platforms work across Express, Next.js, Cloudflare, and Hono adapters. You only change `platform` and `secret` per route. @@ -173,30 +182,30 @@ app.post('/webhooks/stripe', createWebhookHandler({ > ⚠️ Normalization is no longer supported in Tern and has been removed from the public verification APIs. - -| Platform | Algorithm | Status | -|---|---|---| -| **Stripe** | HMAC-SHA256 | ✅ Tested | -| **GitHub** | HMAC-SHA256 | ✅ Tested | -| **Clerk** | HMAC-SHA256 (base64) | ✅ Tested | -| **Shopify** | HMAC-SHA256 (base64) | ✅ Tested | -| **Dodo Payments** | HMAC-SHA256 | ✅ Tested | -| **Paddle** | HMAC-SHA256 | ✅ Tested | -| **Lemon Squeezy** | HMAC-SHA256 | ✅ Tested | -| **Polar** | HMAC-SHA256 | ✅ Tested | -| **WorkOS** | HMAC-SHA256 | ✅ Tested | -| **ReplicateAI** | HMAC-SHA256 | ✅ Tested | -| **GitLab** | Token-based | ✅ Tested | -| **fal.ai** | ED25519 | ✅ Tested | -| **Sentry** | HMAC-SHA256 | ✅ Tested | -| **Grafana** | HMAC-SHA256 | ✅ Tested | -| **Doppler** | HMAC-SHA256 | ✅ Tested | -| **Sanity** | HMAC-SHA256 | ✅ Tested | -| **Svix** | HMAC-SHA256 | ✅ Tested | -| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested | -| **Linear** | HMAC-SHA256 | ✅ Tested | -| **Razorpay** | HMAC-SHA256 | 🔄 Pending | -| **Vercel** | HMAC-SHA256 | 🔄 Pending | +| Platform | Algorithm | Status | +| ------------------------------------------ | -------------------- | ---------- | +| **Stripe** | HMAC-SHA256 | ✅ Tested | +| **GitHub** | HMAC-SHA256 | ✅ Tested | +| **Clerk** | HMAC-SHA256 (base64) | ✅ Tested | +| **Shopify** | HMAC-SHA256 (base64) | ✅ Tested | +| **Dodo Payments** | HMAC-SHA256 | ✅ Tested | +| **Paddle** | HMAC-SHA256 | ✅ Tested | +| **Lemon Squeezy** | HMAC-SHA256 | ✅ Tested | +| **Polar** | HMAC-SHA256 | ✅ Tested | +| **WorkOS** | HMAC-SHA256 | ✅ Tested | +| **ReplicateAI** | HMAC-SHA256 | ✅ Tested | +| **GitLab** | Token-based | ✅ Tested | +| **fal.ai** | ED25519 | ✅ Tested | +| **Sentry** | HMAC-SHA256 | ✅ Tested | +| **Grafana** | HMAC-SHA256 | ✅ Tested | +| **Doppler** | HMAC-SHA256 | ✅ Tested | +| **Sanity** | HMAC-SHA256 | ✅ Tested | +| **Svix** | HMAC-SHA256 | ✅ Tested | +| **Standard Webhooks** (`standardwebhooks`) | HMAC-SHA256 | ✅ Tested | +| **Linear** | HMAC-SHA256 | ✅ Tested | +| **Webflow** | 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). @@ -211,12 +220,15 @@ app.post('/webhooks/stripe', createWebhookHandler({ 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'; +import { createWebhookHandler } from "@hookflo/tern/nextjs"; export const POST = createWebhookHandler({ - platform: 'falai', - secret: '', // fal.ai resolves the public key automatically - handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }), + platform: "falai", + secret: "", // fal.ai resolves the public key automatically + handler: async (payload, metadata) => ({ + received: true, + requestId: metadata.requestId, + }), }); ``` @@ -240,10 +252,10 @@ Tern supports both immediate and queue-based webhook processing. Queue mode is * ### Non-queue mode (default) ```typescript -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +import { createWebhookHandler } from "@hookflo/tern/nextjs"; export const POST = createWebhookHandler({ - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, handler: async (payload) => { return { ok: true }; @@ -254,10 +266,10 @@ export const POST = createWebhookHandler({ ### Queue mode (opt-in) ```typescript -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +import { createWebhookHandler } from "@hookflo/tern/nextjs"; export const POST = createWebhookHandler({ - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, queue: true, handler: async (payload, metadata) => { @@ -287,10 +299,10 @@ queue: { ### Simple alerting ```typescript -import { createWebhookHandler } from '@hookflo/tern/nextjs'; +import { createWebhookHandler } from "@hookflo/tern/nextjs"; export const POST = createWebhookHandler({ - platform: 'stripe', + platform: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET!, alerts: { slack: { webhookUrl: process.env.SLACK_WEBHOOK_URL! }, @@ -303,7 +315,7 @@ export const POST = createWebhookHandler({ ### DLQ-aware alerting and replay ```typescript -import { createTernControls } from '@hookflo/tern/upstash'; +import { createTernControls } from "@hookflo/tern/upstash"; const controls = createTernControls({ token: process.env.QSTASH_TOKEN!, @@ -318,8 +330,8 @@ if (dlqMessages.length > 0) { await controls.alert({ dlq: true, dlqId: dlqMessages[0].dlqId, - severity: 'warning', - message: 'Replay attempted for failed event', + severity: "warning", + message: "Replay attempted for failed event", }); } ``` @@ -330,38 +342,38 @@ Not built-in? Configure any webhook provider without waiting for a library updat ```typescript const result = await WebhookVerificationService.verify(request, { - platform: 'acmepay', - secret: 'acme_secret', + platform: "acmepay", + secret: "acme_secret", signatureConfig: { - algorithm: 'hmac-sha256', - headerName: 'x-acme-signature', - headerFormat: 'raw', - timestampHeader: 'x-acme-timestamp', - timestampFormat: 'unix', - payloadFormat: 'timestamped', + algorithm: "hmac-sha256", + headerName: "x-acme-signature", + headerFormat: "raw", + timestampHeader: "x-acme-timestamp", + timestampFormat: "unix", + payloadFormat: "timestamped", }, }); ``` -### Standard Webhooks config helpers (Svix-style and webhook-* headers) +### Standard Webhooks config helpers (Svix-style and webhook-\* headers) ```typescript import { createStandardWebhooksConfig, STANDARD_WEBHOOKS_BASE, -} from '@hookflo/tern'; +} from "@hookflo/tern"; const signatureConfig = createStandardWebhooksConfig({ - id: 'webhook-id', - timestamp: 'webhook-timestamp', - signature: 'webhook-signature', - idAliases: ['svix-id'], - timestampAliases: ['svix-timestamp'], - signatureAliases: ['svix-signature'], + id: "webhook-id", + timestamp: "webhook-timestamp", + signature: "webhook-signature", + idAliases: ["svix-id"], + timestampAliases: ["svix-timestamp"], + signatureAliases: ["svix-signature"], }); const result = await WebhookVerificationService.verify(request, { - platform: 'standardwebhooks', + platform: "standardwebhooks", secret: process.env.STANDARD_WEBHOOKS_SECRET!, signatureConfig: { ...STANDARD_WEBHOOKS_BASE, @@ -376,24 +388,24 @@ See the [SignatureConfig type](https://tern.hookflo.com) for all options. ### `WebhookVerificationService` -| Method | Description | -|---|---| -| `verify(request, config)` | Verify with full config object | -| `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms | -| `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify | -| `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification | -| `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` | -| `handleWithQueue(request, options)` | Core SDK helper for queue receive/process | +| Method | Description | +| ----------------------------------------------------------------- | ----------------------------------------- | +| `verify(request, config)` | Verify with full config object | +| `verifyWithPlatformConfig(request, platform, secret, tolerance?)` | Shorthand for built-in platforms | +| `verifyAny(request, secrets, tolerance?)` | Auto-detect platform and verify | +| `verifyTokenAuth(request, webhookId, webhookToken)` | Token-based verification | +| `verifyTokenBased(request, webhookId, webhookToken)` | Alias for `verifyTokenAuth` | +| `handleWithQueue(request, options)` | Core SDK helper for queue receive/process | ### `@hookflo/tern/upstash` -| Export | Description | -|---|---| -| `createTernControls(config)` | Read DLQ/events, replay, and send alerts | -| `handleQueuedRequest(request, options)` | Route request between receive/process modes | -| `handleReceive(request, platform, secret, queueConfig, tolerance)` | Verify webhook and enqueue to QStash | -| `handleProcess(request, handler, queueConfig)` | Verify QStash signature and process payload | -| `resolveQueueConfig(queue)` | Resolve `queue: true` from env or explicit object | +| 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` @@ -415,7 +427,6 @@ interface WebhookVerificationResult { ## Troubleshooting - **`Module not found: Can't resolve "@hookflo/tern/nextjs"`** ```bash diff --git a/USAGE.md b/USAGE.md index fc22c2c..7c73de8 100644 --- a/USAGE.md +++ b/USAGE.md @@ -43,6 +43,7 @@ if (result.isValid) { - **Auth0**: `x-auth0-signature` header - **WorkOS**: `workos-signature` header - **WooCommerce**: `x-wc-webhook-signature` header +- **Webflow**: `x-webflow-signature` + `x-webflow-timestamp` headers (signed `timestamp:body`; API/OAuth-created webhooks only) - **ReplicateAI**: `webhook-signature` header - **fal.ai**: `x-fal-webhook-signature` header (ED25519) diff --git a/package-lock.json b/package-lock.json index c5d2b91..4a364b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hookflo/tern", - "version": "4.4.3", + "version": "4.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@hookflo/tern", - "version": "4.4.3", + "version": "4.5.0", "license": "MIT", "dependencies": { "@upstash/qstash": "^2.9.0" diff --git a/package.json b/package.json index c771bb0..ff69fa8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hookflo/tern", - "version": "4.4.3", + "version": "4.5.0", "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/index.ts b/src/index.ts index 6df2cf5..29de762 100644 --- a/src/index.ts +++ b/src/index.ts @@ -308,6 +308,7 @@ export class WebhookVerificationService { if (headers.has('x-doppler-signature')) return 'doppler'; if (headers.has('sanity-webhook-signature')) return 'sanity'; if (headers.has('x-shopify-hmac-sha256')) return 'shopify'; + if (headers.has('x-webflow-signature')) return 'webflow'; if (headers.has('x-vercel-signature')) return 'vercel'; return 'unknown'; diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index 4ea639e..fb144f7 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -375,6 +375,22 @@ export const platformAlgorithmConfigs: Record< description: "Linear webhooks use HMAC-SHA256 on the raw body with a 60s timestamp replay window", }, + webflow: { + platform: "webflow", + signatureConfig: { + algorithm: "hmac-sha256", + headerName: "x-webflow-signature", + headerFormat: "raw", + timestampHeader: "x-webflow-timestamp", + timestampFormat: "unix", + payloadFormat: "custom", + customConfig: { + payloadFormat: "{timestamp}:{body}", + }, + }, + description: + "Webflow webhooks use HMAC-SHA256 (hex) over {timestamp}:{body} with a millisecond x-webflow-timestamp. Only API/OAuth-created webhooks are signed.", + }, svix: { platform: "svix", signatureConfig: { diff --git a/src/test.ts b/src/test.ts index 337bf5b..a9c2ba1 100644 --- a/src/test.ts +++ b/src/test.ts @@ -71,6 +71,12 @@ function createShopifySignature(body: string, secret: string): string { return hmac.digest('base64'); } +function createWebflowSignature(body: string, secret: string, timestampMs: number): string { + const hmac = createHmac('sha256', secret); + hmac.update(`${timestampMs}:${body}`); + return hmac.digest('hex'); +} + function createWooCommerceSignature(body: string, secret: string): string { const hmac = createHmac('sha256', secret); hmac.update(body); @@ -658,6 +664,55 @@ async function runTests() { console.log(' ❌ Shopify test failed:', error); } + // Test 17.6: Webflow + console.log('\n17.6. Testing Webflow webhook...'); + try { + const timestampMs = Date.now(); + const signature = createWebflowSignature(testBody, testSecret, timestampMs); + const headers = { + 'x-webflow-signature': signature, + 'x-webflow-timestamp': String(timestampMs), + 'content-type': 'application/json', + }; + + const result = await WebhookVerificationService.verifyWithPlatformConfig( + createMockRequest(headers), + 'webflow', + testSecret, + ); + const detected = WebhookVerificationService.detectPlatform(createMockRequest(headers)); + console.log(' ✅ Webflow:', trackCheck('Webflow webhook', result.isValid, result.error) ? 'PASSED' : 'FAILED'); + console.log(' ✅ Webflow auto-detect:', trackCheck('Webflow auto-detect', detected === 'webflow') ? 'PASSED' : 'FAILED'); + + const tampered = await WebhookVerificationService.verifyWithPlatformConfig( + createMockRequest(headers, testBody + ' '), + 'webflow', + testSecret, + ); + console.log(' ✅ Webflow tampered body rejected:', trackCheck('Webflow tampered body', !tampered.isValid && tampered.errorCode === 'INVALID_SIGNATURE') ? 'PASSED' : 'FAILED'); + + const staleMs = Date.now() - 10 * 60 * 1000; + const stale = await WebhookVerificationService.verifyWithPlatformConfig( + createMockRequest({ + ...headers, + 'x-webflow-signature': createWebflowSignature(testBody, testSecret, staleMs), + 'x-webflow-timestamp': String(staleMs), + }), + 'webflow', + testSecret, + ); + console.log(' ✅ Webflow stale timestamp rejected:', trackCheck('Webflow stale timestamp', !stale.isValid && stale.errorCode === 'TIMESTAMP_EXPIRED') ? 'PASSED' : 'FAILED'); + + const unsigned = await WebhookVerificationService.verifyWithPlatformConfig( + createMockRequest({ 'content-type': 'application/json' }), + 'webflow', + testSecret, + ); + console.log(' ✅ Webflow missing signature rejected:', trackCheck('Webflow missing signature', !unsigned.isValid && unsigned.errorCode === 'MISSING_SIGNATURE') ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Webflow test failed:', error); + } + // Test 18: WooCommerce console.log('\n18. Testing WooCommerce webhook...'); try { diff --git a/src/types.ts b/src/types.ts index 7f9aada..f2c637f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,7 @@ export type WebhookPlatform = | "doppler" | "sanity" | "linear" + | "webflow" | "standardwebhooks" | "unknown"; @@ -46,6 +47,7 @@ export enum WebhookPlatformKeys { Doppler = "doppler", Sanity = "sanity", Linear = "linear", + Webflow = "webflow", StandardWebhooks = "standardwebhooks", Custom = "custom", Unknown = "unknown", diff --git a/src/utils.ts b/src/utils.ts index 4d731d5..d25be89 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -146,6 +146,11 @@ export function detectPlatformFromHeaders(headers: Headers): WebhookPlatform | n return 'shopify'; } + // Webflow + if (headerMap.has('x-webflow-signature')) { + return 'webflow'; + } + // Vercel if (headerMap.has('x-vercel-signature')) { return 'vercel'; diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 99e31fa..d55cceb 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -64,6 +64,8 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { case 'replicateai': case 'polar': return `${genericHint} Standard Webhooks payload must be signed as id.timestamp.body and secrets may need whsec_ base64 decoding.`; + case 'webflow': + return `${genericHint} Webflow signs {x-webflow-timestamp}:{raw body} (hex HMAC-SHA256) using your OAuth app client secret or the webhook's own secret. Webhooks created in the Webflow dashboard are not signed - create them via the API or an OAuth app.`; default: return genericHint; }