N
HeadlessCoreHeadless Core
Back to Home

API Routes

Next.js API routes that bridge WordPress and the frontend cache layer.

POST /api/revalidate

On-demand cache invalidation triggered by WordPress when content is saved.

Request

POST /api/revalidate
Content-Type: application/json

{
  "secret": "your-revalidation-secret",
  "path": "/blog/my-post-slug",
  "tag": "post"
}
Field Required Description
secret Yes Must match REVALIDATION_SECRET in Next.js and WordPress admin
path No Specific path to revalidate via revalidatePath()
tag No Cache tag to invalidate via revalidateTag(tag, "max")

Response (success)

{
  "revalidated": true,
  "items": ["tag:post", "path:/blog/my-post-slug", "path:/blog"],
  "timestamp": 1710000000000
}

Response (error)

Status Reason
401 Invalid or missing secret
429 Rate limit exceeded (30 requests/minute per IP)
500 Malformed JSON or server error

WordPress trigger

Configured in functions.php on the save_post hook. Requires:

  1. headlesscore_revalidation_secret set in WP admin
  2. headlesscore_nextjs_url set to your frontend URL (e.g. https://yoursite.com)

Rate limiting

Uses in-memory sliding window (src/lib/rateLimit.ts):

  • Limit: 30 requests per minute per IP
  • Scope: Per Node.js process (not distributed across serverless instances)

For high-traffic multi-instance deployments, configure Upstash Redis — see Security Guide.


Secret comparison

Revalidation, preview, and search indexing secrets are compared with constant-time secureCompare() (SHA-256 + timingSafeEqual) to reduce timing side-channels.


GET /api/preview

Enables Next.js draft mode and redirects to the preview URL.

Request

GET /api/preview?secret=YOUR_PREVIEW_SECRET&slug=my-draft-post&type=post
Param Required Description
secret Yes Must match PREVIEW_SECRET
slug Yes Post or page slug
type No post (default) or page

Behaviour

  1. Validates secret against PREVIEW_SECRET env var
  2. Calls draftMode().enable()
  3. Redirects to /blog/{slug} or /{slug}

WordPress integration

The Preview in Next.js link in the post editor is filtered via preview_post_link to point to this route.

Draft content fetching

When draft mode is enabled, graphql.ts functions:

  • Set cache: "no-store" on fetch
  • Pass Authorization: Bearer {WORDPRESS_AUTH_TOKEN} header
  • Query without status: PUBLISH filter

Generate a JWT token via WPGraphQL login mutation or the JWT Authentication plugin settings.


GET /api/exit-preview

Disables draft mode and redirects to the homepage.

GET /api/exit-preview

Used by the yellow preview banner on draft pages.


POST /api/contact-block / /api/newsletter-block

Backing endpoints for the headlesscore/contact-form and headlesscore/newsletter-signup Gutenberg blocks (assets/blocks/pro-blocks.js). Those blocks render as static HTML injected via BlockRenderer's dangerouslySetInnerHTML, so their inline JS can't call a Server Action directly — it POSTs here instead (same-origin relative fetch, no CORS needed), and each route just proxies to the existing submitContactForm/ subscribeNewsletter functions in graphql.ts — the same ones the dedicated /contact page and actions.ts already use. No new validation logic, no new mutations.

POST /api/contact-block
Content-Type: application/json

{ "name": "Jane", "email": "jane@example.com", "message": "Hi!" }
POST /api/newsletter-block
Content-Type: application/json

{ "email": "jane@example.com" }

Both return { success, error }, 400 on missing required fields.


POST /api/stripe-webhook

Optional — mints and delivers a license key when a Stripe Payment Link purchase completes. See Stripe Licensing for full setup. Returns 501 if STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET aren't set (inert by default).

Request

Sent by Stripe itself, not called directly. Requires the raw request body (read via request.text(), not .json()) plus a stripe-signature header, verified with stripe.webhooks.constructEvent().

Behaviour

On checkout.session.completed:

  1. Generates a license key (generateLicenseKey(), src/lib/license.ts) using LICENSE_SIGNING_PRIVATE_KEY_PEM.
  2. Writes it into the Checkout Session's own metadata (no database needed — /license-success reads it back via session_id).
  3. Emails it via WordPress's deliverLicenseKey GraphQL mutation (best-effort — a WP-side failure here doesn't fail the webhook response; the success page is the fallback delivery path).

Always returns 200 to Stripe once the signature is verified, even if the email delivery step fails (logged, not surfaced to Stripe — a 4xx/5xx here makes Stripe retry the whole webhook, which isn't useful once the key has already been generated).

No rate limiting (deliberate)

Unlike /api/revalidate and /api/preview, this route does not call checkRateLimit(). Requests originate from Stripe's own IPs, and the HMAC signature check is already the auth boundary — IP-based limiting adds nothing here and risks rejecting legitimate Stripe webhook retries.


GET/POST /api/search

Optional — see Pluggable Search for full setup. GET is the public search endpoint (?q=); POST is the shared-secret-gated indexing push from WordPress (save_post hook + "Reindex All Content" button). Returns 501 on both methods when SEARCH_PROVIDER is unset.


Environment variables

Variable Route Description
REVALIDATION_SECRET /api/revalidate Shared secret with WordPress
PREVIEW_SECRET /api/preview Shared secret with WordPress
WORDPRESS_AUTH_TOKEN Preview fetches JWT for draft GraphQL queries
STRIPE_SECRET_KEY /api/stripe-webhook, /license-success Stripe API calls
STRIPE_WEBHOOK_SECRET /api/stripe-webhook Verifies the stripe-signature header
LICENSE_SIGNING_PRIVATE_KEY_PEM /api/stripe-webhook Signs new keys — see the security note in Licensing
LICENSE_DELIVERY_SECRET /api/stripe-webhook Matches WP's headlesscore_license_delivery_secret
SEARCH_PROVIDER /api/search "meilisearch" | "algolia" | unset — see Pluggable Search
SEARCH_INDEX_WEBHOOK_SECRET /api/search (POST) Matches WP's headlesscore_search_index_secret

All secrets are server-only — never prefix with NEXT_PUBLIC_.


Testing revalidation manually

curl -X POST http://localhost:3000/api/revalidate \
  -H "Content-Type: application/json" \
  -d '{"secret":"your-secret","path":"/blog","tag":"posts"}'

Expected: {"revalidated":true,"items":[...]}


Security notes

  • Always use HTTPS in production
  • Generate secrets with openssl rand -base64 32
  • Never commit secrets to git (use .env.local)
  • Rate limiting mitigates brute-force secret guessing but is not a substitute for strong secrets