14+ years building on WordPress / Replies in under 5 hours
Headless WordPress 11 min read · Updated July 2026

WordPress Webhooks for Headless Next.js: On-Demand Revalidation Guide

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Real-Time Sync: Using Webhooks for Headless WordPress & Next.js
TL;DR

In a headless WordPress setup, Next.js serves pre-rendered HTML from a CDN — fast, but stale until the cache is cleared. WordPress webhooks close that gap: when a post is saved, WordPress fires an HTTP POST to a Next.js API endpoint, which calls revalidatePath (App Router) or res.revalidate (Pages Router) for just that URL, making the page fresh within seconds without a full site rebuild. Secure the endpoint with HMAC-SHA256 payload signing on the WordPress side and crypto.timingSafeEqual verification on the Next.js side — a plain secret token in a header is not enough, because it doesn't protect against payload tampering. Test the full pipeline locally with ngrok before deploying to confirm the 200 response end-to-end. For production reliability, add wp_schedule_single_event retry logic on the WordPress side so a temporarily unreachable frontend doesn't silently leave pages stale.

You publish a post in WordPress. Five seconds later, a client visits your Next.js site and still sees the old version. That delay is the central frustration of headless WordPress — and webhooks are what closes it.

In a traditional WordPress setup, there is no delay: a page request hits PHP, which reads the database and returns fresh HTML. In a headless setup, Next.js serves a pre-rendered HTML file from a CDN. That file is fast, but it does not automatically update when you change content in WordPress. Webhooks are the bridge — they let WordPress notify your frontend the instant a post changes, so Next.js can discard its cached version and regenerate just that page.

This guide covers the complete implementation: the WordPress side (triggering the webhook on save_post), the Next.js side (Pages Router and App Router), HMAC security to verify the payload came from your server, and how to test the whole pipeline locally with ngrok before deploying.

For the broader headless architecture context — including how the REST API exposes your WordPress content to Next.js — the REST API vs GraphQL comparison is the right starting point. This guide assumes that data-fetching layer is already in place.

Why Headless Content Goes Stale (and Why Polling Doesn’t Fix It)

Next.js Incremental Static Regeneration (ISR) solves part of the staleness problem: you set a revalidate interval in getStaticProps, and Next.js regenerates the page in the background after that interval passes. But ISR is time-based — a page set to revalidate every 60 seconds can show content up to 60 seconds old. For most editorial sites, that’s fine. For a store where a product just went out of stock, or a news site with a correction, it’s too slow.

Build hooks — where WordPress pings a Vercel or Netlify build URL on every save — solve the staleness problem but create a scaling one. A full site rebuild regenerates every page, even the ones that didn’t change. On a site with 500+ posts, a single typo fix can trigger a five-minute rebuild just to update one paragraph.

On-demand revalidation, introduced in Next.js 12.2, is the right answer for most headless WordPress sites: WordPress fires a webhook when a specific post is saved, Next.js receives it, calls revalidatePath or res.revalidate for just that URL, and the page is fresh within seconds — no full rebuild, no 60-second lag.

Strategy Freshness Build cost Complexity Best for
Full rebuild (SSG) Instant after build Entire site Low Sites with <50 pages that rarely update
Time-based ISR After interval (30s–5min) Per-page, lazy Medium Blogs where a 60-second lag is acceptable
On-demand revalidation Seconds after publish One page only Higher (initial setup) Any site needing near-real-time updates

Step 1 — WordPress Side: Fire a Webhook on Post Save

WordPress fires the save_post action every time a post is saved, whether from the block editor, classic editor, the REST API, or a bulk update. Hook into it in functions.php (or a small site-specific plugin) to send an HTTP POST to your Next.js revalidation endpoint.

The function below fires on save, skips revisions and autosaves, checks that the post is published, then sends the post slug to Next.js along with an HMAC-SHA256 signature. The signature is how Next.js verifies the request actually came from your WordPress server, not from an attacker who found the endpoint URL.

// functions.php

add_action( 'save_post', 'ak_notify_nextjs_on_save', 10, 3 );

function ak_notify_nextjs_on_save( $post_id, $post, $update ) {
    // Skip revisions, autosaves, and non-published posts
    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
        return;
    }
    if ( $post->post_status !== 'publish' ) {
        return;
    }

    $frontend_url  = 'https://yoursite.com/api/revalidate';
    $secret_token  = getenv( 'NEXTJS_REVALIDATION_SECRET' );
    $slug          = $post->post_name;

    // HMAC-SHA256 signature so Next.js can verify the payload came from WP
    $payload   = json_encode( [ 'slug' => $slug ] );
    $signature = hash_hmac( 'sha256', $payload, $secret_token );

    wp_remote_post( $frontend_url, [
        'timeout'     => 5,
        'headers'     => [
            'Content-Type'    => 'application/json',
            'x-wp-signature'  => $signature,
            'x-reval-secret'  => $secret_token,
        ],
        'body'        => $payload,
        'data_format' => 'body',
    ] );
}

Store NEXTJS_REVALIDATION_SECRET as a server environment variable — not hardcoded in functions.php. On most managed hosts (Kinsta, WP Engine, Cloudways), you set environment variables in the hosting dashboard. On a VPS, add it to the server’s shell environment or use a .htaccess SetEnv directive. A value generated with openssl rand -hex 32 gives you a 64-character cryptographically random secret.

If you prefer a plugin over custom code, WP Webhooks provides a UI for configuring outbound webhooks without touching functions.php. For more granular control over which endpoints fire and when, the custom REST endpoints guide covers registering dedicated webhook dispatch endpoints via the WordPress REST API.

Step 2a — Next.js Pages Router: The /api/revalidate Endpoint

In the Pages Router (Next.js 12 and earlier, or any project still on pages/), the revalidation handler lives in pages/api/revalidate.js. It verifies the secret, verifies the HMAC signature, and calls res.revalidate() for the changed path.

// pages/api/revalidate.js
import crypto from 'crypto';

export default async function handler(req, res) {
    if (req.method !== 'POST') {
        return res.status(405).json({ message: 'Method not allowed' });
    }

    const secret    = process.env.NEXTJS_REVALIDATION_SECRET;
    const tokenSent = req.headers['x-reval-secret'];

    // Constant-time comparison to prevent timing attacks
    if (!crypto.timingSafeEqual(Buffer.from(tokenSent), Buffer.from(secret))) {
        return res.status(401).json({ message: 'Unauthorized' });
    }

    // HMAC signature verification
    const body      = JSON.stringify(req.body);
    const expected  = crypto.createHmac('sha256', secret).update(body).digest('hex');
    const received  = req.headers['x-wp-signature'] ?? '';

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
        return res.status(401).json({ message: 'Invalid signature' });
    }

    try {
        const { slug } = req.body;
        await res.revalidate(`/posts/${slug}`);
        return res.json({ revalidated: true, slug });
    } catch (err) {
        console.error('[revalidate]', err);
        return res.status(500).json({ message: 'Error revalidating' });
    }
}

Two security checks run before any revalidation: a constant-time secret comparison (using crypto.timingSafeEqual to prevent timing attacks) and an HMAC signature check. Both must pass. A failed check returns a 401 without revealing which check failed.

Add your shared secret to .env.local on the Next.js side:

# .env.local  (Next.js)
NEXTJS_REVALIDATION_SECRET=your-long-random-secret-here

# On WordPress: set as server environment variable (not hardcoded in functions.php)
# cPanel → Software → PHP Config → Environment Variables, or:
putenv('NEXTJS_REVALIDATION_SECRET=your-long-random-secret-here');

Step 2b — Next.js App Router (Next.js 13+): The Route Handler Version

The App Router uses Route Handlers instead of API routes. The file becomes app/api/revalidate/route.js and res.revalidate() is replaced with revalidatePath imported from next/cache.

// app/api/revalidate/route.js  (Next.js 13+ App Router)
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import crypto from 'crypto';

export async function POST(request) {
    const secret    = process.env.NEXTJS_REVALIDATION_SECRET;
    const tokenSent = request.headers.get('x-reval-secret') ?? '';

    if (!crypto.timingSafeEqual(Buffer.from(tokenSent), Buffer.from(secret))) {
        return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
    }

    // Read body as text FIRST - calling request.json() would consume the stream
    const body      = await request.text();
    const expected  = crypto.createHmac('sha256', secret).update(body).digest('hex');
    const received  = request.headers.get('x-wp-signature') ?? '';

    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
        return NextResponse.json({ message: 'Invalid signature' }, { status: 401 });
    }

    const { slug } = JSON.parse(body);
    revalidatePath(`/posts/${slug}`);
    return NextResponse.json({ revalidated: true, slug });
}

The critical difference from the Pages Router version: the App Router’s request object is a standard Web API Request, and its body is a readable stream that can only be consumed once. Call request.text() to read it as a string first — use that string for HMAC verification, then call JSON.parse(body) to get the slug. If you call request.json() first, the stream is consumed and HMAC verification fails every time.

For sites already tuned for Core Web Vitals, the Next.js headless performance guide covers how on-demand revalidation integrates with your full caching strategy without introducing LCP regressions.

Security: Why HMAC Matters More Than a Secret Token Alone

A secret token in a header proves the requester knows the secret — but it doesn’t prove the payload body wasn’t tampered with in transit. HMAC-SHA256 signs the body with the secret, so Next.js can verify both that the request came from your WordPress server and that the payload arrived intact.

Three security layers combined:

  • Constant-time comparisoncrypto.timingSafeEqual prevents timing attacks where an attacker guesses the secret one character at a time by measuring response time differences
  • HMAC signature — signs the JSON body; a modified payload produces a different hash that won’t match, making payload injection impossible
  • Environment variables — the secret never appears in source code or git history on either the WordPress or Next.js side

IP whitelisting (only accepting requests from your WordPress server’s IP) is an optional fourth layer. It’s worth adding to your Vercel Edge Config or Next.js middleware if your WordPress host has a stable IP — a determined attacker with the HMAC secret still can’t spoof the source IP.

The JWT authentication guide covers a related security pattern — securing the data-fetching layer with signed tokens — if you want consistent auth patterns across your whole headless stack rather than bespoke secrets per endpoint.

Testing Locally with ngrok

WordPress running on your production server can’t reach localhost:3000 on your laptop. ngrok solves this by creating a public HTTPS tunnel to your local Next.js dev server, so you can test the full webhook pipeline end-to-end before deploying.

Install ngrok, start your Next.js dev server, then run:

ngrok http 3000

ngrok prints a forwarding URL like https://abc123.ngrok.io. Temporarily replace the $frontend_url in functions.php with that URL, save a test post in WordPress, then open the ngrok inspector at http://localhost:4040 — it shows the raw HTTP request and response, including all headers and the full body, making it straightforward to debug HMAC mismatches or wrong payload shapes.

Once the inspector shows a 200 response with "revalidated": true, swap the URL back to your production Vercel endpoint and deploy. The CI/CD pipeline guide covers how to wire environment secrets into your deployment pipeline so NEXTJS_REVALIDATION_SECRET is set correctly in production without manual steps.

Error Handling and Retry Logic

The wp_remote_post call uses a 5-second timeout. If the Next.js endpoint is slow or unreachable, WordPress doesn’t wait — the post save completes normally and the failed request is dropped. That’s intentional: a slow frontend should never block a content editor from saving their work.

For production reliability, add retry logic on the WordPress side:

  • On a non-200 response, schedule a retry with wp_schedule_single_event() using a 30-second delay
  • Cap at 3 retry attempts to avoid cascading load if the frontend is down for an extended period
  • Log failures to wp-content/debug.log (requires WP_DEBUG_LOG enabled) so you can identify which pages might be showing stale content after a frontend outage

On the Next.js side, revalidatePath is synchronous within the route handler — it doesn’t return until the cache entry is invalidated. Wrap it in a try/catch and return a 500 on failure so WordPress knows a retry is needed. Any unhandled error that returns a 200 will prevent WordPress from retrying, leaving the cache stale with no log entry.

Implementation Checklist

  1. Generate the shared secret: openssl rand -hex 32
  2. Store it as NEXTJS_REVALIDATION_SECRET in both your WordPress server environment and your Next.js .env.local (or Vercel environment variables)
  3. Add the save_post hook to functions.php with HMAC-SHA256 signing
  4. Create the revalidation endpoint: pages/api/revalidate.js (Pages Router) or app/api/revalidate/route.js (App Router)
  5. Use crypto.timingSafeEqual for all secret comparisons — not string equality
  6. In the App Router version: read the body with request.text() before any JSON parsing
  7. Test locally with ngrok — confirm a 200 response in the ngrok inspector at localhost:4040
  8. Deploy to staging; save a post in WordPress and confirm the Next.js staging page updates within 5 seconds
  9. Add wp_schedule_single_event() retry logic for non-200 responses
  10. Enable WP_DEBUG_LOG and monitor wp-content/debug.log for failed webhook deliveries in the first week after launch

Once webhooks are in place, the headless stack handles the complete content lifecycle: WordPress as the authoring environment, the REST API or GraphQL delivering structured content to Next.js, and webhooks keeping the frontend cache in sync within seconds of a publish. The headless WordPress pros, cons and use cases guide covers where this architecture makes sense versus a traditional coupled setup — useful context if you’re still evaluating the approach. For the SEO implications of running headless, the headless WordPress SEO guide covers the five gaps that need closing to avoid ranking problems specific to decoupled setups.

Frequently asked questions

A WordPress webhook is an HTTP POST request that WordPress sends automatically to another URL — typically your Next.js frontend — when a specific event occurs, like a post being saved or published. In a headless architecture where Next.js serves pre-rendered pages from a CDN, the webhook is the signal that tells the frontend 'this page just changed, discard your cached version and regenerate it.' Without it, Next.js has no way to know content changed in WordPress until either a full site rebuild runs or a time-based ISR revalidation interval expires.

Use on-demand revalidation (triggered by WordPress webhooks) when freshness matters — news sites, WooCommerce stores with live inventory, or any site where stale content after a publish causes a real problem. Use time-based ISR when approximate freshness is fine (a blog post that rarely changes can revalidate every 5 minutes without issue). Avoid full builds for anything over 50 pages — rebuilding the entire site for a single content change is inefficient and slow. In practice, most production headless WordPress sites combine on-demand revalidation (for content pages) with a moderate ISR fallback (for edge cases where the webhook fails or a page was never explicitly revalidated).

Three layers: a shared secret, an HMAC signature, and constant-time comparison. The shared secret (stored as an environment variable on both servers) proves the requester knows it. The HMAC-SHA256 signature, computed from the request body using that secret, proves the payload arrived unchanged and came from your WordPress server specifically — a plain secret token in a header doesn't protect against body tampering. Always use crypto.timingSafeEqual for comparisons — regular string equality is vulnerable to timing attacks. Optional fourth layer: IP whitelisting, accepting requests only from your WordPress server's IP address.

Use ngrok. Run your Next.js dev server on localhost:3000, then run ngrok http 3000 in a separate terminal to get a public HTTPS forwarding URL (e.g. https://abc123.ngrok.io). Temporarily replace the $frontend_url in functions.php with that ngrok URL, save a test post in WordPress, then open the ngrok inspector at localhost:4040. It shows the full HTTP request and response — headers, body, status code — making it easy to debug HMAC mismatches or wrong payload shapes before the endpoint is publicly deployed. Once the inspector shows a 200 with revalidated: true, swap back to your real production URL.

In the Pages Router, create pages/api/revalidate.js and call res.revalidate('/posts/slug') — res.revalidate is a method Next.js adds to the response object. In the App Router (Next.js 13+), create app/api/revalidate/route.js and call revalidatePath('/posts/slug') imported from next/cache. The key gotcha with App Router: the Request body is a readable stream that can only be consumed once. Call request.text() to read it as a string first, use that string for HMAC verification, then call JSON.parse(body) to extract the slug. Calling request.json() first consumes the stream, leaving nothing for signature verification.

By default, wp_remote_post times out after a few seconds and the failure is silently dropped — the post saves normally in WordPress, but the Next.js cache isn't cleared and the page stays stale. To handle this in production: on a non-200 response, use wp_schedule_single_event() to retry after 30 seconds, and cap retries at 3 attempts to avoid hammering the endpoint if the frontend is down for an extended period. Log failures to wp-content/debug.log (requires WP_DEBUG_LOG enabled in wp-config.php) so you have an audit trail of which pages might be showing stale content.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →