If you've ever built a SaaS product, you've written this code:
const sig = req.headers['stripe-signature']
const raw = await getRawBody(req)
const ts = sig.split(',')[0].split('=')[1]
const s = sig.split(',')[1].split('=')[1]
const pl = `${ts}.${raw}`
const exp = createHmac('sha256', secret).update(pl).digest('hex')
if (!timingSafeEqual(Buffer.from(s), Buffer.from(exp))) throw new Error('401')
if (Date.now()/1e3 - Number(ts) > 300) throw new Error('replay attack')
Then three months later you add Polar. Then GitHub. Then Razorpay. Suddenly you have 200 lines of platform-specific signature verification code scattered across your codebase — all doing the same thing differently.
This post shows you how to do it once, cleanly, with @hookflo/tern.
Why Webhook Verification Is Harder Than It Looks
Every platform signs webhooks differently:
-
Stripe —
t={timestamp},v1={signature}comma-separated, HMAC-SHA256, payload istimestamp.body -
GitHub —
sha256={signature}prefixed, HMAC-SHA256, payload is raw body -
Polar —
v1={signature}space-separated, Standard Webhooks spec, base64 encoded secret - WorkOS — millisecond timestamps (not seconds) — breaks every standard timestamp validator
- fal.ai — ED25519 asymmetric crypto with JWKS key rotation, no shared secret at all
Each has subtle differences that are easy to get wrong. And wrong usually means silent failures — webhooks returning 400, retries flooding your server, events you never process.
Installation
npm install @hookflo/tern
Basic Usage — Next.js App Router
// app/api/webhooks/stripe/route.ts
import { createWebhookHandler } from '@hookflo/tern/nextjs'
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload) => {
console.log(payload.type) // 'payment_intent.succeeded'
return { received: true }
}
})
That's it. Tern handles:
- ✅ Raw body extraction (the thing that breaks most webhook implementations)
- ✅ Signature verification with timing-safe comparison
- ✅ Replay attack protection via timestamp validation
- ✅ Proper error codes (
MISSING_SIGNATURE,INVALID_SIGNATURE,TIMESTAMP_EXPIRED)
The Key Insight — Change One Word to Switch Platforms
// Stripe
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload) => ({ received: true })
})
// Polar — change one word
export const POST = createWebhookHandler({
platform: 'polar',
secret: process.env.POLAR_WEBHOOK_SECRET!,
handler: async (payload) => ({ received: true })
})
// GitHub — change one word
export const POST = createWebhookHandler({
platform: 'github',
secret: process.env.GITHUB_WEBHOOK_SECRET!,
handler: async (payload) => ({ received: true })
})
// Razorpay — change one word
export const POST = createWebhookHandler({
platform: 'razorpay',
secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
handler: async (payload) => ({ received: true })
})
Same API. Every platform. The underlying algorithm differences — base64 vs hex, milliseconds vs seconds, prefixed vs comma-separated — are all handled internally.
Error Handling
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
onError: (error) => {
console.error('Webhook failed:', error.errorCode)
// MISSING_SIGNATURE | INVALID_SIGNATURE | TIMESTAMP_EXPIRED
},
handler: async (payload) => {
return { received: true }
}
})
Replay Attack Protection
Every platform that sends a timestamp gets automatic replay protection. If a webhook arrives more than 5 minutes after it was signed, Tern rejects it with TIMESTAMP_EXPIRED.
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
toleranceInSeconds: 300, // default 5 minutes, adjust as needed
handler: async (payload) => ({ received: true })
})
Custom Platforms
Not on the list? Pass your own config — works with any HMAC scheme:
export const POST = createWebhookHandler({
platform: 'custom',
secret: process.env.MY_WEBHOOK_SECRET!,
signatureConfig: {
algorithm: 'hmac-sha256',
headerName: 'x-my-signature',
headerFormat: 'raw',
payloadFormat: 'raw',
},
handler: async (payload) => ({ received: true })
})
Supported Platforms
| Platform | Algorithm | Status |
|---|---|---|
| Stripe | HMAC-SHA256 | ✅ Verified |
| GitHub | HMAC-SHA256 | ✅ Verified |
| Clerk | HMAC-SHA256 | ✅ Verified |
| Polar | HMAC-SHA256 | ✅ Verified |
| Shopify | HMAC-SHA256 | ✅ Verified |
| WorkOS | HMAC-SHA256 | ✅ Verified |
| Razorpay | HMAC-SHA256 | ✅ Verified |
| Replicate | HMAC-SHA256 | ✅ Verified |
| Paddle | HMAC-SHA256 | ✅ Verified |
| LemonSqueezy | HMAC-SHA256 | ✅ Verified |
| DodoPayments | HMAC-SHA256 | ✅ Verified |
| fal.ai | ED25519 | ✅ Verified |
Why Not Just Use Each Platform's Official SDK?
You could. But:
- Stripe's webhook verification ships as part of
stripe— a 2MB package - GitHub's requires
@octokit/webhooksas a separate dep - Polar, WorkOS, Razorpay, Replicate have no dedicated webhook verification packages
You end up with multiple packages, multiple APIs, multiple ways to extract the raw body. Tern is zero dependencies and one API for all of them.
The Raw Body Problem
One thing that breaks most webhook implementations in Next.js is raw body parsing. JSON middleware re-parses the body and you lose the original bytes needed for HMAC verification.
Tern handles this automatically — you never touch the raw body yourself.
// without tern — this breaks signature verification
export async function POST(req: Request) {
const body = await req.json() // ← destroys raw body for HMAC
// signature check will always fail from here
}
// with tern — just works
export const POST = createWebhookHandler({
platform: 'stripe',
secret: process.env.STRIPE_WEBHOOK_SECRET!,
handler: async (payload) => ({ received: true })
// raw body handled internally ✅
})
Getting Started
npm install @hookflo/tern
import { createWebhookHandler } from '@hookflo/tern/nextjs'
export const POST = createWebhookHandler({
platform: 'stripe', // swap to any supported platform
secret: process.env.WEBHOOK_SECRET!,
handler: async (payload) => {
// your logic here
return { received: true }
}
})
Links:
- GitHub: github.com/Hookflo/tern
- npm: npmjs.com/package/@hookflo/tern
- Discord: Hookflo Community
Built by Hookflo — webhook infrastructure for modern SaaS. Star the repo if this helped ⭐
Top comments (0)