Keep your Next.js site in sync with your CMS content in real time. This guide covers how Lynkow webhooks work, how to verify HMAC signatures, and how to build a secure revalidation endpoint that invalidates the right pages when content changes.

Prerequisites

You should have the Lynkow SDK installed and a client initialized. If not, see Guide 1: Quick Start.

TypeScript
// lib/lynkow.ts
import { createClient } from 'lynkow'

export const lynkow = createClient({
  siteId: process.env.NEXT_PUBLIC_LYNKOW_SITE_ID!,
  fetchOptions: {
    next: { revalidate: 60 },
  },
})

1. How Lynkow webhooks work

When something changes in your Lynkow site (a post is published, a review is approved, a redirect is created), the API can send an HTTP POST request to any URL you configure. This is a webhook.

The flow:

Editor publishes article in Lynkow admin
        |
        v
Lynkow API triggers "content.published" event
        |
        v
Finds all enabled webhooks for this site that listen to this event
        |
        v
Sends POST requests in parallel (Promise.allSettled)
        |
        v
Your Next.js route handler receives the payload
        |
        v
Verifies the HMAC signature
        |
        v
Calls revalidatePath() or revalidateTag() to purge the ISR cache
        |
        v
Next visitor sees updated content

Key characteristics:

  • Fire-and-forget -- Lynkow sends the webhook and does not retry on failure

  • Parallel delivery -- if multiple webhooks listen to the same event, they are triggered simultaneously via Promise.allSettled

  • 30-second timeout -- your endpoint must respond within 30 seconds or the request is aborted

  • HMAC-SHA256 signing -- payloads are signed with your webhook secret so you can verify authenticity

  • Lightweight payloads -- webhook payloads contain only essential fields (IDs, slugs, status) to keep request sizes small; fetch full data from the API if needed

Understanding the cache layers

There are multiple cache layers between a visitor and your content. Each is handled differently:

Visitor → [Your CDN] → Next.js → [Next.js Cache] → SDK → [Cloudflare CDN] → Lynkow API
          (optional)              (revalidate:60)   (cache:false)  (auto-purged)

Layer

TTL

Invalidation

Your action

Cloudflare API cache

5 min

Purged automatically by Lynkow when content changes

None required

Next.js fetch cache

revalidate: 60 (safety net)

revalidatePath() / revalidateTag() from your webhook handler

Set up the webhook handler (this guide)

SDK in-memory cache

Disabled by default

N/A

None required (disabled since SDK v3.9)

Your own CDN (Cloudflare, Vercel, etc.)

Varies

Depends on your CDN config

See Cloudflare CDN section

With cache: false on the SDK client and a webhook handler, the flow becomes:

  1. Editor publishes content in Lynkow admin

  2. Lynkow automatically purges the Cloudflare CDN cache for that site's API responses

  3. Lynkow sends the webhook to your Next.js endpoint

  4. Your handler calls revalidatePath() or revalidateTag()

  5. Next visitor triggers a re-render, the SDK calls fetch() (no in-memory cache), Cloudflare returns fresh data

  6. Content is updated within seconds


2. Webhook events

Every webhook event follows the format {resource}.{action}. Here are the events relevant to a content site:

Content events

Event

Triggered when

content.created

A new content item is saved as draft

content.updated

An existing content item is edited

content.published

Content transitions to published status

content.archived

Content is archived

content.deleted

Content is permanently deleted

content.translated

Content is copied to a new locale

content.bulk_published

Multiple items are published at once

content.bulk_archived

Multiple items are archived at once

content.bulk_deleted

Multiple items are deleted at once

Category and tag events

Event

Triggered when

category.created

A new category is created

category.updated

A category name, slug, or parent changes

category.deleted

A category is removed

category.translated

A category is copied to a new locale

tag.created

A new tag is created

tag.updated

A tag is renamed

tag.deleted

A tag is removed

Form events

Event

Triggered when

form.created

A new form is created

form.updated

A form's schema or settings change

form.deleted

A form is removed

form.submitted

A visitor submits a form

Review events

Event

Triggered when

review.created

A new review is submitted (visitor or admin)

review.updated

A review's status changes (approved, rejected)

review.deleted

A review is removed

review.bulk_updated

Multiple reviews are moderated at once

review.bulk_deleted

Multiple reviews are deleted at once

Media events

Event

Triggered when

media.uploaded

A new file is uploaded

media.updated

File metadata (alt text, title) is changed

media.replaced

A file is replaced with a new version

media.trashed

A file is moved to trash

media.restored

A file is restored from trash

media.deleted

A file is permanently deleted

Site block events

Event

Triggered when

site_block.created

A new site block is created

site_block.updated

A site block's data changes

site_block.published

A site block is published (goes live)

site_block.unpublished

A site block is taken offline

site_block.deleted

A site block is removed

Other events

Event

Triggered when

redirect.created

A new redirect rule is added

redirect.updated

A redirect is modified

redirect.deleted

A redirect is removed

sitemap_entry.created

A custom sitemap entry is added

sitemap_entry.updated

A sitemap entry is modified

sitemap_entry.deleted

A sitemap entry is removed


3. Webhook payload structure

Every webhook POST request has this JSON body:

TypeScript
{
  event: string          // e.g. "content.published"
  timestamp: string      // ISO 8601, e.g. "2024-03-15T10:30:00.000Z"
  data: {
    // Resource-specific fields
  }
}

Content payload example

JSON
{
  "event": "content.published",
  "timestamp": "2024-03-15T10:30:00.000Z",
  "data": {
    "id": 42,
    "slug": "getting-started-with-lynkow",
    "title": "Getting Started with Lynkow",
    "type": "article",
    "status": "published",
    "locale": "en",
    "path": "/blog/getting-started-with-lynkow",
    "authorId": 1,
    "categoryId": 5,
    "translationGroupId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "createdAt": "2024-03-10T08:00:00.000Z",
    "updatedAt": "2024-03-15T10:30:00.000Z",
    "publishedAt": "2024-03-15T10:30:00.000Z"
  }
}

Note that payload bodies are intentionally lightweight. They include IDs, slugs, and status but exclude large fields like body, meta, and excerpt. Call the Lynkow API to fetch the full resource if needed.

Bulk event payload example

JSON
{
  "event": "content.bulk_published",
  "timestamp": "2024-03-15T10:35:00.000Z",
  "data": {
    "count": 3,
    "ids": [42, 43, 44]
  }
}

4. Setting up a webhook in the admin dashboard

  1. Open your Lynkow admin dashboard

  2. Navigate to Settings > Webhooks

  3. Click Add webhook

  4. Fill in the configuration:

    • Name -- A descriptive label (e.g., "Next.js revalidation")

    • URL -- Your endpoint URL (e.g., https://example.com/api/revalidate)

    • Secret -- A strong random string for HMAC signing (e.g., generate with openssl rand -hex 32)

    • Events -- Select which events should trigger this webhook

    • Custom headers -- Optional extra headers to send with each request

  5. Toggle Enabled to activate the webhook

  6. Click Save

Store the secret in your Next.js environment:

env
# .env.local
LYNKOW_WEBHOOK_SECRET=your-webhook-secret-here

5. Next.js Route Handler

Create a Route Handler that receives webhook payloads, verifies the signature, and triggers on-demand revalidation:

TypeScript
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import crypto from 'node:crypto'

// Force Node.js runtime — node:crypto is not available in Edge Runtime (Vercel)
export const runtime = 'nodejs'

const WEBHOOK_SECRET = process.env.LYNKOW_WEBHOOK_SECRET

interface WebhookPayload {
  event: string
  timestamp: string
  data: Record<string, any>
}

export async function POST(request: NextRequest) {
  // 0. Guard: webhook secret must be configured
  if (!WEBHOOK_SECRET) {
    console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
    return NextResponse.json({ error: 'Server misconfigured' }, { status: 500 })
  }

  // 1. Read the raw body for signature verification
  const rawBody = await request.text()

  // 2. Verify the HMAC-SHA256 signature
  const signature = request.headers.get('X-Webhook-Signature')
  if (!signature) {
    return NextResponse.json({ error: 'Missing signature' }, { status: 401 })
  }

  const expectedSignature = `sha256=${crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex')}`

  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )

  if (!isValid) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }

  // 3. Parse the payload
  let payload: WebhookPayload
  try {
    payload = JSON.parse(rawBody)
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  const { event, data } = payload

  // 4. Revalidate based on event type
  const revalidated = handleRevalidation(event, data)

  return NextResponse.json({
    revalidated: true,
    event,
    ...revalidated,
  })
}

Vercel / Edge Runtime: The export const runtime = 'nodejs' line is critical. Without it, Vercel may run your Route Handler in Edge Runtime, where node:crypto is not available. The handler will crash silently with a 500 error and no explicit log.


6. Verifying the HMAC-SHA256 signature

Lynkow signs every webhook payload using HMAC-SHA256. The signature is sent in the X-Webhook-Signature header with the format sha256={hex_digest}.

The signature is computed from the JSON-stringified webhook payload (the entire { event, timestamp, data } object) using the secret you configured.

Always use crypto.timingSafeEqual for comparing signatures. A naive string comparison (===) is vulnerable to timing attacks, where an attacker can guess the secret one character at a time by measuring response times.

TypeScript
import crypto from 'node:crypto'

function verifySignature(rawBody: string, signature: string, secret: string): boolean {
  const expected = `sha256=${crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex')}`

  // Both buffers must have the same length for timingSafeEqual
  if (signature.length !== expected.length) {
    return false
  }

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

Important: You must compare against the raw request body string, not a re-serialized version. JSON serialization is not deterministic (key order, spacing), so JSON.stringify(JSON.parse(body)) may produce a different string than the original.


7. Mapping events to revalidation targets

The core decision is: for each webhook event, what pages or data need to be refreshed?

Path-based revalidation with revalidatePath()

Use revalidatePath() when you know the exact URL of the page that changed:

TypeScript
// Revalidate a specific blog post
revalidatePath(`/blog/${data.slug}`)

// Revalidate the blog listing page
revalidatePath('/blog')

// Revalidate the entire site (use sparingly)
revalidatePath('/', 'layout')

Tag-based revalidation with revalidateTag()

Use revalidateTag() when multiple pages depend on the same data source. This requires that your fetch calls include cache tags:

TypeScript
// In your data fetching code, tag the fetch:
const { data: articles } = await lynkow.contents.list({
  limit: 10,
  fetchOptions: {
    next: { tags: ['articles'] },
  },
})

// In the webhook handler, invalidate by tag:
revalidateTag('articles', 'default')

Next.js 16 breaking change: revalidateTag() now requires a second argument (the cache kind). Use revalidateTag(tag, 'default'). On Next.js 15 and earlier, revalidateTag(tag) works without it.

Tag-based revalidation is cleaner because you do not need to know every page URL that uses a piece of data.


8. Revalidation strategy

Here is a complete mapping from webhook events to revalidation actions:

TypeScript
function handleRevalidation(
  event: string,
  data: Record<string, any>
): { paths: string[]; tags: string[] } {
  const paths: string[] = []
  const tags: string[] = []

  switch (event) {
    // ── Content ──────────────────────────────────────
    case 'content.published':
    case 'content.updated':
    case 'content.archived':
    case 'content.deleted': {
      // Revalidate the individual page
      if (data.path) {
        paths.push(data.path)
      } else if (data.slug) {
        paths.push(`/blog/${data.slug}`)
      }
      // Revalidate listing pages
      paths.push('/blog')
      paths.push('/')
      // Invalidate content cache tags
      tags.push('articles')
      tags.push('latest-articles')
      break
    }

    case 'content.created': {
      // Draft created -- only refresh listing if you show drafts
      // Usually no revalidation needed for drafts
      break
    }

    case 'content.bulk_published':
    case 'content.bulk_archived':
    case 'content.bulk_deleted': {
      // Bulk operations -- revalidate listing pages and content tag
      paths.push('/blog')
      paths.push('/')
      tags.push('articles')
      break
    }

    // ── Categories ───────────────────────────────────
    case 'category.created':
    case 'category.updated':
    case 'category.deleted': {
      if (data.slug) {
        paths.push(`/blog/category/${data.slug}`)
      }
      tags.push('categories')
      // Category changes may affect article listings
      paths.push('/blog')
      break
    }

    // ── Tags ─────────────────────────────────────────
    case 'tag.created':
    case 'tag.updated':
    case 'tag.deleted': {
      tags.push('tags')
      break
    }

    // ── Forms ────────────────────────────────────────
    case 'form.created':
    case 'form.updated':
    case 'form.deleted': {
      tags.push('forms')
      // Revalidate pages that embed this form
      if (data.slug) {
        paths.push(`/contact`) // adjust based on your routing
      }
      break
    }

    case 'form.submitted': {
      // Form submissions typically don't need page revalidation
      // unless you display submission counts publicly
      break
    }

    // ── Reviews ──────────────────────────────────────
    case 'review.created':
    case 'review.updated':
    case 'review.deleted':
    case 'review.bulk_updated':
    case 'review.bulk_deleted': {
      tags.push('reviews')
      // If reviews are displayed on specific pages
      paths.push('/reviews')
      break
    }

    // ── Media ────────────────────────────────────────
    case 'media.uploaded':
    case 'media.updated':
    case 'media.replaced':
    case 'media.deleted': {
      // Media changes usually don't require page revalidation
      // The CDN handles image URL caching separately
      break
    }

    // ── Site blocks (navigation, footer, etc.) ───────
    case 'site_block.published':
    case 'site_block.unpublished':
    case 'site_block.updated':
    case 'site_block.deleted': {
      // Site blocks affect layout-level data (navigation, footer, banners)
      // Revalidate the layout to refresh shared components
      paths.push('/', )
      tags.push('site-blocks')
      tags.push(`site-block-${data.slug}`)
      break
    }

    // ── Redirects ────────────────────────────────────
    case 'redirect.created':
    case 'redirect.updated':
    case 'redirect.deleted': {
      tags.push('redirects')
      break
    }

    default: {
      // Unknown event -- log it but don't fail
      console.log(`[Webhook] Unhandled event: ${event}`)
    }
  }

  // Execute revalidation
  for (const path of paths) {
    revalidatePath(path)
  }
  for (const tag of tags) {
    revalidateTag(tag, 'default')
  }

  return { paths, tags }
}

Adjust the path mappings to match your project's URL structure. The paths above assume a /blog/[slug] pattern, but your routes may differ.


9. Using cache tags with the SDK

To take full advantage of tag-based revalidation, add cache tags to your Lynkow SDK calls using fetchOptions:

TypeScript
// app/blog/page.tsx
import { lynkow } from '@/lib/lynkow'

export default async function BlogPage() {
  const { data: articles } = await lynkow.contents.list({
    limit: 10,
    sort: 'published_at',
    order: 'desc',
    fetchOptions: {
      next: { tags: ['articles', 'latest-articles'] },
    },
  })

  return (
    <main>
      {articles.map((article) => (
        <article key={article.id}>
          <h2>{article.title}</h2>
        </article>
      ))}
    </main>
  )
}
TypeScript
// app/blog/[slug]/page.tsx
import { lynkow } from '@/lib/lynkow'

interface Props {
  params: Promise<{ slug: string }>
}

export default async function ArticlePage({ params }: Props) {
  const { slug } = await params
  const article = await lynkow.contents.getBySlug(slug, {
    fetchOptions: {
      next: { tags: ['articles', `article-${slug}`] },
    },
  })

  return (
    <article>
      <h1>{article.title}</h1>
    </article>
  )
}
TypeScript
// Shared layout data (navigation, footer)
// app/layout.tsx
import { lynkow } from '@/lib/lynkow'

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const navigation = await lynkow.siteBlocks.getBySlug('navigation', {
    fetchOptions: {
      next: { tags: ['site-blocks', 'site-block-navigation'] },
    },
  })

  const footer = await lynkow.siteBlocks.getBySlug('footer', {
    fetchOptions: {
      next: { tags: ['site-blocks', 'site-block-footer'] },
    },
  })

  return (
    <html>
      <body>
        <nav>{/* render navigation data */}</nav>
        <main>{children}</main>
        <footer>{/* render footer data */}</footer>
      </body>
    </html>
  )
}

When a site_block.published event arrives for the "navigation" block, the handler calls revalidateTag('site-block-navigation'), which invalidates every page that included that tag in its fetch -- effectively refreshing the navigation across your entire site without revalidating each page individually.


10. Securing the endpoint

Beyond HMAC verification, add these safeguards:

Reject missing or empty secrets

TypeScript
export async function POST(request: NextRequest) {
  if (!WEBHOOK_SECRET) {
    console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
    return NextResponse.json(
      { error: 'Webhook secret not configured' },
      { status: 500 }
    )
  }

  // ... rest of handler
}

Reject stale timestamps

Prevent replay attacks by rejecting payloads older than 5 minutes:

TypeScript
function isTimestampValid(timestamp: string, toleranceMs = 5 * 60 * 1000): boolean {
  const payloadTime = new Date(timestamp).getTime()
  const now = Date.now()
  return Math.abs(now - payloadTime) < toleranceMs
}

// In the handler, after signature verification:
const payload: WebhookPayload = JSON.parse(rawBody)

if (!isTimestampValid(payload.timestamp)) {
  return NextResponse.json({ error: 'Stale timestamp' }, { status: 401 })
}

Rate limiting

For production, consider adding rate limiting to prevent abuse. A simple approach using a counter:

TypeScript
const recentRequests = new Map<string, number>()
const RATE_LIMIT = 60 // max requests per minute

export async function POST(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown'
  const now = Math.floor(Date.now() / 60000) // minute bucket
  const key = `${ip}-${now}`

  const count = recentRequests.get(key) || 0
  if (count >= RATE_LIMIT) {
    return NextResponse.json({ error: 'Rate limited' }, { status: 429 })
  }
  recentRequests.set(key, count + 1)

  // Clean old entries
  for (const [k] of recentRequests) {
    if (!k.endsWith(`-${now}`)) recentRequests.delete(k)
  }

  // ... rest of handler
}

11. SDK cache and framework caching

The SDK's in-memory cache is disabled by default. Every SDK call goes through fetch(), which your framework (Next.js, Nuxt, Astro) intercepts and manages with its own caching layer. This is the recommended setup for server-side rendering.

No configuration is needed for webhook revalidation to work correctly. When your handler calls revalidatePath() or revalidateTag(), Next.js purges its cache, and the next render fetches fresh data from the Lynkow API.

If you are building a browser-only SPA without framework caching, you can opt into the SDK's localStorage cache:

TypeScript
const lynkow = createClient({
  siteId: 'your-site-uuid',
  cache: true, // Enable localStorage cache for browser SPAs
})

12. Complete webhook handler

Here is the full, production-ready webhook handler combining everything from this guide:

TypeScript
// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'
import crypto from 'node:crypto'

// Force Node.js runtime — node:crypto is not available in Edge Runtime (Vercel)
export const runtime = 'nodejs'

// ── Configuration ──────────────────────────────────
const WEBHOOK_SECRET = process.env.LYNKOW_WEBHOOK_SECRET

interface WebhookPayload {
  event: string
  timestamp: string
  data: Record<string, any>
}

// ── Signature verification ─────────────────────────
function verifySignature(rawBody: string, signature: string | null): boolean {
  if (!signature || !WEBHOOK_SECRET) return false

  const expected = `sha256=${crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex')}`

  if (signature.length !== expected.length) return false

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  )
}

// ── Timestamp validation ───────────────────────────
function isTimestampValid(timestamp: string, toleranceMs = 5 * 60 * 1000): boolean {
  const payloadTime = new Date(timestamp).getTime()
  if (Number.isNaN(payloadTime)) return false
  return Math.abs(Date.now() - payloadTime) < toleranceMs
}

// ── Revalidation logic ─────────────────────────────
function handleRevalidation(
  event: string,
  data: Record<string, any>
): { paths: string[]; tags: string[] } {
  const paths: string[] = []
  const tags: string[] = []

  switch (event) {
    // ── Content ────────────────────────────────────
    case 'content.published':
    case 'content.updated':
    case 'content.archived':
    case 'content.deleted': {
      if (data.path) {
        paths.push(data.path)
      } else if (data.slug) {
        paths.push(`/blog/${data.slug}`)
      }
      paths.push('/blog')
      paths.push('/')
      tags.push('articles')
      tags.push('latest-articles')
      break
    }

    case 'content.translated': {
      if (data.slug) {
        paths.push(`/blog/${data.slug}`)
      }
      paths.push('/blog')
      tags.push('articles')
      break
    }

    case 'content.created': {
      // Drafts don't appear on the public site -- no revalidation needed
      break
    }

    case 'content.bulk_published':
    case 'content.bulk_archived':
    case 'content.bulk_deleted': {
      paths.push('/blog')
      paths.push('/')
      tags.push('articles')
      tags.push('latest-articles')
      break
    }

    // ── Categories ─────────────────────────────────
    case 'category.created':
    case 'category.updated':
    case 'category.deleted':
    case 'category.translated': {
      if (data.slug) {
        paths.push(`/blog/category/${data.slug}`)
      }
      paths.push('/blog')
      tags.push('categories')
      break
    }

    // ── Tags ───────────────────────────────────────
    case 'tag.created':
    case 'tag.updated':
    case 'tag.deleted': {
      tags.push('tags')
      break
    }

    // ── Forms ──────────────────────────────────────
    case 'form.created':
    case 'form.updated':
    case 'form.deleted': {
      tags.push('forms')
      break
    }

    case 'form.submitted': {
      // No page revalidation needed for submissions
      break
    }

    // ── Reviews ────────────────────────────────────
    case 'review.created':
    case 'review.updated':
    case 'review.deleted':
    case 'review.bulk_updated':
    case 'review.bulk_deleted': {
      tags.push('reviews')
      break
    }

    // ── Media ──────────────────────────────────────
    case 'media.uploaded':
    case 'media.updated':
    case 'media.replaced':
    case 'media.trashed':
    case 'media.restored':
    case 'media.deleted': {
      // Media changes are handled by the CDN -- no page revalidation needed
      break
    }

    // ── Site blocks ────────────────────────────────
    case 'site_block.created':
    case 'site_block.updated':
    case 'site_block.published':
    case 'site_block.unpublished':
    case 'site_block.deleted':
    case 'site_block.schema_updated':
    case 'site_block.translation_created': {
      // Site blocks affect layout-level data
      paths.push('/')
      tags.push('site-blocks')
      if (data.slug) {
        tags.push(`site-block-${data.slug}`)
      }
      break
    }

    // ── Redirects ──────────────────────────────────
    case 'redirect.created':
    case 'redirect.updated':
    case 'redirect.deleted':
    case 'redirect.bulk_deleted': {
      tags.push('redirects')
      break
    }

    // ── Sitemap ────────────────────────────────────
    case 'sitemap_entry.created':
    case 'sitemap_entry.updated':
    case 'sitemap_entry.deleted': {
      tags.push('sitemap')
      break
    }

    default: {
      console.log(`[Webhook] Unhandled event: ${event}`)
    }
  }

  // Execute revalidation
  for (const path of paths) {
    try {
      revalidatePath(path)
    } catch (err) {
      console.error(`[Webhook] Failed to revalidate path "${path}":`, err)
    }
  }
  for (const tag of tags) {
    try {
      revalidateTag(tag, 'default')
    } catch (err) {
      console.error(`[Webhook] Failed to revalidate tag "${tag}":`, err)
    }
  }

  return { paths, tags }
}

// ── Route handler ──────────────────────────────────
export async function POST(request: NextRequest) {
  // Guard: webhook secret must be configured
  if (!WEBHOOK_SECRET) {
    console.error('[Webhook] LYNKOW_WEBHOOK_SECRET is not configured')
    return NextResponse.json(
      { error: 'Server misconfigured' },
      { status: 500 }
    )
  }

  // 1. Read raw body
  const rawBody = await request.text()

  // 2. Verify HMAC-SHA256 signature
  const signature = request.headers.get('X-Webhook-Signature')
  if (!verifySignature(rawBody, signature)) {
    console.warn('[Webhook] Invalid or missing signature')
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }

  // 3. Parse payload
  let payload: WebhookPayload
  try {
    payload = JSON.parse(rawBody)
  } catch {
    return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
  }

  // 4. Reject stale payloads (older than 5 minutes)
  if (!isTimestampValid(payload.timestamp)) {
    console.warn('[Webhook] Stale timestamp:', payload.timestamp)
    return NextResponse.json({ error: 'Stale timestamp' }, { status: 401 })
  }

  // 5. Handle revalidation
  const { event, data } = payload
  console.log(`[Webhook] Received event: ${event}`)

  const result = handleRevalidation(event, data)

  console.log(
    `[Webhook] Revalidated ${result.paths.length} paths, ${result.tags.length} tags`
  )

  return NextResponse.json({
    revalidated: true,
    event,
    paths: result.paths,
    tags: result.tags,
  })
}

Putting it all together

Environment setup

env
# .env.local
NEXT_PUBLIC_LYNKOW_SITE_ID=your-site-id
LYNKOW_WEBHOOK_SECRET=your-32-char-hex-secret

File structure

app/
├── api/
│   └── revalidate/
│       └── route.ts          # Webhook handler (this guide)
├── blog/
│   ├── page.tsx              # Blog listing (tagged with 'articles')
│   ├── [slug]/
│   │   └── page.tsx          # Article page (tagged with 'articles')
│   └── category/
│       └── [slug]/
│           └── page.tsx      # Category page (tagged with 'categories')
└── layout.tsx                # Root layout (tagged with 'site-blocks')

How ISR + webhooks work together

Without webhooks, your Next.js site uses time-based ISR (revalidate: 60), meaning pages may be up to 60 seconds stale after a content change.

With webhooks, you get near-instant updates:

  1. Editor publishes an article in Lynkow

  2. Lynkow sends content.published webhook within milliseconds

  3. Your handler calls revalidatePath('/blog/my-article') and revalidateTag('articles')

  4. Next.js marks those cached pages as stale

  5. The next visitor triggers a background regeneration

  6. Subsequent visitors see the updated content

You should keep the time-based revalidate as a safety net. If a webhook is lost (network issue, deployment), the time-based fallback ensures content is never stale for longer than the revalidation interval.

Testing locally

Use a tunneling tool to expose your local Next.js server to the internet:

Bash
# Using ngrok
ngrok http 3000

Then configure the webhook URL in the Lynkow admin dashboard to point to your ngrok URL:

https://abc123.ngrok.io/api/revalidate

Publish or update a piece of content and check your terminal for the webhook logs.

Debugging

If webhooks are not working:

  1. Check webhook logs -- In the Lynkow admin, go to Settings > Webhooks and click on your webhook to see delivery logs with status codes and response bodies

  2. Verify the secret -- Make sure LYNKOW_WEBHOOK_SECRET in your .env.local matches the secret configured in the admin dashboard

  3. Check the endpoint URL -- Ensure it is publicly accessible (not localhost) and uses HTTPS

  4. Check the response -- Your handler must respond within 30 seconds or Lynkow will abort the request

  5. Check event selection -- Make sure the events you need are checked in the webhook configuration

  6. Check the SDK cache -- If you see CF-Cache-Status: MISS and correct webhook logs but content is still stale, make sure you are using cache: false in your createClient() call (see section 11)


Cloudflare CDN and stale HTML pages

Note: The Lynkow API's own Cloudflare cache (api.lynkow.com) is purged automatically when content changes. You do not need to do anything for that layer. This section is only relevant if your Next.js site itself is behind Cloudflare's proxy (orange cloud enabled on your domain).

If your site is behind Cloudflare, there is an additional cache layer: Cloudflare caches the HTML pages served by Next.js. Your webhook handler calls revalidatePath() which purges the Next.js cache, but Cloudflare continues serving its cached HTML copy until it expires.

Diagnosing the issue

Bash
curl -I https://your-site.com/blog/my-article

Header

Value

Meaning

CF-Cache-Status

HIT

Cloudflare served from edge cache (potentially stale)

CF-Cache-Status

MISS

Cloudflare fetched from origin (fresh)

CF-Cache-Status

DYNAMIC

Cloudflare did not cache this response

If you see HIT with a high Age after triggering a webhook, Cloudflare is serving stale HTML.

The simplest approach. Let Cloudflare cache static assets (JS, CSS, images) but always go to origin for HTML:

  1. Go to Caching > Cache Rules in your Cloudflare dashboard

  2. Create a rule:

    • When: URI Path does not match \.(js|css|png|jpg|jpeg|gif|svg|woff2?|ico|webp|avif|woff|ttf)$

    • Then: Cache eligibility: Bypass cache

Your HTML pages always come from Next.js (which has its own cache controlled by revalidate + webhooks), while static assets are still served from Cloudflare's edge.

Option 2: Short Edge TTL for HTML pages

If you want Cloudflare to cache HTML for performance:

  1. Go to Caching > Cache Rules

  2. Create a rule for your HTML pages with Edge TTL: 60 seconds

Content will be at most 60 seconds stale after a change, which is acceptable for most sites.

Option 3: Purge Cloudflare from the webhook handler

For near-instant updates, add a Cloudflare purge call to your webhook handler. Use prefixes (not files) for reliable purging with Cache Rules:

TypeScript
async function purgeCloudflareCache(paths: string[]) {
  const CF_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID
  const CF_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN
  const SITE_HOST = process.env.NEXT_PUBLIC_SITE_HOST // e.g. "www.example.com"

  if (!CF_ZONE_ID || !CF_API_TOKEN || !SITE_HOST) return

  const prefixes = paths.map((path) => `${SITE_HOST}${path}`)

  await fetch(
    `https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${CF_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ prefixes }),
    }
  )
}

Then call it in your revalidation handler after revalidatePath():

TypeScript
await purgeCloudflareCache(result.paths)

Required environment variables:

env
# .env.local
CLOUDFLARE_ZONE_ID=your-zone-id
CLOUDFLARE_API_TOKEN=your-api-token
NEXT_PUBLIC_SITE_HOST=www.example.com

Important: Use prefixes (not files) in the purge request. The files method does not work reliably when Cloudflare Cache Rules force caching. The prefixes method purges all cached responses that start with the given prefix, including query string variations.