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: 'jan@example.com', 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. The plugin’s own keys user_path, referrer, source and campaign do not count toward the 10. See Visit path and Lead source.
  • 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. On a field with numeric or integer, max is the largest number and leaves the 5000-character limit alone.

Every client must follow this:

Field type Value in fields
checkbox A boolean. An unchecked box is sent explicitly as false, not omitted.
checkboxes A JSON list of the ticked option values, for example ["PIT-11", "IFT-1"]. Nothing ticked is an empty list [].
text, textarea, email, tel, number, select, radio, date A string. date is ISO YYYY-MM-DD. An unanswered radio may be left out.
file Do not put it in fields. See file uploads below.

A real boolean with an explicit false is the recommended contract. Other checkbox values are read like this, ignoring case and spaces at the ends. false, 0, "0", "false", "", null, "no", "nie", "off" and "n" mean not ticked. Every other text or number means ticked, because a native FormData sends a box’s value ("on" by default) only when it is ticked. A text such as "yes" still counts as ticked but leaves a warning in the debug log, with the field name and the form id only.

Every other value in fields must be a single scalar. A checkboxes field is the one exception: its value is a list, with at most one item per option. The server stores the answer in the order the options are declared and drops duplicates, so the order you send does not matter. A single string such as "PIT-11" is read as a list with one item. Any other shape (an object, nested lists, more items than options) is rejected with an invalid error on that field. An array value for any other non-file field is rejected the same way.

Fields hidden by a showIf condition can be left out. If you send them anyway, the server drops their values: it decides on its own which fields the answers make visible.

A form shown as steps (settings.layout set to "steps") can have each step checked before the visitor moves on:

POST /wp-json/octa-forms/v1/forms/{slug}/steps/{n}/check
Content-Type: application/json
{ "fields": { "name": "Jan", "email": "jan@example.com" } }

Send all the values collected so far, so conditional fields resolve the same way as on submit. The server validates only the visible fields of section n (file fields excepted) and answers 200 {"ok": true} or 400 with the usual {errors} map. It stores nothing, needs no token and does not count toward the submission rate limit. It has its own limit per visitor instead, and answers 429 with _ban when it is reached. A form that is not shown as steps, or a step it does not have, answers 404. The final submission validates everything again, so a check that fails to run should let the visitor move on.

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.
  • If you show a size or type check in the browser before uploading, treat it as a convenience only. The server re-measures the size and inspects the real contents on every request, so those limits are the ones that count. The stock helper renders forms with an accept attribute and a data-octa-forms-max-size attribute (megabytes) for exactly this; reading them is optional.
  • 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.

redirectUrl comes back ready to use. Placeholders such as {package} are already filled with the URL-encoded answers, and id=<submission id> is appended unless the configured address sets id itself. See Thank-you page address.

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. Besides the rule names, a field can report invalid (a value of the wrong shape, for example a list where one value is expected) or checkbox (a list or an object sent for a single checkbox)
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
404 _request: ["step_not_found"] A step check for a form not shown as steps, or for a step it does not have
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
400 _spam: [code] The submission was declined by a challenge wired to the octa_forms_challenge filter (convention: challenge_failed). Nothing in a default install responds with this key; the built-in Akismet check quarantines silently instead. See Anti-spam
500 _request: ["storage_failed"] A server-side write error. Retry later

The reserved keys are _form, _token, _ban, _request and _spam. Anything else is a field name. Keys starting with _ inside meta are reserved too: the server silently drops them from client input.

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).