Skip to content

Your own JavaScript

For full control, skip the helper and call the REST API yourself. There are two endpoints. First fetch a token, then submit with it.

GET /wp-json/octa-forms/v1/token?form={slug}
POST /wp-json/octa-forms/v1/forms/{slug}/submissions

The {slug} is the form’s slug, and it is a stable contract: it locks after the form is published and will not change. A form in draft status is treated as temporarily disabled, and both endpoints answer 404.

The token combines three jobs: it deduplicates double submits, it acts as a time-trap against instant bot submissions, and it requires JavaScript to obtain. It is not the main anti-spam barrier. The honeypot and rate limiting are. See Anti-spam.

The rules:

  1. Fetch the token on the visitor’s first interaction with the form (for example, focus on the first field). Do not fetch it on every page view.

  2. Send it back in the POST body as token.

  3. Handle these 400 responses, all of which are retriable:

    Code in _token Meaning What to do
    too_fast The token is younger than 3 seconds Wait about 3 seconds and retry once
    token_expired The token is too old (it lives 15 minutes) Fetch a fresh token, wait about 3 seconds, retry once
    invalid The token did not verify Fetch a fresh one, show a message, let the visitor retry
  4. A failed token fetch must never fail silently. Log it and show a clear form error. A security plugin blocking /wp-json/ must not quietly kill the form.

  5. The token endpoint has its own soft per-IP throttle. Under a flood it answers 429 with a Retry-After header. Wait the stated time before fetching again. Do not loop requests.

A form can waive the token with settings.requireToken: false. Then you skip this step entirely. Honeypot and rate limiting still apply.

For a form with no file fields, POST application/json:

const base = '/wp-json/octa-forms/v1/';
const { token } = await (await fetch(base + 'token?form=contact')).json();
// ... at least 3 seconds later (the natural time to fill a form) ...
const res = await fetch(base + 'forms/contact/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fields: { name: 'Jan', email: '[email protected]', consent: true },
meta: { url: location.href },
token,
}),
});

The body has three parts:

  • fields — only the fields defined in the form’s config. Unknown keys are silently dropped.
  • meta — optional. Up to 10 keys, values up to 500 characters, scalars only (things like url, src, utm). Stored with the submission.
  • token — from step 1.

Limits: the body is at most 64 KB, and a single field value is at most 5000 characters unless a max rule says otherwise.

Every client must follow this:

Field type Value in fields
checkbox A boolean. An unchecked box is sent explicitly as false, not omitted.
text, textarea, tel, select, date A string. date is ISO YYYY-MM-DD.
file Do not put it in fields. See file uploads below.

Checkbox values from a native FormData ("on") are also accepted, but a real boolean with an explicit false is the recommended contract.

A form with file fields uses multipart/form-data instead:

POST /wp-json/octa-forms/v1/forms/{slug}/submissions
Content-Type: multipart/form-data; boundary=…
part "payload" → application/json: {fields, meta, token} (identical to the JSON body above)
part "files[<field>]" → the file's contents, for each file field
  • One file per field for now. More than one returns a maxFiles error.
  • The 64 KB body limit applies only to the payload part. Files have their own limits: a hard 10 MB per file, plus your field’s mimes, maxSize and maxFiles rules.
  • The server detects the file type from its contents. The declared type and extension do not matter, and executables are always rejected.
  • File values sent inside payload.fields are ignored. File references are created server-side only.
  • application/x-www-form-urlencoded is rejected. Do not send raw FormData without the payload envelope.
{ "ok": true, "id": 123, "redirectUrl": "..." }

or

{ "ok": true, "id": 123, "thankYouTitle": "...", "thankYouText": "..." }

redirectUrl takes priority. The thank-you fields are plain text, no HTML.

A double POST with the same token and fields is idempotent: it returns 200 with the same id.

Status Key Meaning
400 _token: [code] See the token contract above
400 field: { rule: message } Per-field validation errors
400 field: { mimes/maxSize/maxFiles/file_required: message } Per-field file errors
400 _request: [code] body_too_large, meta_invalid, unsupported_media_type, payload_invalid, upload_failed
404 _form: ["not_found"] Unknown or inactive slug
429 _ban: ["retry"] + Retry-After Too many successful submits from one network. Retry after the stated time
429 _ban: ["banned"] A flood from one IP. Temporary ban
429 _ban: ["rate_limited"] + Retry-After The token endpoint’s own throttle (GET /token). Wait the stated time before fetching again
500 _request: ["storage_failed"] A server-side write error. Retry later

The reserved keys are _form, _token, _ban and _request. Anything else is a field name.

If the form has a honeypot field, render it as a real input, hidden with CSS, never type="hidden". See Anti-spam for exactly how, and for what happens when one is tripped (the response looks like success, so the bot learns nothing).