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:
headlesscore_revalidation_secretset in WP adminheadlesscore_nextjs_urlset 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
- Validates
secretagainstPREVIEW_SECRETenv var - Calls
draftMode().enable() - 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: PUBLISHfilter
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:
- Generates a license key (
generateLicenseKey(),src/lib/license.ts) usingLICENSE_SIGNING_PRIVATE_KEY_PEM. - Writes it into the Checkout Session's own
metadata(no database needed —/license-successreads it back viasession_id). - Emails it via WordPress's
deliverLicenseKeyGraphQL 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