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