Skip to content
Gaurav TiwariDocs

Headless Forms

Version 4.15.1 Updated November 7, 2026

Headless mode lets a site that doesn’t run WordPress show a Core Forms form and send its submissions back to WordPress. Use it for a static HTML site, an Astro or Next.js build, or a React, Vue or Svelte app. You build the form in Core Forms as usual, and every submission runs through the same validation, spam checks, storage and actions as a form on a WordPress page.

Watch: headless forms on any website

Enable Headless mode, use the drop-in embed on HTML, Astro, React, Vue, or Svelte, and verify the submission in WordPress.

There are two ways to connect:

  • Drop-in embed: paste a <div> and one <script> tag. The script loads the form’s HTML from WordPress, shows it, and posts submissions back.
  • Native fetch: request the form’s schema yourself, render your own markup, and post the answers as JSON.

Before you start

Headless access needs a site-wide API key and a per-form switch.

Headless tab of a form with the headless switch, drop-in embed, native fetch and cURL sections
  1. Go to Core Forms > Settings and open the Headless / REST section.
  2. Click Generate next to API key to create a random key (it starts with cf_), then save the settings.
  3. In Allowed origins, enter each site that will embed your forms, one per line, as scheme, host and optional port. For example https://example.com or http://localhost:4321.
  4. Save the settings.
  5. Edit the form, open its Headless tab, turn on Headless mode, and click Save Form.

The Headless tab shows a status banner. It warns you when the API key isn’t set, or when the form is enabled but neither reCAPTCHA nor Turnstile is configured. Once both are in place it shows Ready to embed, and its snippets contain your real key and URLs.

Drop-in embed

Copy the snippet from the form’s Headless tab. It looks like this:

<div data-cf-form="contact" data-cf-key="YOUR_SITE_API_KEY"></div>
<script src="https://your-wordpress-site.com/wp-json/core-forms/v1/embed.js" defer></script>
  • data-cf-form takes the form’s slug or numeric ID.
  • data-cf-key is the site API key.
  • embed.js mounts every [data-cf-form] element on the page once the document is ready.

When a visitor submits the form, the script posts the answers as JSON with the API key in the X-Core-Forms-Key header. It also sends the page URL, so [CF_REFERRER_URL] and the other referrer variables point at the page the visitor was on, not at the WordPress REST endpoint.

After a successful submission the script replaces the form with the form’s success message, or follows the form’s redirect URL. If the submission fails, it shows the error message above the form.

Events

The script fires these events on the host <div>. They bubble, so you can listen on a parent element too.

Event When it fires event.detail
cf:mounted The form HTML is in place The form schema
cf:submit The visitor submits { form }, the <form> element
cf:success The submission was accepted The response body
cf:error Loading or submitting failed The error or response body
document.querySelector('[data-cf-form]').addEventListener('cf:success', (event) => {
  console.log('Submitted', event.detail);
});

Plain HTML

Paste the snippet where the form should appear. Nothing else is needed.

Astro

Paste the snippet into a component or page. Add is:inline so Astro leaves the external script alone:

<div data-cf-form="contact" data-cf-key={import.meta.env.PUBLIC_CF_KEY}></div>
<script is:inline src="https://your-wordpress-site.com/wp-json/core-forms/v1/embed.js" defer></script>

React, Vue and Svelte

In a single-page app the form’s element often appears after the page has loaded, so mount it yourself. Load embed.js once (for example in index.html), then call window.CoreFormsEmbed.mount() when the element is in the DOM. The script also exposes window.CoreFormsEmbed.init(), which mounts every element that hasn’t been mounted yet.

React:

import { useEffect, useRef } from 'react';

export function ContactForm() {
  const ref = useRef(null);
  useEffect(() => {
    window.CoreFormsEmbed?.mount(ref.current);
  }, []);
  return <div ref={ref} data-cf-form="contact" data-cf-key={import.meta.env.VITE_CF_KEY} />;
}

Vue:

<script setup>
import { onMounted, ref } from 'vue';
const host = ref(null);
onMounted(() => window.CoreFormsEmbed?.mount(host.value));
</script>

<template>
  <div ref="host" data-cf-form="contact" :data-cf-key="key"></div>
</template>

Svelte:

<script>
  import { onMount } from 'svelte';
  let host;
  onMount(() => window.CoreFormsEmbed?.mount(host));
</script>

<div bind:this={host} data-cf-form="contact" data-cf-key={key}></div>

Don’t render children inside the host element. The script replaces its contents.

Native fetch

Use this route when you want to render the form with your own components.

  1. Request the schema with GET /wp-json/core-forms/v1/forms/{id-or-slug}. This endpoint is public.
  2. Render your fields from the response.
  3. Post the answers as JSON to POST /wp-json/core-forms/v1/forms/{id}/submit with the X-Core-Forms-Key header.

The schema response contains:

  • id, slug and title
  • html: the complete rendered form
  • fields: each field’s name, type, required, label and options
  • messages: the form’s messages
  • submit_url: the submit endpoint
  • requires_captcha: true when a reCAPTCHA or Turnstile site key is saved
const res = await fetch('https://your-wordpress-site.com/wp-json/core-forms/v1/forms/42');
const form = await res.json();

await fetch(form.submit_url, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Core-Forms-Key': 'YOUR_SITE_API_KEY',
  },
  body: JSON.stringify({
    name: 'Ada',
    email: 'ada@example.com',
    _cf_h42: '',                      // honeypot: optional, must be empty if sent
    _cf_page_url: window.location.href, // optional: fills [CF_REFERRER_URL]
  }),
});

The honeypot field

Every Core Forms form has a hidden honeypot field named _cf_h followed by the form ID, for example _cf_h42. The drop-in embed sends it for you because it’s part of the form HTML.

With your own markup you can leave it out: since 4.15.1 the endpoint adds an empty one for you, because the API key already identifies your app. If you do send it, keep it empty. A filled honeypot is treated as a bot, so the submission isn’t saved and runs no actions, while the endpoint still answers with a generic success message.

Before 4.15.1, a submission without the honeypot field was silently dropped in the same way. Update the plugin if your app sends only its own fields.

Array fields

For checkboxes and other multi-value fields, send an array. Keys ending in [], such as "topics[]", are accepted too and are treated the same as "topics".

Responses

Status Meaning
200 The submission was accepted. The body carries the processor’s message object, and redirect_url when the form redirects.
422 Validation or processing failed. The body carries the error message.
401 The X-Core-Forms-Key header is missing or wrong.
403 The form isn’t enabled for headless use.
404 No form matches the ID or slug.
503 No API key is set on the site.

The message is an object such as { "type": "success", "text": "Thanks!" }, not a plain string.

How submissions land in WordPress

The submit endpoint feeds the request into the same processor as a form on a WordPress page. That means:

  • Required fields, email fields, calculations, conditional logic and submission limits are checked as usual.
  • The honeypot, the global blocklist, reCAPTCHA, Turnstile and Akismet all run.
  • The entry is saved when the form saves submissions, and appears under the form’s Submissions tab and on All Submissions.
  • Every enabled action runs: emails, webhooks, integrations and payments.
  • With draft publishing on, headless requests use the published form, not the draft.

WordPress nonces don’t work across origins. The submit endpoint creates its own nonce on the server after checking the API key, so forms keep working when Enable Nonce is on.

Allowed origins and CORS

The REST routes send CORS headers so browsers on other sites can call them. No cookies are sent or accepted.

  • With Allowed origins filled in, only requests from a listed origin get an Access-Control-Allow-Origin header. The browser blocks every other origin.
  • With Allowed origins empty, Core Forms reflects whatever origin the browser sent. That’s handy while you set things up, but add your origins before you go live.

CORS is a browser rule. It doesn’t stop a server or script from calling the endpoint directly, so it doesn’t replace the API key or a captcha.

Keeping the API key private

The key only lets someone submit to forms you’ve marked as headless. A leaked key can bring spam to those forms, but it gives no account access and can’t read entries.

To keep it out of the browser, post to a route on your own server and add the header there. The Headless tab has ready-to-copy proxy examples for Cloudflare Workers or Pages Functions, Next.js (App Router) and Astro server endpoints. Keep the key in a server-only environment variable, never in a PUBLIC_ or NEXT_PUBLIC_ one.

Rotate the key any time from Settings > Headless / REST > Generate. Every existing client stops working as soon as you save the new key.

Spam protection

Configure reCAPTCHA or Turnstile under Core Forms > Settings before you publish a headless form. Once reCAPTCHA keys are saved, every submission must carry a valid token in g-recaptcha-response. Turnstile is checked on forms where it’s turned on, and expects its token in cf-turnstile-response. A missing or invalid token rejects the submission.

The schema’s requires_captcha flag is true whenever a reCAPTCHA or Turnstile site key is saved, even if Turnstile is off for this particular form.

The drop-in embed inserts the form HTML with innerHTML, which doesn’t run scripts inside that HTML. If you use a captcha with the drop-in embed or your own markup, load the provider’s script on your site and make sure the token is part of the submission. Test a real submission before going live.

Endpoint reference

Method Route Auth
GET /wp-json/core-forms/v1/forms/{id-or-slug} Public
POST /wp-json/core-forms/v1/forms/{id-or-slug}/submit X-Core-Forms-Key header
GET /wp-json/core-forms/v1/embed.js Public

The key is accepted only in the header, never as a query parameter.

Share

Was this helpful?

Still stuck? Open a support ticket