diff --git a/app/frameworks/cloudflare/page.tsx b/app/frameworks/cloudflare/page.tsx new file mode 100644 index 0000000..6947744 --- /dev/null +++ b/app/frameworks/cloudflare/page.tsx @@ -0,0 +1,65 @@ +import { SiteNav } from "@/components/site-nav"; + +export default function CloudflareFrameworkPage() { + return ( +
+ +
+

Framework — Cloudflare Workers

+

Webhook verification at the edge.

+

Web Crypto API native. Zero Node.js dependencies. Runs in V8 isolates across Cloudflare's global network.

+ beta +
+ +
+

⚠️ Important difference from Next.js

queue: true does NOT work on Cloudflare Workers. Always use explicit queue config with the env parameter.

+
+ +
{`import { createWebhookHandler } from '@hookflo/tern/cloudflare'
+
+export interface Env {
+  WEBHOOK_SECRET: string
+  QSTASH_TOKEN: string
+  QSTASH_CURRENT_SIGNING_KEY: string
+  QSTASH_NEXT_SIGNING_KEY: string
+  [key: string]: unknown
+}
+
+export default {
+  async fetch(request: Request, env: Env): Promise {
+    const handler = createWebhookHandler({
+      platform: 'stripe',
+      secret: env.WEBHOOK_SECRET,
+      queue: {
+        token: env.QSTASH_TOKEN,
+        signingKey: env.QSTASH_CURRENT_SIGNING_KEY,
+        nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY,
+      },
+      handler: async (payload) => {
+        return { received: true }
+      }
+    })
+
+    return handler(request, env)
+  }
+}`}
+ +
+

Secrets setup

{`WEBHOOK_SECRET=whsec_xxx
+QSTASH_TOKEN=qstash_xxx
+
+npx wrangler secret put WEBHOOK_SECRET
+npx wrangler secret put QSTASH_TOKEN
+npx wrangler secret put QSTASH_CURRENT_SIGNING_KEY
+npx wrangler secret put QSTASH_NEXT_SIGNING_KEY`}
+

Deploy

{`npx wrangler dev
+npx wrangler deploy`}
+
+ +
+

Test locally

  • Stripe: stripe listen + stripe trigger
  • Clerk: Dashboard → Send test webhook
  • GitHub: Redeliver from recent deliveries
  • Fal AI: curl queue.fal.run with ngrok URL
  • Replicate: curl predictions API with ngrok URL
  • Razorpay: dashboard test webhook
+

Error reference

  • "Webhook secret is not configured"
  • "Missing signature header: stripe-signature"
  • "Queue temporarily unavailable"
  • "DeduplicationId cannot contain ':'"
+
+
+ ); +} diff --git a/app/frameworks/nextjs/page.tsx b/app/frameworks/nextjs/page.tsx new file mode 100644 index 0000000..140ebea --- /dev/null +++ b/app/frameworks/nextjs/page.tsx @@ -0,0 +1,72 @@ +import Link from "next/link"; +import { SiteNav } from "@/components/site-nav"; + +export default function NextJsFrameworkPage() { + return ( +
+ +
+

Framework — Next.js app router

+

The webhook handler Next.js was missing.

+

Purpose-built App Router adapter. Reads platform and secret from Vercel feature flags at runtime. Switch platforms without redeploying.

+ stable +
+ +
$ npm i @hookflo/tern
+ +
+

Modes

+
{`// app/api/webhooks/route.ts
+import { createWebhookHandler } from '@hookflo/tern/nextjs'
+
+export const POST = createWebhookHandler({
+  platform: 'stripe',
+  secret: process.env.WEBHOOK_SECRET!,
+  handler: async (payload) => {
+    return { received: true }
+  }
+})
+
+export const GET = async () => {
+  const failed = await controls.dlq()
+  return Response.json({ count: failed.length, events: failed })
+}
+
+export const PATCH = async (request: Request) => {
+  const { dlqId } = await request.json()
+  const result = await controls.replay(dlqId)
+  return Response.json(result)
+}`}
+
+ +
+

Switch platforms with a flag flip.

+
{`import { createWebhookHandler } from '@hookflo/tern/nextjs'
+import { platform } from '../flags'
+
+export const POST = createWebhookHandler({
+  platform: await platform(),
+  secret: process.env.WEBHOOK_SECRET!,
+  handler: async (payload) => {
+    return { received: true }
+  }
+})`}
+
+ +
+

Environment variables

{`WEBHOOK_SECRET=whsec_xxx
+QSTASH_TOKEN=qstash_xxx
+QSTASH_CURRENT_SIGNING_KEY=sig_xxx
+QSTASH_NEXT_SIGNING_KEY=sig_xxx`}
+

Two-call pattern

Call 1: Platform → Tern verify + enqueue + 200. Call 2: QStash → Tern verify QStash signature + run handler.

+
+ +
+

Test locally

  • Stripe: stripe listen + stripe trigger
  • Clerk: Dashboard → Webhooks → Send test webhook
  • GitHub: Recent deliveries → Redeliver
  • Fal AI: curl to queue.fal.run with ngrok URL
  • Replicate: curl to api.replicate.com with ngrok URL
  • Razorpay: Dashboard → Webhooks → Send test webhook
+

Error reference

  • "Webhook secret is not configured"
  • "Missing signature header: stripe-signature"
  • "Queue temporarily unavailable"
  • "DeduplicationId cannot contain ':'"
+
+ +
GitHub →Reliable Delivery →
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index 20f5e69..37b264a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,183 +1,86 @@ @import "tailwindcss"; @import "tw-animate-css"; -@custom-variant dark (&:is(.dark *)); - :root { - --paper: #f7f4ef; - --paper2: #f0ebe2; - --ink: #1a1714; - --ink2: #3d3830; - --ink3: #6b6358; - --ink4: #9e9488; - --border: #d8d0c4; - --border2: #c4baad; - --green: #1a6b3c; - --green-bg: #edf5f0; - --red: #c0392b; - --red-bg: #fdf1ef; - --accent: #2c5f8a; - --accent-bg: #edf2f7; - - --background: var(--paper); - --foreground: var(--ink); - --card: white; - --card-foreground: var(--ink); - --popover: white; - --popover-foreground: var(--ink); - --primary: var(--ink); - --primary-foreground: var(--paper); - --secondary: var(--paper2); - --secondary-foreground: var(--ink); - --muted: var(--paper2); - --muted-foreground: var(--ink3); - --accent: var(--accent); - --accent-foreground: white; - --destructive: var(--red); - --destructive-foreground: white; - --border: var(--border); - --input: var(--border); - --ring: var(--ink); - --radius: 0.25rem; - --sidebar: white; - --sidebar-foreground: var(--ink); - --sidebar-primary: var(--ink); - --sidebar-primary-foreground: white; - --sidebar-accent: var(--paper2); - --sidebar-accent-foreground: var(--ink); - --sidebar-border: var(--border); - --sidebar-ring: var(--ink); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.72 0.16 79); - --accent-foreground: oklch(0.145 0 0); - --accent-2: oklch(0.65 0.12 200); - --accent-2-foreground: oklch(0.98 0 0); - --accent-3: oklch(0.62 0.14 260); - --accent-3-foreground: oklch(0.98 0 0); - --accent-4: oklch(0.7 0.12 40); - --accent-4-foreground: oklch(0.98 0 0); - --accent-5: oklch(0.7 0.08 150); - --accent-5-foreground: oklch(0.16 0 0); - --destructive: oklch(0.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); -} - -* { - margin: 0; - padding: 0; + --bg: #0a0a0a; + --card: #111111; + --card-2: #141414; + --border: #1f1f1f; + --border-2: #2a2a2a; + --text: #e8e8e8; + --muted: #666; + --accent: #00dc82; + --accent-dim: rgba(0, 220, 130, 0.12); } -body { - font-family: 'Lora', serif; -} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { background: var(--bg); color: var(--text); font-family: Geist, Inter, system-ui, sans-serif; } +code, pre { font-family: "Geist Mono", "JetBrains Mono", ui-monospace, monospace; } -code, pre { - font-family: 'JetBrains Mono', monospace; -} +.tern-page { min-height: 100vh; background: var(--bg); } +.section { width: min(1100px, 92vw); margin: 0 auto; padding: 32px 0; } +.hero { padding-top: 52px; } +.label { font: 11px "Geist Mono", "JetBrains Mono", ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; color: #9a9a9a; border-top: 1px solid transparent; border-image: linear-gradient(90deg, transparent, #00dc82, transparent) 1; padding-top: 10px; margin-bottom: 12px; } +h1, h2 { font-family: "Instrument Serif", Georgia, serif; font-weight: 400; line-height: 1.1; margin-bottom: 12px; } +h1 { font-size: clamp(2rem, 5vw, 4rem); } +h2 { font-size: clamp(1.7rem, 4vw, 2.8rem); } +h3 { font-size: 1.1rem; margin-bottom: 8px; } +.maxw { max-width: 820px; } +.muted { color: var(--muted); line-height: 1.6; } +.actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; } +.btn { display: inline-flex; align-items: center; padding: 10px 14px; border: 1px solid var(--border-2); border-radius: 8px; text-decoration: none; color: var(--text); } +.btn.primary { background: var(--accent); color: #001a0f; border-color: var(--accent); font-weight: 600; } +.btn.secondary { background: var(--accent-dim); border-color: #0f4c34; } +.btn.ghost { background: var(--card); } -.font-display { - font-family: 'Instrument Serif', serif; - font-style: italic; -} +.site-nav-wrap { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid var(--border); background: rgba(10, 10, 10, .9); backdrop-filter: blur(8px); } +.site-nav { width: min(1100px, 92vw); margin: 0 auto; min-height: 60px; display: flex; justify-content: space-between; align-items: center; position: relative; } +.site-brand { color: var(--text); text-decoration: none; display: flex; align-items: center; gap: 8px; font-family: "Geist Mono", "JetBrains Mono", ui-monospace, monospace; font-size: 12px; letter-spacing: .1em; text-transform: uppercase; } +.site-brand-mark { width: 24px; height: 24px; border: 1px solid var(--border-2); display: inline-flex; align-items: center; justify-content: center; border-radius: 6px; } +.site-links { display: flex; align-items: center; gap: 14px; font-size: 13px; } +.site-links a, .site-dropdown-btn { color: #b8b8b8; text-decoration: none; background: none; border: none; cursor: pointer; font: inherit; } +.new-link { color: var(--text) !important; } +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 5px; background: var(--accent); } +.site-dropdown { position: absolute; top: 50px; right: 185px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 6px; display: grid; gap: 4px; } +.site-dropdown a { padding: 6px 8px; border-radius: 6px; } +.site-dropdown a:hover { background: var(--card-2); } -.font-serif { - font-family: 'Lora', serif; -} - -.font-mono { - font-family: 'JetBrains Mono', monospace; -} - -@theme inline { - --font-sans: 'Lora', 'Lora Fallback', serif; - --font-mono: 'JetBrains Mono', 'JetBrains Mono Fallback', monospace; - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-accent-2: var(--accent-2); - --color-accent-2-foreground: var(--accent-2-foreground); - --color-accent-3: var(--accent-3); - --color-accent-3-foreground: var(--accent-3-foreground); - --color-accent-4: var(--accent-4); - --color-accent-4-foreground: var(--accent-4-foreground); - --color-accent-5: var(--accent-5); - --color-accent-5-foreground: var(--accent-5-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } -} +.announcement { width: min(1100px, 92vw); margin: 16px auto 0; border: 1px solid #18573d; background: var(--accent-dim); border-radius: 10px; padding: 10px 14px; display: flex; justify-content: space-between; gap: 12px; } +.announcement-actions { display: flex; align-items: center; gap: 10px; } +.announcement button { background: transparent; border: 1px solid #1f5a3f; color: var(--text); border-radius: 6px; width: 24px; height: 24px; } +.grid { display: grid; gap: 12px; } +.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.card { background: linear-gradient(180deg, var(--card), var(--card-2)); border: 1px solid var(--border); border-radius: 12px; padding: 18px; } +.banner { border-color: #1f5a3f; background: linear-gradient(180deg, #0f1713, #111); } +.num { font: 11px "Geist Mono", "JetBrains Mono", ui-monospace, monospace; color: #8f8f8f; margin-bottom: 8px; } +.new { color: var(--accent); margin-left: 8px; } +.inline-link { color: var(--accent); display: inline-block; margin-top: 8px; } +.link-card { text-decoration: none; color: inherit; } +.pill { border: 1px solid var(--border-2); border-radius: 999px; padding: 6px 10px; color: #b5b5b5; text-decoration: none; font-size: 13px; } +.badge-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 18px; } +.code-block { background: #0b0b0b; border: 1px solid var(--border); border-radius: 10px; padding: 14px; overflow: auto; line-height: 1.6; color: #c8c8c8; white-space: pre; } +.code-block.small { font-size: 12px; } +.list { padding-left: 18px; display: grid; gap: 6px; } +.stats-grid { margin-top: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } +.stats-grid > div { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px; display: grid; gap: 6px; } +.stats-grid strong { font-size: 1.6rem; } +.flow { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 8px; } +.flow-step { border: 1px solid var(--border); border-radius: 9px; background: var(--card); padding: 10px; text-align: center; } +.flow-step.ok { border-color: #1f5a3f; } +.flow-step.warn { border-color: #684f1e; } +.compare p { margin-bottom: 8px; } +.table-row { display: grid; grid-template-columns: 1.2fr 1fr .6fr .6fr auto; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); align-items: center; font-size: 13px; } +.table-row button { background: var(--accent-dim); border: 1px solid #1f5a3f; color: #98f6c9; border-radius: 6px; padding: 6px 10px; } +.cost-table { width: 100%; margin: 12px 0; border-collapse: collapse; } +.cost-table td { border-bottom: 1px solid var(--border); padding: 10px; } +.warning { border-color: #6a4f1f; background: linear-gradient(180deg, #18140e, #121212); } +.footer-cta { text-align: center; padding-bottom: 64px; } -::-webkit-scrollbar { - width: 1px; +@media (max-width: 900px) { + .site-links { overflow-x: auto; max-width: 75vw; } + .grid.two, .grid.three, .stats-grid, .flow { grid-template-columns: 1fr; } + .table-row { grid-template-columns: 1fr; } } diff --git a/app/layout.tsx b/app/layout.tsx index c928a6a..5f85942 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,30 +1,8 @@ import type React from "react"; import type { Metadata } from "next"; -import { Lora, JetBrains_Mono, Instrument_Serif } from "next/font/google"; import "./globals.css"; import { Analytics } from "@vercel/analytics/next"; -const lora = Lora({ - subsets: ["latin"], - display: "swap", - variable: "--font-lora", - weight: ["400", "500", "600"], -}); - -const jetbrainsMono = JetBrains_Mono({ - subsets: ["latin"], - display: "swap", - variable: "--font-jetbrains-mono", - weight: ["400", "500", "700"], -}); - -const instrumentSerif = Instrument_Serif({ - subsets: ["latin"], - display: "swap", - variable: "--font-instrument-serif", - weight: ["400"], -}); - export const metadata: Metadata = { metadataBase: new URL("https://tern.hookflo.com"), title: { @@ -164,7 +142,7 @@ export default function RootLayout({ return ( {/* Additional SEO tags */} diff --git a/app/page.tsx b/app/page.tsx index 1ea35be..5834b98 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,1435 +1,80 @@ -"use client"; - -import { useState } from "react"; import Link from "next/link"; -import { Feather } from "lucide-react"; -import WebhookIntegrationGuide from "@/components/webhook-integration-guide"; - -// ─── INLINE STYLES (same design system as HTML) ─────────────────────────────── -const css = ` - @import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,500;0,600;1,400;1,500&family=JetBrains+Mono:wght@400;500;700&family=Instrument+Serif:ital@0;1&display=swap'); - - :root { - --paper: #f7f4ef; - --paper2: #f0ebe2; - --ink: #1a1714; - --ink2: #3d3830; - --ink3: #6b6358; - --ink4: #9e9488; - --border: #d8d0c4; - --border2: #c4baad; - --green: #059669; - --green-bg:#edf5f0; - --red: #c0392b; - --red-bg: #fdf1ef; - --accent: #2c5f8a; - --mono: 'JetBrains Mono', monospace; - --serif: 'Lora', Georgia, serif; - --display: 'Instrument Serif', Georgia, serif; - } - - .tern-root { font-family: var(--serif); background: var(--paper); color: var(--ink); } - .tern-root * { box-sizing: border-box; } - - /* NAV */ - .t-nav { - position: sticky; top: 0; z-index: 100; - background: rgba(247,244,239,0.92); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border); - padding: 0 clamp(20px,5vw,80px); - display: flex; align-items: center; justify-content: space-between; - height: 56px; - } - .t-nav-logo { display:flex; align-items:center; gap:8px; text-decoration:none; color:var(--ink); } - .t-nav-icon { - width:28px; height:28px; border:1.5px solid var(--ink); border-radius:6px; - display:flex; align-items:center; justify-content:center; flex-shrink:0; - } - .t-nav-name { font-family:var(--mono); font-size:13px; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; } - .t-nav-links { display:flex; align-items:center; gap:28px; list-style:none; margin:0; padding:0; } - .t-nav-links a { font-family:var(--mono); font-size:11px; font-weight:500; letter-spacing:0.06em; color:var(--ink3); text-decoration:none; transition:color .2s; } - .t-nav-links a:hover { color:var(--ink); } - .t-nav-gh { - display:flex; align-items:center; gap:6px; - font-family:var(--mono); font-size:11px; font-weight:700; letter-spacing:0.06em; - color:var(--ink); border:1.5px solid var(--ink); padding:6px 14px; border-radius:4px; - text-decoration:none; transition:background .2s, color .2s; - } - .t-nav-gh:hover { background:var(--ink); color:var(--paper); } - @media(max-width:640px){ .t-nav-links { display:none; } } - - /* HERO */ - .t-hero { - padding: clamp(60px,10vw,120px) clamp(20px,5vw,80px) clamp(60px,8vw,100px); - max-width:1200px; margin:0 auto; - display:grid; grid-template-columns:1fr 1fr; gap:60px; align-items:start; - - } - @media(max-width:900px){ .t-hero { grid-template-columns:1fr; gap:40px; } } - - - .t-eyebrow { - display:inline-flex; align-items:center; gap:8px; - font-family:var(--mono); font-size:10px; font-weight:700; - letter-spacing:0.14em; text-transform:uppercase; color:var(--ink3); margin-bottom:24px; - } - .t-eyebrow::before { content:''; display:block; width:24px; height:1px; background:var(--ink3); } - - .t-h1 { - font-family:var(--display); font-size:clamp(38px,5vw,60px); - font-weight:400; line-height:1.1; letter-spacing:-0.02em; color:var(--ink); margin-bottom:20px; - } - .t-h1 em { font-style:italic; color:var(--ink2); } - - .t-hero-desc { font-size:clamp(15px,1.8vw,17px); color:var(--ink3); line-height:1.65; max-width:480px; margin-bottom:36px; } - - .t-actions { display:flex; align-items:center; gap:16px; flex-wrap:wrap; } - - .t-btn-primary { - font-family:var(--mono); font-size:12px; font-weight:700; - letter-spacing:0.08em; text-transform:uppercase; - color:var(--paper); background:var(--ink); border:none; - padding:12px 24px; border-radius:4px; cursor:pointer; text-decoration:none; - transition:opacity .2s, transform .15s; display:inline-block; - } - .t-btn-primary:hover { opacity:.85; transform:translateY(-1px); } - - .t-btn-secondary { - font-family:var(--mono); font-size:12px; font-weight:500; letter-spacing:0.06em; - color:var(--ink2); text-decoration:none; - display:inline-flex; align-items:center; gap:6px; - border-bottom:1px solid var(--border2); padding-bottom:2px; - transition:border-color .2s, color .2s; - } - .t-btn-secondary:hover { color:var(--ink); border-color:var(--ink); } - - .t-install { display:flex; align-items:center; gap:12px; margin-top:28px; } - .t-install-cmd { - display:inline-flex; align-items:center; gap:10px; - font-family:var(--mono); font-size:12px; font-weight:500; - color:var(--ink2); background:var(--paper2); border:1px solid var(--border); - padding:8px 16px; border-radius:4px; - } - .t-install-cmd span { color:var(--ink4); } - .t-copy-btn { background:none; border:none; cursor:pointer; color:var(--ink4); padding:2px; transition:color .2s; display:flex; align-items:center; } - .t-copy-btn:hover { color:var(--ink); } - - /* CODE CARD */ - .t-code-card { background:var(--ink); border-radius:10px; overflow:hidden; box-shadow:6px 8px 0 var(--border2), 0 20px 60px rgba(26,23,20,.12); } - .t-code-bar { background:#2a2520; padding:10px 16px; display:flex; align-items:center; gap:8px; border-bottom:1px solid rgba(255,255,255,.06); } - .t-dots { display:flex; gap:6px; } - .t-dot { width:10px; height:10px; border-radius:50%; } - .t-code-filename { font-family:var(--mono); font-size:10px; color:rgba(255,255,255,.3); margin-left:8px; letter-spacing:.04em; } - .t-code-body { padding:20px 22px; font-family:var(--mono); font-size:12px; line-height:1.75; } - /* syntax */ - .ck { color:#c792ea; } - .cs { color:#c3e88d; } - .cf { color:#82aaff; } - .cc { color:#546e7a; font-style:italic; } - .co { color:#ffcb6b; } - .ct { color:#f8f8f2; } - - .t-hero-stats { display:grid; grid-template-columns:repeat(3,1fr); border:1px solid var(--border); border-radius:8px; margin-top:20px; overflow:hidden; background:white; } - .t-hero-stat { padding:16px; text-align:center; border-right:1px solid var(--border); } - .t-hero-stat:last-child { border-right:none; } - .t-hero-stat-val { font-family:var(--display); font-size:26px; font-style:italic; color:var(--ink); line-height:1; } - .t-hero-stat-lbl { font-family:var(--mono); font-size:9px; color:var(--ink4); margin-top:4px; letter-spacing:.08em; text-transform:uppercase; } - - /* SECTIONS */ - .t-section { padding:clamp(50px,8vw,90px) clamp(20px,5vw,80px); } - .t-section-inner { max-width:1200px; margin:0 auto; } - .t-section-label { - font-family:var(--mono); font-size:10px; font-weight:700; - letter-spacing:.16em; text-transform:uppercase; color:var(--ink4); - margin-bottom:16px; display:flex; align-items:center; gap:10px; - } - .t-section-label::after { content:''; flex:1; height:1px; background:var(--border); max-width:60px; } - .t-h2 { font-family:var(--display); font-size:clamp(28px,4vw,42px); font-weight:400; line-height:1.15; color:var(--ink); margin-bottom:16px; } - .t-h2 em { font-style:italic; } - .t-section-desc { font-size:16px; color:var(--ink3); max-width:560px; line-height:1.6; margin-bottom:48px; } - - /* DIFF */ - .t-diff-section { background:white; border-top:1px solid var(--border); border-bottom:1px solid var(--border); } - .t-diff-grid { display:grid; grid-template-columns:1fr 1fr; border:1.5px solid var(--border2); border-radius:10px; overflow:hidden; box-shadow:4px 5px 0 var(--border); } - @media(max-width:700px){ .t-diff-grid { grid-template-columns:1fr; } } - .t-diff-label { display:flex; align-items:center; gap:8px; padding:10px 16px; font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; border-bottom:1px solid var(--border); } - .t-diff-label.before { background:var(--red-bg); color:var(--red); border-right:1px solid var(--border); } - .t-diff-label.after { background:var(--green-bg); color:var(--green); } - @media(max-width:700px){ .t-diff-label.before { border-right:none; } } - .t-diff-dot { width:6px; height:6px; border-radius:50%; } - .t-diff-lc { margin-left:auto; opacity:.55; font-size:9px; font-weight:400; } - .t-diff-code { padding:16px; font-family:var(--mono); font-size:11.5px; line-height:1.7; background:#fdfcfb; border-right:1px solid var(--border); min-height:300px; } - .t-diff-code.after { background:#fafdfb; border-right:none; } - @media(max-width:700px){ .t-diff-code { border-right:none; border-bottom:1px solid var(--border); } } - .dl { display:flex; gap:10px; padding:1.5px 4px; border-radius:3px; } - .dl.rem { background:rgba(192,57,43,.06); } - .dl.add { background:rgba(26,107,60,.07); } - .dl.neu { opacity:.35; } - .dl.emp { min-height:19.5px; } - .dp { width:10px; flex-shrink:0; font-weight:700; } - .dl.rem .dp { color:var(--red); } - .dl.add .dp { color:var(--green); } - .dl.neu .dp { color:var(--ink4); } - .dc { flex:1; white-space:pre-wrap; word-wrap:break-word; overflow-wrap:break-word; overflow:hidden; } - .dl.rem .dc { color:#a93226; } - .dl.add .dc { color:#1a6b3c; } - .dl.neu .dc { color:var(--ink3); } - .dkw { color:#7c3aed; } - .dstr { color:#166534; } - .dfn { color:#1d4ed8; } - .dcm { color:#9ca3af; font-style:italic; } - .dobj { color:#92400e; } - - .t-diff-stats { display:grid; grid-template-columns:repeat(4,1fr); border:1.5px solid var(--border2); border-top:none; border-radius:0 0 10px 10px; overflow:hidden; background:white; } - @media(max-width:600px){ .t-diff-stats { grid-template-columns:repeat(2,1fr); } } - .t-diff-stat { padding:16px; text-align:center; border-right:1px solid var(--border); } - .t-diff-stat:last-child { border-right:none; } - @media(max-width:600px){ .t-diff-stat:nth-child(2){ border-right:none; } .t-diff-stat:nth-child(3){ border-top:1px solid var(--border); } } - .t-diff-stat-val { font-family:var(--display); font-size:28px; font-style:italic; line-height:1; } - .t-diff-stat-val.r { color:var(--red); } - .t-diff-stat-val.g { color:var(--green); } - .t-diff-stat-lbl { font-family:var(--mono); font-size:9px; color:var(--ink4); margin-top:4px; letter-spacing:.08em; text-transform:uppercase; } - - /* FLAG SECTION */ - .t-flag-section { background:white; border-top:1px solid var(--border); } - .t-flag-card { background:var(--paper2); border:1.5px solid var(--border2); border-radius:12px; overflow:hidden; box-shadow:4px 5px 0 var(--border); max-width:820px; } - .t-flag-top { padding:28px 32px 24px; border-bottom:1px solid var(--border); } - .t-flag-eyebrow { font-family:var(--mono); font-size:9px; font-weight:700; letter-spacing:.16em; text-transform:uppercase; color:var(--ink4); margin-bottom:10px; } - .t-flag-title { font-family:var(--display); font-size:clamp(20px,3vw,28px); font-weight:400; color:var(--ink); line-height:1.2; margin-bottom:10px; } - .t-flag-title em { font-style:italic; } - .t-flag-desc { font-size:14px; color:var(--ink3); line-height:1.6; max-width:560px; } - .t-flag-ui { padding:24px 32px; display:flex; align-items:center; gap:24px; flex-wrap:wrap; } - .t-flag-toggles { display:flex; flex-direction:column; gap:10px; } - .t-flag-row { display:flex; align-items:center; gap:14px; background:white; border:1px solid var(--border); border-radius:8px; padding:12px 16px; min-width:300px; } - @media(max-width:600px){ .t-flag-row { min-width:unset; width:100%; } .t-flag-ui { flex-direction:column; gap:16px; } } - .t-flag-key { font-family:var(--mono); font-size:11px; font-weight:700; color:var(--ink2); } - .t-flag-val { font-family:var(--mono); font-size:10px; color:var(--ink4); margin-top:2px; } - .t-flag-toggle { width:40px; height:22px; background:var(--ink); border-radius:11px; position:relative; flex-shrink:0; } - .t-flag-toggle::after { content:''; position:absolute; right:3px; top:3px; width:16px; height:16px; background:white; border-radius:50%; } - .t-flag-arrow { display:flex; flex-direction:column; align-items:center; gap:4px; color:var(--ink4); font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.08em; } - .t-flag-result { background:white; border:1px solid var(--border); border-radius:8px; padding:14px 18px; } - .t-flag-result-label { font-family:var(--mono); font-size:9px; font-weight:700; color:var(--ink4); letter-spacing:.1em; text-transform:uppercase; margin-bottom:6px; } - .t-flag-result-val { font-family:var(--mono); font-size:12px; color:var(--green); font-weight:700; } - - /* MIDDLEWARE SECTION */ - .t-mw-section { background:var(--paper2); border-top:1px solid var(--border); } - .t-mw-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:12px; } - @media(max-width:640px){ .t-mw-grid { grid-template-columns:1fr; } } - .t-mw-card { background:white; border:1px solid var(--border); border-radius:10px; overflow:hidden; transition:box-shadow .2s, transform .15s; } - .t-mw-card:hover { box-shadow:3px 4px 0 var(--border); transform:translateY(-1px); } - .t-mw-card-head { padding:16px 18px 12px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; } - .t-mw-card-name { font-family:var(--mono); font-size:12px; font-weight:700; color:var(--ink); letter-spacing:.04em; } - .t-mw-card-badge { font-family:var(--mono); font-size:9px; font-weight:700; letter-spacing:.1em; text-transform:uppercase; padding:3px 8px; border-radius:20px; } - .t-mw-card-badge.stable { background:var(--green-bg); color:var(--green); } - .t-mw-card-badge.beta { background:#fef9e7; color:#b7770d; } - .t-mw-card-body { padding:14px 18px; } - .t-mw-card-import { font-family:var(--mono); font-size:10.5px; background:var(--paper2); border:1px solid var(--border); border-radius:4px; padding:8px 12px; margin-bottom:10px; color:var(--ink2); white-space:pre-wrap; word-wrap:break-word; overflow-wrap:break-word; overflow-x:auto; } - .t-mw-card-desc { font-size:12.5px; color:var(--ink3); line-height:1.5; } - - /* PLATFORMS */ - .t-platforms-section { background:var(--paper); border-top:1px solid var(--border); } - .t-platforms-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:10px; } - .t-platform-chip { display:flex; align-items:center; gap:10px; padding:12px 16px; background:white; border:1px solid var(--border); border-radius:8px; font-family:var(--mono); font-size:11px; font-weight:500; color:var(--ink2); transition:border-color .2s, box-shadow .2s, transform .15s; cursor:default; } - .t-platform-chip:hover { border-color:var(--border2); box-shadow:2px 3px 0 var(--border); transform:translateY(-1px); } - .t-platform-icon { width:20px; height:20px; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:11px; flex-shrink:0; } - .t-platform-dot { margin-left:auto; width:6px; height:6px; border-radius:50%; background:var(--green); flex-shrink:0; } - .t-platform-soon { margin-left:auto; font-size:8px; color:var(--ink4); letter-spacing:.06em; } - - /* FEATURES */ - .t-features-section { background:white; border-top:1px solid var(--border); } - .t-features-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:1px; background:var(--border); border:1px solid var(--border); border-radius:10px; overflow:hidden; } - .t-feature-card { background:white; padding:28px 28px 24px; transition:background .2s; } - .t-feature-card:hover { background:#fdfcfb; } - .t-feature-num { font-family:var(--mono); font-size:10px; font-weight:700; color:var(--ink4); letter-spacing:.1em; margin-bottom:16px; } - .t-feature-icon { width:36px; height:36px; border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; justify-content:center; margin-bottom:14px; background:var(--paper2); } - .t-feature-title { font-family:var(--serif); font-size:16px; font-weight:600; color:var(--ink); margin-bottom:8px; line-height:1.3; } - .t-feature-desc { font-size:13.5px; color:var(--ink3); line-height:1.55; } - - /* HOW IT WORKS */ - .t-how-section { background:var(--paper); border-top:1px solid var(--border); } - .t-steps { display:flex; flex-direction:column; border:1px solid var(--border); border-radius:10px; overflow:hidden; max-width:780px; background:white; } - .t-step { display:grid; grid-template-columns:64px 1fr; border-bottom:1px solid var(--border); } - .t-step:last-child { border-bottom:none; } - .t-step-num { display:flex; align-items:flex-start; justify-content:center; padding-top:24px; font-family:var(--display); font-size:22px; font-style:italic; color:var(--ink4); border-right:1px solid var(--border); } - .t-step-body { padding:22px 24px; } - .t-step-title { font-family:var(--serif); font-size:15px; font-weight:600; color:var(--ink); margin-bottom:6px; } - .t-step-desc { font-size:13.5px; color:var(--ink3); line-height:1.55; margin-bottom:12px; word-wrap:break-word; overflow-wrap:break-word; } - .t-step-code { font-family:var(--mono); font-size:11px; background:var(--paper2); border:1px solid var(--border); border-radius:5px; padding:10px 14px; color:var(--ink2); line-height:1.6; overflow-x:auto; white-space:pre-wrap; word-wrap:break-word; overflow-wrap:break-word; } - @media(max-width:640px){ .t-step { grid-template-columns:1fr; } .t-step-num { padding-top:16px; padding-bottom:12px; border-right:none; border-bottom:1px solid var(--border); justify-content:flex-start; padding-left:24px; } } - .t-step-code .skw { color:#7c3aed; } - .t-step-code .sstr { color:#166534; } - .t-step-code .sfn { color:#1d4ed8; } - .t-step-code .sobj { color:#92400e; } - .t-step-code .scm { color:#9ca3af; font-style:italic; } - - /* CTA */ - .t-cta-section { background:var(--ink); padding:clamp(60px,8vw,100px) clamp(20px,5vw,80px); } - .t-cta-inner { max-width:1200px; margin:0 auto; display:grid; grid-template-columns:1fr auto; gap:40px; align-items:center; } - @media(max-width:700px){ .t-cta-inner { grid-template-columns:1fr; } .t-cta-actions { flex-direction:row; } } - .t-cta-title { font-family:var(--display); font-size:clamp(28px,4vw,44px); font-weight:400; color:var(--paper); line-height:1.15; } - .t-cta-title em { font-style:italic; color:rgba(247,244,239,.7); } - .t-cta-desc { font-size:15px; color:rgba(247,244,239,.55); margin-top:10px; line-height:1.55; } - .t-cta-actions { display:flex; flex-direction:column; gap:12px; } - .t-btn-cta { font-family:var(--mono); font-size:12px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:var(--ink); background:var(--paper); border:none; padding:13px 28px; border-radius:4px; cursor:pointer; text-decoration:none; transition:opacity .2s; text-align:center; white-space:nowrap; } - .t-btn-cta:hover { opacity:.88; } - .t-btn-cta-sec { font-family:var(--mono); font-size:11px; color:rgba(247,244,239,.5); text-decoration:none; text-align:center; transition:color .2s; } - .t-btn-cta-sec:hover { color:rgba(247,244,239,.85); } - - /* FOOTER */ - .t-footer { background:var(--ink); border-top:1px solid rgba(255,255,255,.07); padding:28px clamp(20px,5vw,80px); } - .t-footer-inner { max-width:1200px; margin:0 auto; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:16px; } - .t-footer-logo { font-family:var(--mono); font-size:11px; font-weight:700; letter-spacing:.12em; text-transform:uppercase; color:rgba(247,244,239,.5); } - .t-footer-links { display:flex; gap:20px; list-style:none; margin:0; padding:0; } - .t-footer-links a { font-family:var(--mono); font-size:10px; color:rgba(247,244,239,.35); text-decoration:none; letter-spacing:.06em; transition:color .2s; } - .t-footer-links a:hover { color:rgba(247,244,239,.7); } - .t-footer-copy { font-family:var(--mono); font-size:10px; color:rgba(247,244,239,.25); letter-spacing:.04em; } - @media(max-width:600px){ .t-footer-inner { flex-direction:column; text-align:center; } .t-footer-links { flex-wrap:wrap; justify-content:center; } } - - @keyframes fadeUp { - from { opacity:0; transform:translateY(20px); } - to { opacity:1; transform:translateY(0); } - } - .fade-up-1 { animation:fadeUp .5s ease .05s both; } - .fade-up-2 { animation:fadeUp .5s ease .12s both; } - .fade-up-3 { animation:fadeUp .5s ease .18s both; } - .fade-up-4 { animation:fadeUp .5s ease .24s both; } - .fade-up-5 { animation:fadeUp .5s ease .30s both; } - .fade-up-r { animation:fadeUp .5s ease .20s both; } - - /* inline code */ - .t-inline-code { font-family:var(--mono); font-size:.88em; background:var(--paper2); border:1px solid var(--border); padding:1px 5px; border-radius:3px; color:var(--ink2); } -`; - -// ─── DATA ────────────────────────────────────────────────────────────────────── - -const PLATFORMS = [ - { name: "Stripe", icon: "/Stripe.svg", bg: "#f0f9f4", verified: true }, - { name: "Clerk", icon: "/clerk.svg", bg: "#f5f0fa", verified: true }, - { name: "GitHub", icon: "/github.svg", bg: "#f0f5ff", verified: true }, - { name: "Shopify", icon: "/shopify.svg", bg: "#fff5f0", verified: true }, - { name: "Polar", icon: "/polar.svg", bg: "#f5f5f0", verified: true }, - { name: "Dodo Payments", icon: "/dodo.svg", bg: "#f0f5fa", verified: true }, - { name: "GitLab", icon: "/gitlab.svg", bg: "#fafafa", verified: true }, - { name: "Vercel", icon: "/vercel.svg", bg: "#f0f9ff", verified: true }, - { name: "Replicate", icon: "/replicate.svg", bg: "#f0f8ff", verified: true }, - { name: "Razorpay", icon: "/razorpay.svg", bg: "#f0f8ff", verified: true }, - { name: "WorkOS", icon: "/workos.svg", bg: "#f5f5f5", verified: true }, - { name: "Fal AI", icon: "/fal.svg", bg: "#f0f8ff", verified: true }, - { - name: "LemonSqueezy", - icon: "/lemonsqueezy.svg", - bg: "#f5f5f0", - verified: true, - }, - { name: "Paddle", icon: "/paddle.svg", bg: "#fafafa", verified: true }, - { name: "Doppler", icon: "/doppler.svg", bg: "#fff1f3", verified: true }, - { name: "Sentry", icon: "/sentry.svg", bg: "#eff6ff", verified: true }, - { name: "Grafana", icon: "/grafana.svg", bg: "#eff6ff", verified: true }, -]; - -const MIDDLEWARES = [ - { - name: "@hookflo/tern/nextjs", - badge: "stable", - import: `import { createWebhookHandler } from '@hookflo/tern/nextjs'`, - desc: "App Router adapter. Reads platform & secret from Vercel feature flags at runtime — no redeploy needed.", - }, - { - name: "@hookflo/tern/express", - badge: "stable", - import: `import { createWebhookMiddleware } from '@hookflo/tern/express'`, - desc: "Express.js middleware. Drop-in request verification before your route handler runs.", - }, - { - name: "@hookflo/tern/cloudflare", - badge: "beta", - import: `import { createWebhookHandler } from '@hookflo/tern/cloudflare'`, - desc: "Cloudflare Workers adapter using the Web Crypto API. Edge-native, zero Node.js dependencies.", - }, - { - name: "Core API", - badge: "stable", - import: `import { WebhookVerificationService } from '@hookflo/tern'`, - desc: "Framework-agnostic core. Works in any runtime that supports the Web Crypto API — Deno, Bun, Node.js 18+.", - }, -]; - -const FEATURES = [ - { - num: "01", - title: "Algorithm agnostic", - desc: "HMAC-SHA256, SHA1, SHA512, or custom. Tern decouples platform logic from signing logic — add any platform without touching core code.", - icon: ( - - - - ), - }, - { - num: "02", - title: "Zero dependencies", - desc: "No svix, no axios, no lodash. Pure TypeScript using the platform's Web Crypto API. Smaller bundle, full auditability.", - icon: ( - - - - - ), - }, - { - num: "03", - title: "Fully type-safe", - desc: "Comprehensive TypeScript types throughout. Catch wrong platform names and missing secrets at compile time, not in production.", - icon: ( - - - - - ), - }, - { - num: "04", - title: "Framework agnostic", - desc: "Express, Next.js App Router, Cloudflare Workers, Deno — Tern normalizes the request interface so your code works everywhere.", - icon: ( - - - - - ), - }, - { - num: "05", - title: "Custom platform configs", - desc: "Using a provider we don't support yet? Supply a signatureConfig object and verify any HMAC webhook — no library update needed.", - icon: ( - - - - ), - }, - { - num: "06", - title: "Timing-safe by default", - desc: "All comparisons use constant-time equality to prevent timing attacks. Replay protection via configurable timestamp tolerance is on by default.", - icon: ( - - - - - ), - }, -]; - -// ─── COMPONENT ───────────────────────────────────────────────────────────────── +import { AnnouncementBanner } from "@/components/announcement-banner"; +import { SiteNav } from "@/components/site-nav"; -interface PlatformChipItemProps { - platform: (typeof PLATFORMS)[0]; -} - -function PlatformChipItem({ platform }: PlatformChipItemProps) { - return ( -
-
-
- {`${platform.name} -
- {platform.name} - {platform.verified ? ( -
- ) : ( - soon - )} -
-
- ); -} +const badges = ["✓ Verified", "📬 Queued", "🔁 Retried", "📥 DLQ", "🧹 Deduped"]; export default function HomePage() { - const [copied, setCopied] = useState(false); - - const copyInstall = () => { - navigator.clipboard.writeText("npm i @hookflo/tern"); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( -
- - - {/* ── NAV ── */} - - - {/* ── HERO ── */} -
-
- {/* Left */} -
-
- Webhook Verification Framework -
-

- Verify Every Webhook, -
Across Every Platform. -
-

- -

- Tern is a zero-dependency TypeScript framework for webhook - signature verification. One SDK, every platform. No boilerplate, - no fragile hand-rolled crypto. -

- -
-
- $ npm i @hookflo/tern -
- -
-
- - {/* Right: Code Card */} -
-
-
-
-
-
-
-
- - app/api/webhooks/route.ts - -
-
-
- import{" "} - {"{ createWebhookHandler }"}{" "} - from{" "} - '@hookflo/tern/nextjs' -
-
- import{" "} - {"{ platform }"}{" "} - from{" "} - '../flags' -
-
 
-
- export const{" "} - POST ={" "} - createWebhookHandler - {"({"} -
-
-   platform:{" "} - await{" "} - platform - (), -
-
-   secret:   - await{" "} - process.env.WEBHOOK_SECRET! - , -
-
-   handler: - async{" "} - (payload) => {"{"} -
-
- -     // verified ✓ — handle your event - -
-
-   {"}"} -
-
- {"})"} -
-
-
-
-
-
10+
-
Platforms
-
-
-
0
-
Dependencies
-
-
-
-
Custom configs
-
-
-
-
-
- {/* ── BEFORE / AFTER DIFF ── */} -
-
-
Before & After
-

- Your webhook handler, -
- minus the boilerplate. -

-

- Clerk webhook verification today vs with Tern. Same security, a - fraction of the code. -

- -
- {/* BEFORE */} -
-
-
- Before — Clerk (manual) - ~22 lines -
-
- {[ - { - t: "rem", - p: "−", - c: ( - <> - import - {" { Webhook } "} - from{" "} - 'svix' - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - import - {" { headers } "} - from{" "} - 'next/headers' - - ), - }, - { t: "emp" }, - { - t: "rem", - p: "−", - c: ( - <> - export async function{" "} - POST - {"(req: Request) {"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" SECRET = process.env."} - CLERK_WEBHOOK_SECRET - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - if - {"(!SECRET) "} - throw new{" "} - Error - {"("} - 'Missing secret' - {")"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" h = "} - await{" "} - headers - {"()"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" svix_id = h."} - get - {"("} - "svix-id" - {")"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" svix_ts = h."} - get - {"("} - "svix-timestamp" - {")"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" svix_sig = h."} - get - {"("} - "svix-signature" - {")"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - if - {"(!svix_id || !svix_ts || !svix_sig)"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - return new{" "} - Response - {"("} - 'Bad headers' - {", { status: 400 })"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" body = JSON."} - stringify - {"("} - await - {" req."} - json - {"())"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - const - {" wh = "} - new{" "} - Webhook - {"(SECRET)"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - let - {" evt"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - try - {" { evt = wh."} - verify - {"(body, {"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" "} - "svix-id" - {": svix_id, "} - "svix-signature" - {": svix_sig })"} - - ), - }, - { - t: "rem", - p: "−", - c: ( - <> - {" } "} - catch - {" (err) { "} - return new{" "} - Response - {"("} - 'Invalid' - {", {status:400}) }"} - - ), - }, - { - t: "neu", - p: " ", - c: ( - <> - - {" // finally... handle event"} - - - ), - }, - { t: "neu", p: " ", c: <>{"}"} }, - ].map((line, i) => - line.t === "emp" ? ( -
- ) : ( -
- {line.p} - {line.c} -
- ), - )} -
-
- - {/* AFTER */} -
-
-
- After — With Tern - 8 lines. done. -
-
- {[ - { - t: "add", - p: "+", - c: ( - <> - import - {" { createWebhookHandler } "} - from{" "} - '@hookflo/tern/nextjs' - - ), - }, - { - t: "add", - p: "+", - c: ( - <> - import - {" { platform } "} - from{" "} - '../flags' - - ), - }, - { t: "emp" }, - { - t: "add", - p: "+", - c: ( - <> - export const - {" POST = "} - createWebhookHandler - {"({"} - - ), - }, - { - t: "add", - p: "+", - c: ( - <> - {" platform: "} - await{" "} - platform - {"(),"} - - ), - }, - { - t: "add", - p: "+", - c: ( - <> - {" secret: "} - process.env.WEBHOOK_SECRET! - {","} - - ), - }, - { - t: "add", - p: "+", - c: ( - <> - {" handler: "} - async - {" (payload) => {"} - - ), - }, - { - t: "neu", - p: " ", - c: ( - <> - - {" // verified ✓ — handle your event"} - - - ), - }, - { t: "add", p: "+", c: <>{" }"} }, - { t: "add", p: "+", c: <>{"})"} }, - ...Array(10).fill({ t: "emp" }), - ].map((line, i) => - line.t === "emp" ? ( -
- ) : ( -
- {line.p} - {line.c} -
- ), - )} -
-
-
- - {/* Diff stats */} -
-
-
−22
-
Lines deleted
-
-
-
+8
-
Lines added
-
-
-
0
-
Redeploys
-
-
-
-
Platforms
-
-
-
-
- - {/* ── VERCEL FLAG ── */} -
-
-
The Killer Feature
-
-
-
Vercel Feature Flags × Tern
-

- Switch platforms with -
- a single flag flip. -

-

- No code change. No redeployment. Set your platform via Vercel - feature flags — Tern reads them at runtime. Switch from Clerk to - Stripe to GitHub without touching your codebase. -

-
-
-
-
-
-
PLATFORM
-
clerk → stripe
-
-
-
-
-
- - - - that's it -
-
-
Result
-
- ✓ Verified — no redeploy -
-
-
-
-
-
- - {/* ── MIDDLEWARE / FRAMEWORK ADAPTERS ── */} -
-
-
Framework Adapters
-

- Works wherever -
- your code runs. -

-

- Purpose-built adapters for every major runtime. Same verification - logic, native integration. -

-
- {MIDDLEWARES.map((mw) => ( -
-
- {mw.name} - - {mw.badge} - -
-
-
{mw.import}
-

{mw.desc}

-
-
- ))} -
+
+ + + +
+

Tern webhook security

+

Universal webhook verification with reliable delivery.

+

+ Verify signatures across platforms, then enqueue verified events for retries, dead-letter queue recovery, and + deduplication with Bring Your Own Keys (BYOK). +

+
+ + Get started + + + Reliable Delivery → +
-
- - {/* ── WEBHOOK INTEGRATION GUIDE ── */} -
- -
- - {/* ── PLATFORMS ── */} -
-
-
Supported Platforms
-

- Works with the tools -
- you already use. -

-

- Verified implementations — not guesswork. Each platform is tested - against real webhook payloads. -

-
- {PLATFORMS.map((p) => ( - - ))} -
-

- ● verified    Custom config lets you verify any{" "} - HMAC-based webhook without waiting for built-in support. -

-
-
- - {/* ── FEATURES ── */} -
-
-
Features
-

Built for the long run.

-

- No magic, no bloat. Just the right abstractions in the right places. -

-
- {FEATURES.map((f) => ( -
-
{f.num}
-
{f.icon}
-
{f.title}
-
{f.desc}
-
- ))} -
+
+ {badges.map((badge) => ( + + {badge} + + ))}
- {/* ── HOW IT WORKS ── */} -
-
-
How it works
-

- Up and running -
- in three steps. -

-
-
-
1
-
-
Install
-
- One package, zero transitive dependencies. Works in Node.js, - Next.js, Cloudflare Workers, Deno. -
-
npm install @hookflo/tern
-
-
-
-
2
-
-
Verify with your platform
-
- Pass the request, platform name, and secret. Tern handles - header parsing, timing validation, and HMAC comparison. -
-
- import - {" { WebhookVerificationService } "} - from{" "} - '@hookflo/tern' - {"\n\n"} - const - {" result = "} - await - {" WebhookVerificationService\n"} - {" ."} - verifyWithPlatformConfig - {"(request, "} - 'clerk' - {", process.env."} - WEBHOOK_SECRET - {")\n\n"} - if - {" (result.isValid) { "} - // handle your event - {" }"} -
-
-
-
-
3
-
-
- Use feature flags to switch platforms -
-
- With the Next.js adapter, pass{" "} - platform from Vercel - feature flags. Change platforms at runtime — zero code - changes, zero redeployments. -
-
- export const - {" POST = "} - createWebhookHandler - {"({\n"} - {" platform: "} - await{" "} - platform - {"(), "} - // from @vercel/flags - {"\n"} - {" secret: "}{" "} - process.env.WEBHOOK_SECRET!, - {"\n"} - {" handler: "} - async - {" (payload) => { "} - /* ... */ - {" }\n"} - {"})"} -
-
-
-
+
+

Beyond verification

+

Verification is just the start.

+
+
+

01

+

Cross-platform verification

+

One handler API for Stripe, Clerk, GitHub, Fal AI, Replicate, and more.

+
+
+

02 New

+

Guaranteed delivery

+

Queue, retry, DLQ, replay, and deduplication powered by Upstash QStash.

+ + Open Reliable Delivery → + +
+
+

03

+

Framework adapters

+

Purpose-built adapters for Next.js App Router and Cloudflare Workers.

+ + Read Next.js guide → + +
- {/* ── CTA ── */} -
-
-
-

- Ready to delete -
- your webhook boilerplate? -

-

- Open source. MIT licensed. Built and maintained at Hookflo. -

-
- +
+

Platform guides

+
+ +

Fal AI

+

Ed25519 signature flow, webhook event filtering, and low-cost testing setup.

+ + +

Replicate

+

Fetch webhook secret via API, verify Ed25519 signatures, and test predictions.

+
- - {/* ── FOOTER ── */} - -
+
); } diff --git a/app/platforms/falai/page.tsx b/app/platforms/falai/page.tsx new file mode 100644 index 0000000..0099ae8 --- /dev/null +++ b/app/platforms/falai/page.tsx @@ -0,0 +1,32 @@ +import { SiteNav } from "@/components/site-nav"; + +export default function FalAiPage() { + return ( +
+ +
+

Platform — Fal AI

+

Fal AI webhook verification with Ed25519 support.

+
+
+
+

⚠️ Fal AI uses Ed25519 — not HMAC

+

Unlike Stripe or Clerk, Fal AI signs with Ed25519 public key cryptography. Use secret: "" intentionally and tern fetches public JWKS automatically.

+
+
+
+
{`curl -X POST \\
+  'https://queue.fal.run/fal-ai/flux/schnell?fal_webhook=YOUR_URL' \\
+  -H "Authorization: Key $FAL_KEY" \\
+  -H "Content-Type: application/json" \\
+  -d '{"prompt": "a red cat", "num_images": 1}'`}
+

Fal AI sends two webhook events: IN_QUEUE and COMPLETED. Filter with fal_webhook_events=completed.

+

Cheapest model for testing: fal-ai/flux/schnell — $0.003 per image.

+
+
+

Test locally

  • Fal AI: curl queue.fal.run with ngrok URL
  • Stripe: stripe listen + stripe trigger
  • Clerk: dashboard send webhook
  • GitHub: redeliver
  • Replicate: predictions API with ngrok URL
  • Razorpay: send test webhook
+

Error reference

  • "Webhook secret is not configured"
  • "Missing signature header: stripe-signature"
  • "Queue temporarily unavailable"
  • "DeduplicationId cannot contain ':'"
+
+
+ ); +} diff --git a/app/platforms/replicate/page.tsx b/app/platforms/replicate/page.tsx new file mode 100644 index 0000000..813b272 --- /dev/null +++ b/app/platforms/replicate/page.tsx @@ -0,0 +1,39 @@ +import { SiteNav } from "@/components/site-nav"; + +export default function ReplicatePage() { + return ( +
+ +
+

Platform — Replicate

+

Replicate webhook verification with Ed25519 signatures.

+
+
+
+

Get webhook secret with API

+
{`curl -s \\
+  -H "Authorization: Bearer $REPLICATE_API_TOKEN" \\
+  https://api.replicate.com/v1/webhooks/default/secret`}
+

Response includes whsec_* key. Store that as REPLICATE_WEBHOOK_SECRET once.

+
+
+
+
{`curl -X POST \\
+  -H "Authorization: Bearer $REPLICATE_API_TOKEN" \\
+  -H "Content-Type: application/json" \\
+  -d '{
+    "version": "YOUR_MODEL_VERSION",
+    "input": { "prompt": "a red cat" },
+    "webhook": "YOUR_URL",
+    "webhook_events_filter": ["completed"]
+  }' \\
+  https://api.replicate.com/v1/predictions`}
+

Replicate uses Ed25519 signatures. tern handles verification when secret is configured.

+
+
+

Test locally

  • Replicate: predictions API with ngrok URL
  • Stripe: stripe listen + stripe trigger
  • Clerk: dashboard send test webhook
  • GitHub: redeliver
  • Fal AI: queue.fal.run curl with ngrok URL
  • Razorpay: send test webhook
+

Error reference

  • "Webhook secret is not configured"
  • "Missing signature header: stripe-signature"
  • "Queue temporarily unavailable"
  • "DeduplicationId cannot contain ':'"
+
+
+ ); +} diff --git a/app/platforms/stripe/page.tsx b/app/platforms/stripe/page.tsx new file mode 100644 index 0000000..44abbb6 --- /dev/null +++ b/app/platforms/stripe/page.tsx @@ -0,0 +1,14 @@ +import { SiteNav } from "@/components/site-nav"; + +export default function StripePlatformPage() { + return ( +
+ +
+

Platform — Stripe

+

Stripe verification with optional reliable delivery.

+

Use tern to verify Stripe signatures, then add queue mode for retries and DLQ controls.

+
+
+ ); +} diff --git a/app/upstash/page.tsx b/app/upstash/page.tsx new file mode 100644 index 0000000..1a47322 --- /dev/null +++ b/app/upstash/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { SiteNav } from "@/components/site-nav"; + +const dlqSeed = [ + { id: "msg_26hZ...", event: "payment_intent.failed", age: "2m ago", icon: "💳" }, + { id: "msg_8xKp...", event: "user.created", age: "14m ago", icon: "👤" }, + { id: "msg_3rNz...", event: "push", age: "1h ago", icon: "🐙" }, +]; + +export default function UpstashPage() { + const [dlqRows, setDlqRows] = useState(dlqSeed); + + return ( +
+ +
+

01 — Powered by Upstash QStash

+

From fire-and-forget to guaranteed delivery.

+

+ Every verified webhook is queued, retried on failure, deduplicated, and recoverable from a dead-letter queue. + Bring Your Own Keys (BYOK). Zero infrastructure. Your data never leaves your stack. +

+ +
+ +
+
+

🔑 Bring Your Own Keys

+

Tern never touches your Upstash credentials. Pass your QStash token and events flow through your account directly. No vendor lock-in. No data through our servers.

+ QSTASH_TOKEN=qstash_•••••••••• +
+
+
Retries
+
0Infra to manage
+
1Config option
+
100%Data sovereignty
+
+
+ +
+

Flow

+
+ {["📨 Incoming", "🔐 Verify", "📬 Enqueue", "⚙️ Handler", "🔁 Retry", "📥 DLQ"].map((s, i) => ( +
3 ? "warn" : "ok"}`}>{s}
+ ))} +
+
+ +
+

Queue features

+
+

01

Automatic Retries

QStash retries failed deliveries with exponential backoff.

queue: {'{ retries: 3 }'}
+

02

Dead-Letter Queue

Events exhausting retries land in your DLQ for replay.

controls.dlq()
+

03

Deduplication

Repeated webhook event IDs are processed once.

Upstash-Deduplication-Id
+

04

Programmatic Replay

Replay any failed message with one API call.

controls.replay(dlqId)
+
+
+ +
+

Code

+
{`import { createWebhookHandler, controls } from '@hookflo/tern/nextjs'
+
+export const POST = createWebhookHandler({
+  platform: 'stripe',
+  secret: process.env.WEBHOOK_SECRET!,
+  queue: true,
+  handler: async (payload) => {
+    return { received: true }
+  }
+})
+
+export const GET = async () => {
+  const failed = await controls.dlq()
+  return Response.json({ count: failed.length, events: failed })
+}
+
+export const PATCH = async (request: Request) => {
+  const { dlqId } = await request.json()
+  const result = await controls.replay(dlqId)
+  return Response.json(result)
+}`}
+
+

queue: true → Next.js only, reads env automatically.

+

queue: {'{ ... }'} → all frameworks, explicit config.

+
+
+ +
+
+

Dead Letter Queue [{dlqRows.length} failed]

+ {dlqRows.map((row) => ( +
+ {row.icon} {row.id}{row.event}3/3 ✗{row.age} + +
+ ))} +
+
+ +
+
+

Deduplication

+
    +
  • ✓ evt_stripe_001 payment_intent.succeeded [processed]
  • +
  • ⊘ evt_stripe_001 payment_intent.succeeded [duplicate — dropped]
  • +
  • ⊘ evt_stripe_001 payment_intent.succeeded [duplicate — dropped]
  • +
  • ✓ evt_stripe_002 payment_intent.succeeded [processed]
  • +
+
+
+ +
+
+

Pay Upstash directly. Not a middleman.

+ + + + + +
Base fee/month$39$0
500K events$44/mo$5/mo
5M events$89/mo$50/mo
Annual saving$468–$528/yr
+

Tern is free. You pay Upstash directly.

+
+
+

Free tier covers most projects

+

✓ 1,000 messages/day free · ✓ No credit card required · ✓ Upgrade only when you need to.

+
+
+ +
+ $ npm i @hookflo/tern +
+ ⭐ Star on GitHub + Read the docs → +
+

Open source · MIT · Zero dependencies

+
+
+ ); +} diff --git a/components/announcement-banner.tsx b/components/announcement-banner.tsx new file mode 100644 index 0000000..6cf2f76 --- /dev/null +++ b/components/announcement-banner.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +export function AnnouncementBanner() { + const [hidden, setHidden] = useState(null); + + useEffect(() => { + const dismissed = localStorage.getItem("tern-upstash-banner-dismissed"); + setHidden(dismissed === "1"); + }, []); + + if (hidden !== false) { + return null; + } + + return ( +
+
+ 🎉 Tern × Upstash — Guaranteed delivery now in beta · Queue · Retry · DLQ · Dedup · BYOK +
+
+ Learn more → + +
+
+ ); +} diff --git a/components/site-nav.tsx b/components/site-nav.tsx new file mode 100644 index 0000000..deb0e59 --- /dev/null +++ b/components/site-nav.tsx @@ -0,0 +1,48 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +const links = [ + { label: "How it works", href: "/#how-it-works" }, + { label: "Usage", href: "/#usage" }, + { label: "Platforms", href: "/#platforms" }, + { label: "Features", href: "/#features" }, +]; + +export function SiteNav() { + const [open, setOpen] = useState(false); + + return ( +
+ +
+ ); +}