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}/submissionsThe {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.
Step 1: the token
Section titled “Step 1: the token”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:
-
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.
-
Send it back in the POST body as
token. -
Handle these
400responses, all of which are retriable:Code in _tokenMeaning What to do too_fastThe token is younger than 3 seconds Wait about 3 seconds and retry once token_expiredThe token is too old (it lives 15 minutes) Fetch a fresh token, wait about 3 seconds, retry once invalidThe token did not verify Fetch a fresh one, show a message, let the visitor retry -
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. -
The token endpoint has its own soft per-IP throttle. Under a flood it answers
429with aRetry-Afterheader. 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.
Step 2: the submission
Section titled “Step 2: the submission”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 likeurl,src,utm). Stored with the submission. The plugin’s own keysuser_path,referrer,sourceandcampaigndo 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.
How each field type is serialized
Section titled “How each field type is serialized”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.
Checking one step
Section titled “Checking one step”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}/checkContent-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.
File uploads
Section titled “File uploads”A form with file fields uses multipart/form-data instead:
POST /wp-json/octa-forms/v1/forms/{slug}/submissionsContent-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
maxFileserror. - The 64 KB body limit applies only to the
payloadpart. Files have their own limits: a hard 10 MB per file, plus your field’smimes,maxSizeandmaxFilesrules. - 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.fieldsare 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
acceptattribute and adata-octa-forms-max-sizeattribute (megabytes) for exactly this; reading them is optional. application/x-www-form-urlencodedis rejected. Do not send rawFormDatawithout thepayloadenvelope.
Step 3: the response
Section titled “Step 3: the response”Success (200)
Section titled “Success (200)”{ "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.
Errors (always { errors: {...} })
Section titled “Errors (always { errors: {...} })”| 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.
The honeypot
Section titled “The honeypot”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).