DEV Community

Prateek Jain
Prateek Jain

Posted on

How to Verify Webhooks in Next.js (Stripe, GitHub, Polar, and More)

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')
Enter fullscreen mode Exit fullscreen mode

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:

  • Stripet={timestamp},v1={signature} comma-separated, HMAC-SHA256, payload is timestamp.body
  • GitHubsha256={signature} prefixed, HMAC-SHA256, payload is raw body
  • Polarv1={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
Enter fullscreen mode Exit fullscreen mode

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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 })
})
Enter fullscreen mode Exit fullscreen mode

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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 })
})
Enter fullscreen mode Exit fullscreen mode

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 })
})
Enter fullscreen mode Exit fullscreen mode

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/webhooks as 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 ✅
})
Enter fullscreen mode Exit fullscreen mode

Getting Started

npm install @hookflo/tern
Enter fullscreen mode Exit fullscreen mode
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 }
  }
})
Enter fullscreen mode Exit fullscreen mode

Links:


Built by Hookflo — webhook infrastructure for modern SaaS. Star the repo if this helped ⭐

Top comments (0)