Everything on one page
This page is the condensed version of the whole documentation. It covers how a form reaches a page, the config JSON, the exact wire contract, how to send data that is not a form field, what JavaScript and CSS the plugin loads, and how to restyle it.
It exists so you can hand one document to an AI coding assistant, or read one document before you start building, instead of opening fifteen pages. Every section links to the full page if you need more depth.
The mental model
Section titled “The mental model”OctaForms is a backend form engine. Six things follow from that, and most integration mistakes come from missing one of them.
- The plugin renders no markup unless you ask it to. You write the HTML, or you use the starter shortcode. There is no page builder and there is no filter to alter the rendered markup. If you need different HTML, render your own.
- A form is one JSON document. It holds the fields, the validation, the emails, the webhooks and the settings.
- The slug ties your markup to that config. It locks once the form is published and never changes.
- Validation is server-side only. There is no JavaScript rule engine to keep in sync. The server returns the full list of field errors in one response.
- JavaScript is required. The token, the retry contract and the response shapes all assume a JS client. Without JS the form shows a
<noscript>note. - A success response is not proof of a stored record. A tripped honeypot returns a success-shaped
200with a randomid, so a bot learns nothing. Never treat the returnedidas a durable key on the client.
Adding a form to a page
Section titled “Adding a form to a page”Three levels, trading convenience for control. You can mix them across forms on one site.
| Level | You write | You get |
|---|---|---|
| Shortcode | [octa_form slug="…"] |
A working, unstyled form |
| Your markup plus the bundled helper | HTML only | Token, submit, retries and error placement handled |
| Your own JavaScript | HTML and every request | Full control |
Level 1: the shortcode
Section titled “Level 1: the shortcode”The tag is octa_form, singular. It takes exactly two attributes and there are no aliases.
[octa_form slug="contact"][octa_form slug="contact" context="Hero section"]slug is required and is passed through sanitize_title(). An empty, unknown or inactive slug renders nothing but the HTML comment <!-- octa-forms: unknown or inactive form -->, which makes a typo easy to spot in the page source.
context is an optional label that does two things. It gives the form and its fields id attributes prefixed with octa-forms-, with a -2, -3 suffix when the same context appears more than once on a page. It is also recorded with every submission, so you can tell two placements of the same form apart. Without context the fields still get unique ids but the <form> itself gets none.
The shortcode also forces the helper to bind its own form even if autobind was globally turned off.
Full page: The starter shortcode.
Level 2: your markup plus the helper
Section titled “Level 2: your markup plus the helper”Add data-octa-forms="slug" to your <form> and the bundled helper binds to it when the DOM is ready.
<form data-octa-forms="contact"> <input type="text" name="name" /> <input type="email" name="email" /> <label><input type="checkbox" name="consent" /> I agree to be contacted</label> <input type="file" name="cv" accept=".pdf" data-octa-forms-max-size="5" /> <button type="submit">Send</button></form>Rules for that markup:
- Each input’s
namemust match a fieldnamein the config. Unknown names are silently dropped by the server. - One value per input name, with one exception. A
checkboxesgroup names every boxname[]and the helper sends the ticked values as a list undername. Any other group of checkboxes sharing a name collapses to the last one, and a<select multiple>sends only the first selection. acceptanddata-octa-forms-max-size(in megabytes) are optional hints the helper uses for a check before the upload starts. Leave them off and the check is simply skipped. The server always re-measures and re-sniffs.
The helper fetches the token on first interaction, submits, handles the token retry cases, shows the thank-you or follows the redirect, and places each validation message next to its field.
Full page: The JS helper.
Level 3: your own JavaScript
Section titled “Level 3: your own JavaScript”Skip the helper and call the REST API. That contract is below.
The config JSON
Section titled “The config JSON”One JSON document per form, with exactly four top-level keys. Only fields is required. Note that email is a single object while webhooks is an array, and that there is no top-level security key. The editor’s Security tab writes into settings.
{ "fields": [ { "name": "name", "type": "text", "label": "Your name", "rules": [{ "r": "required" }] }, { "name": "email", "type": "email", "label": "Email", "rules": [{ "r": "required" }, { "r": "email" }] }, { "name": "message", "type": "textarea", "label": "Message", "rules": [{ "r": "required" }, { "r": "max:2000" }] }, { "name": "consent", "type": "checkbox", "label": "Marketing consent", "description": "I agree to receive offers. See the [privacy policy](https://example.com/privacy).", "consent": true }, { "name": "extra_note", "type": "text", "honeypot": true } ], "email": { "notification": { "enabled": true, "to": ["sales@example.com"] }, "confirmation": { "enabled": true, "toField": "email" } }, "webhooks": [ { "url": "https://hooks.example.com/octaforms", "active": true, "secret": "at-least-32-random-characters" } ], "settings": { "submitLabel": "Send message", "thankYouTitle": "Thanks!", "thankYouText": "We will get back to you shortly." }}Unknown keys anywhere in the document are preserved on save. The editor is a document editor, not a rebuilder.
fields
Section titled “fields”Each field needs a name and a type. Names must be unique. A name cannot start with _, cannot contain [ or ], and cannot be one of the reserved error keys _form, _token, _ban, _request or _spam.
| Property | Applies to | Notes |
|---|---|---|
name |
all | Required. Must match the input’s name. |
type |
all | Required. An unknown type only warns and renders as a text input. |
label |
all | Falls back to name. On a checkbox it stays the submissions-list column and the email field name. |
placeholder |
text, textarea, tel, email |
Ignored on every other type, even if you set it. |
rules |
all | See validation. |
options |
select |
Either {value, label} objects or plain strings. A select with no options is a save error. |
description |
checkbox |
Long consent text shown next to the box instead of the label. |
consent |
checkbox |
Opt-in GDPR proof recording. See below. |
honeypot |
all | The field is never validated, stored or delivered. It renders CSS-hidden. |
showInList |
all | Show this field as a column in the submissions list. |
inEmail |
all | Defaults to true. Only a literal false leaves the field out of emails. |
sensitive |
all | Makes the default webhook strategy for this field mask instead of sending it. |
There is no multiple property, and mimes and maxSize are rules rather than field properties.
Two checkbox options are worth calling out.
description holds a long consent statement rendered next to the box instead of the short label. A markdown-style [text](url) in it becomes a safe link that opens in a new tab.
consent: true is what makes the answer stored as GDPR proof, together with the exact wording shown and the time. It is opt-in per field and is never inferred from required. The two are independent. “I confirm my data is correct” is required and proves nothing. A marketing opt-in is optional and is exactly what you must be able to prove, refusals included. A required checkbox with no consent key at all raises a save warning, because those configs stopped recording silently when the rule changed. Setting the key either way clears it.
Field types
Section titled “Field types”The complete list is text, textarea, email, tel, number, checkbox, checkboxes, select, radio, date and file. There is no hidden or url type.
| Type | Rendered as |
|---|---|
text |
<input type="text"> |
email |
<input type="email" inputmode="email" autocomplete="email"> |
tel |
<input type="tel" inputmode="tel" autocomplete="tel"> |
number |
<input type="text" inputmode="decimal">, a decimal comma is saved as a dot (but 1,000 stays as typed) |
textarea |
<textarea rows="4">, and a block in emails |
select |
<select> with a leading empty “Choose one” option |
radio |
a <fieldset> whose <legend> is the label, one radio per option |
checkbox |
<input type="checkbox" value="on"> wrapped by its label |
checkboxes |
a <fieldset> of checkboxes named name[], value is a list |
date |
<input type="date">, ISO YYYY-MM-DD on the wire |
file |
<input type="file">, one file per field |
Every field can carry a default (the starting value) and a showIf condition ({"field": "x", "equals": "Yes"} or {"field": "agree", "checked": true}). A field hidden by its condition skips its rules and loses its value on the server. The form can group fields into sections, shown as cards, or one at a time with settings.layout set to "steps".
Full pages: Field types and Form config.
settings
Section titled “settings”| Key | Default | Effect |
|---|---|---|
submitLabel |
Send |
Submit button text. |
thankYouTitle, thankYouText |
Translated defaults | The success message. Leave empty and the default follows the site language. |
redirectUrl |
none | Redirect after success instead of showing the thank-you. Takes priority. Fills {field_name}, {lead_id} and {form_id} and appends id. |
requireToken |
true |
Only a literal false disables the token. Honeypot and rate limiting stay on. |
deactivated |
false |
Takes the form offline. Both endpoints then answer 404. |
retentionDays |
site setting | How long submissions are kept. null means “use the global setting”. |
rateLimit |
see below | Per-form override of the rate limiter. |
rateLimit takes attempts (30), attemptsWindow (60 seconds), ban (3600 seconds), success (10) and successWindow (600 seconds). Every value is floored at 1.
{ "from": { "email": "", "name": "" }, "notification": { "enabled": true, "to": [], "cc": [], "bcc": [], "replyTo": "", "subject": "", "body": "" }, "confirmation": { "enabled": true, "toField": "email", "bcc": [], "replyTo": "", "subject": "", "body": "" }}- An empty
from.emailmeans no per-form override, so the global plugin setting is used. - Recipient lists are static addresses only. A
{anywhere in an entry is a save error. replyTois the one recipient slot that accepts a{field}variable, and it is re-checked as a real address after substitution.- The confirmation has no
ccand carries no attachments by design. The notification carries them. - An empty
subjectorbodyfalls back to a translated default, which is the recommended way to keep emails following the site language. - Variables available in subjects and bodies are
{id},{form_id},{submission_url}and{fieldName}for every field.
Full page: Emails.
webhooks
Section titled “webhooks”| Key | Notes |
|---|---|
url |
Required. Validated for SSRF safety at save and again before every dispatch. |
active |
A falsy value skips the webhook entirely. |
secret |
Required when active, minimum 32 characters. |
anonymize |
{fieldName: strategy} where strategy is remove, mask, hash or none. |
include_user_path |
Defaults to false. Controls whether meta.user_path is sent. |
The payload is signed as sha256= followed by an HMAC-SHA256 of the body. Full page: Webhooks.
Saving and validation
Section titled “Saving and validation”The config is validated on every save. Errors block the save, for example a select with no options, a duplicate field name, or an active webhook with a short secret. Warnings let the save through but flag a risk, for example an unknown field type, a misspelled rule name that would be skipped silently at submit time, or a honeypot named something browsers like to autofill. Every problem carries the exact path it came from. The previous version is kept, so the raw JSON editor has a one-step undo.
Full pages: The config JSON, The form editor.
Validation rules
Section titled “Validation rules”Rules are an array of objects on the field. Each has an r key naming the rule, and an optional m key overriding the message. Parameters go after the first colon inside the r string. Rules run in order, on the server.
"rules": [{ "r": "required" }, { "r": "min:3" }, { "r": "max:2000", "m": "Keep it under 2000 characters." }]Leave m off and a translated default message is used. That is the recommended approach, because the message then follows the site language.
| Rule | Checks |
|---|---|
required |
Not empty. On a checkbox it means ticked, on a file field it means a file arrived. |
email |
A valid address. ASCII only, so internationalised domains are rejected by design. |
min:N / max:N |
Text length at least or at most N characters. On a field with numeric or integer, the number itself at least or at most N. |
numeric / integer |
A number, or a whole number. |
date |
A real calendar date in YYYY-MM-DD. |
url |
A valid URL, ASCII only. |
tel |
6 to 15 digits after stripping spaces, dashes, dots and brackets, with an optional leading +. |
regex:/pattern/ |
Matches your pattern, taken literally and never split on commas. An empty or invalid pattern fails closed. |
in:a,b,c |
One of the listed values. |
minChecked:N / maxChecked:N |
At least or at most N boxes ticked in a checkboxes group. The only rules besides required that a group takes. |
telPL |
Polish number, 9 digits, an optional +48 prefix. |
nip |
Polish NIP including its checksum. |
postCodePL |
Polish postal code, NN-NNN. |
mimes:pdf,jpg |
The file’s real type, sniffed from its contents, matches one of the listed extensions or MIME types. |
maxSize:N |
The file is at most N megabytes. A value of zero or less fails closed. |
maxFiles:N |
At most N files. Anything above 1 warns, because one file per field is what the transport supports. |
Two things happen automatically, so you do not write them yourself. A required rule on a checkbox becomes a strict “must be exactly true” check, and on a file field it becomes “a file was uploaded”. A select value is always checked against its own options even with no in: rule.
An empty value skips every rule unless one of the required variants is present, so an optional field with a min:3 rule accepts being left blank.
min and max compare the number itself when the field also has numeric or integer and the answer is a number. With numeric and min:18, an age field takes 18 and 18,5 and refuses 17. Everywhere else they count characters, also on a number field without a number rule. On a numeric field N is any number, fractions and negatives included. On a length N is a whole number of 0 or more. Anything else is refused at save. The default messages for the number case are “This value is too small.” and “This value is too large.” A phone field written as numeric with max:11 would refuse every real number, so use tel for phones. A form saved before 1.5.0 keeps counting characters until it is saved again. The editor warns about numeric or integer next to min or max on any field that is not a number field.
Register your own rule with the octa_forms_validation_rules filter. The callback takes the value and the parsed parameters and returns a boolean. A custom rule has no default message, so supply m in the config or your visitors see a generic “Invalid value.”
add_filter( 'octa_forms_validation_rules', function ( array $rules ): array { $rules['vatEU'] = function ( $value, array $params ): bool { return (bool) preg_match( '/^[A-Z]{2}\d+$/', (string) $value ); }; return $rules;} );Full pages: Validation rules, Custom validation rules.
The wire contract
Section titled “The wire contract”Two public endpoints. Both are unauthenticated by design, so there is no nonce to pass.
GET /wp-json/octa-forms/v1/token?form={slug}POST /wp-json/octa-forms/v1/forms/{slug}/submissionsA form that is deactivated or in draft answers 404 on both. Every response in this namespace carries Cache-Control: no-store, private, no-cache, max-age=0.
Step 1: the token
Section titled “Step 1: the token”The token deduplicates double submits, acts as a time trap against instant bots, and requires JavaScript to obtain. It is not the main anti-spam barrier. The honeypot and rate limiting are.
{ "token": "1721480000.9f3c1a2b8d7e4f60.a1b2c3…", "ttl": 900 }It is bound to the form slug and nothing else. There is no session, cookie or IP binding, and verification is stateless. It lives 15 minutes and must be at least 3 seconds old when you submit it.
- Fetch it on the visitor’s first interaction with the form, not on page view.
- Send it back as the top-level
tokenkey in the POST body. It is not a header and not a form field. - Handle these retriable
400responses.
Code in _token |
Meaning | What to do |
|---|---|---|
too_fast |
Younger than 3 seconds | Wait about 3 seconds, retry once |
token_expired |
Older than 15 minutes | Fetch a fresh one, wait about 3 seconds, retry once |
invalid |
Malformed or failed its signature check | Fetch a fresh one, show a message, let the visitor retry |
A failed token fetch must never fail silently. Log it and show a form error, because a security plugin blocking /wp-json/ must not quietly kill the form. The endpoint has its own per-IP throttle of 60 requests a minute and answers 429 with Retry-After above that. Do not loop requests.
Do not cache /wp-json/octa-forms/v1/token. It sends the right headers, but your page cache or CDN needs its own exclusion rule.
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 ...
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 document has three keys and everything else at the top level is ignored. fields accepts only names declared in the config. meta is the channel for anything that is not a form field, described in its own section. token comes from step 1.
Limits: the document is at most 64 KB, and a single field value at most 5000 characters unless a max:N rule raises it. On a field with numeric or integer, max:N is the largest number and does not raise the limit.
application/x-www-form-urlencoded is rejected outright with unsupported_media_type.
How each field type is serialized
Section titled “How each field type is serialized”| Field type | Value in fields |
|---|---|
checkbox |
A boolean. An unchecked box is sent explicitly as false, never omitted. |
checkboxes |
A list of the ticked option values, [] when none. A single string counts as a list with one item. |
text, textarea, tel, email, number, select, radio, date |
A string. date is ISO YYYY-MM-DD. |
file |
Not in fields at all. See below. |
Every other value must be a single scalar or null. A checkboxes list holds at most one item per option and is stored in declaration order without duplicates. Any other array, or a list of the wrong shape, is rejected with a field error of invalid. An explicit boolean is the recommended contract for a single checkbox. Other 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, and every other text or number means ticked, because plain HTML sends a box’s value only when it is ticked. A text outside "on", "true" and "1" that counts as ticked, such as "yes", leaves a warning in the debug log with the field name and the form id. A list or an object produces a checkbox field error.
File uploads
Section titled “File uploads”A form with file fields uses multipart/form-data.
POST /wp-json/octa-forms/v1/forms/{slug}/submissionsContent-Type: multipart/form-data; boundary=…
part "payload" → application/json: {fields, meta, token} (the same document as above)part "files[<field>]" → the file contents, one part per file field- One file per field. More than one returns a
maxFilesfield error. - The 64 KB limit applies to the
payloadpart only. Files have a hard 10 MB cap on top of the field’s ownmimes,maxSizeandmaxFilesrules. - The type is detected from the file’s contents. The declared type and the extension do not matter, and executables are always rejected.
- Any value for a file field inside
payload.fieldsis discarded before processing. File references are created server-side only, so they cannot be spoofed. - Do not send raw
FormDatawithout thepayloadenvelope. - If PHP’s own
post_max_sizeis exceeded, the response isbody_too_largerather than a parse error, so you can tell the two apart.
Step 3: the response
Section titled “Step 3: the response”Success is 200, in one of two shapes.
{ "ok": true, "id": 123, "redirectUrl": "https://example.com/thanks" }{ "ok": true, "id": 123, "thankYouTitle": "Thanks!", "thankYouText": "We will be in touch." }redirectUrl wins and suppresses the thank-you keys. The thank-you fields are plain text with no HTML.
The address can carry answers. {field_name} becomes that field’s value, {lead_id} the submission id and {form_id} the form ID. Each value is URL-encoded, and a checkbox group gives its ticked options joined by commas. An unknown name, an empty answer or a field hidden by showIf gives an empty value. id=<submission id> is appended unless the address already sets id. So https://example.com/thanks?package={package} with Premium & VIP becomes https://example.com/thanks?package=Premium%20%26%20VIP&id=123. The address ends up in logs, browser history and analytics, so keep emails, phone numbers and names out of it.
A repeat POST with the same token and the same fields is idempotent. It returns 200 with the same id, enqueues nothing a second time, and fires no second octa_forms_submission_created.
Errors always have the shape { "errors": { … } }. Reserved keys hold an array of code strings. Any other key is a field name and holds an object of rule to message.
{ "errors": { "_token": ["too_fast"] } }{ "errors": { "email": { "required": "This field is required." } } }| Status | Key | Meaning |
|---|---|---|
| 400 | _token: [code] |
invalid, token_expired, too_fast |
| 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 |
| 400 | _spam: ["challenge_failed"] |
Declined by a challenge wired to octa_forms_challenge. Nothing in a default install returns this |
| 404 | _form: ["not_found"] |
Unknown, draft or deactivated slug |
| 429 | _ban: ["retry"] plus Retry-After |
Too many successful submits from one network |
| 429 | _ban: ["banned"] plus Retry-After |
A flood from one IP. Temporary ban |
| 429 | _ban: ["rate_limited"] plus Retry-After |
The token endpoint’s own throttle |
| 500 | _request: ["storage_failed"] |
A server-side write error. Retry later |
Rate limiting
Section titled “Rate limiting”Two counters per form and per hashed IP, with per-form overrides in settings.rateLimit.
- Attempts. 30 requests in 60 seconds earns a temporary ban of 3600 seconds. The
Retry-Afterreflects the remaining ban. - Successes. 10 stored submissions in 600 seconds returns
retryinstead of a ban, withRetry-Afterclamped to between 60 and 120 seconds regardless of the configured window. This is the polite case, meant for a busy office behind one address, and these requests are never counted toward a ban.
There are no X-RateLimit-* headers. Respect Retry-After and do not loop.
Full page: Your own JavaScript.
Sending data that is not a field
Section titled “Sending data that is not a field”Anything that is not a declared form field travels in the meta object next to fields. This is the channel for a UTM tag, a page URL, a plan name, a referral code or a campaign ID.
{ "fields": { "email": "jan@example.com" }, "meta": { "url": "https://example.com/pricing", "utm_source": "newsletter", "plan": "pro" }, "token": "…"}The rules:
- Up to 10 keys. Values must be scalars, meaning string, number, boolean or null.
- A string value is capped at 500 characters and a key name is truncated at 100 characters.
- Keys starting with
_are a reserved internal namespace. Client-supplied ones are silently dropped and never stored. - Bad input degrades instead of failing the lead. A non-scalar value or an over-long string drops that one key while the submission still goes through. Only two things reject the request outright,
metathat is not an object at all and more than 10 scalar keys. Both return_request: ["meta_invalid"].
Meta is stored with the submission, shown in the admin details, and included in the webhook payload.
Adding meta from your own script
Section titled “Adding meta from your own script”With the helper you do not build the request yourself. Register a provider instead and it runs before every submit.
window.octaForms.addMetaProvider(() => ({ plan: document.body.dataset.plan, variant: window.myAbTest?.variant,}));Each provider runs inside its own try/catch, so a provider that throws never blocks a submission. A non-object return is ignored. The base keys url and context are merged last and always win.
Four keys are special and never cost you one of the 10 slots. user_path carries the opt-in list of pages the visitor viewed before submitting, as [{t, path}]. See Visit path. referrer, source and campaign carry the opt-in lead source: the address of the page that led to the form ("" for a direct entry, always sent), the first outside source of the visit (Google Ads, Google Organic, Facebook or an address, only with consent) and the first page’s utm_*, gclid, gbraid, wbraid and fbclid tags as an object. campaign is the plugin’s only when it is an object, and referrer and source only while the feature is on, so a theme’s own scalar keys of those names keep working. See Lead source.
Frontend assets
Section titled “Frontend assets”One JavaScript file and one stylesheet, always, on every front-end page. Both use the handle octa-forms and build to build/octa-forms.js and build/octa-forms.css.
- The script loads in the footer at the lowest possible priority with
defer, so it never gets in the way of your own scripts. - The stylesheet is printed last and rewritten to load asynchronously, so it never blocks rendering. A
<noscript>copy keeps it working with JavaScript off. - The bundle contains the form helper, the visit-path module, the callback widget, the cron nudge and the GTM bridge. Each activates only when its own global is present on the page. Without its global a module stays completely silent.
Because features are driven by data rather than by which files load, there is nothing to conditionally enqueue.
| Global | Present when | Carries |
|---|---|---|
octaFormsHelper |
Always | restUrl, autobind, nudge, maxFileBytes, and an i18n object of message strings |
octaFormsCron |
The cron nudge is enabled | tickUrl, delay in milliseconds |
octaFormsUserPath |
Visit path is enabled | metaKey, maxEntries, ttlDays, maxEntryChars, consent category names, requireConsent |
octaFormsLeadSource |
Lead source is enabled | wpConsentCategory, requireConsent |
octaFormsGtm |
The GTM push is enabled | dataLayer, the container’s dataLayer variable name |
octaFormsCallback |
The widget is configured and visible | Per-device modes, position, headings, labels and i18n |
maxFileBytes is the smaller of the WordPress upload limit and the plugin’s own cap, so it is the number to trust for a client-side check.
Kill switches
Section titled “Kill switches”| What it turns off | Filter | Setting |
|---|---|---|
| The whole bundle, JS and CSS | octa_forms_frontend_assets_enabled |
— |
| The stylesheet only | octa_forms_styles_enabled |
disable_styles |
| The helper’s automatic scan | octa_forms_helper_autobind |
— |
| The cron nudge | octa_forms_cron_nudge_enabled |
disable_cron_nudge |
| The widget on a given page | octa_forms_callback_visible |
— |
| The GTM dataLayer push | octa_forms_gtm_push_enabled |
— |
The master switch also suppresses shortcode output, which then renders <!-- octa-forms: frontend assets disabled -->.
Full page: Frontend assets.
The JavaScript API
Section titled “The JavaScript API”window.octaForms is assigned as soon as the bundle loads.
| Method | Purpose |
|---|---|
attach(form) |
Bind a form inserted after the initial scan, for example inside a modal. Returns the instance, or null if the element has no data-octa-forms, is already bound, or the helper has no restUrl. It is idempotent, guarded by data-octa-forms-bound="1". |
onAttach(fn) |
Runs fn(form) for every form as it binds, during the initial scan and for later attach() calls. Use it when a separate script must enhance every form, which is the typical captcha case. Listeners are isolated in try/catch. |
addMetaProvider(fn) |
Register a function returning extra meta keys before each submit. |
Events
Section titled “Events”Two layers, kept apart on purpose.
Raw events fire on the form element and carry the full server payload. They do not bubble and are meant for theme code.
form.addEventListener('octaforms:success', (e) => { /* e.detail is the server payload */ });form.addEventListener('octaforms:error', (e) => { /* e.detail is { status, errors } */ });Neutral events are the ones to track. They bubble, fire for every form, and carry slug, context and location but never any visitor data.
octaforms:form-start, octaforms:form-submit, octaforms:form-success and octaforms:form-error fire on the form. A form shown as steps adds octaforms:step-change with step, total and title each time someone moves to another step. step is the section’s number and total counts every section, also one skipped because all its fields are hidden. title is null for a section without a title. The widget adds octaforms:widget-open, octaforms:tab-switch, octaforms:phone-call and octaforms:phone-copy on document. The GTM push carries octaFormsSchema: 1, which changes only for a breaking change. A GTM trigger on the whole octaforms_ prefix now also lets octaforms_step_change through to GA4.
Every form-submit is followed by exactly one form-success or form-error. When nothing reached a server, the error reports status: 0 plus a reason of token, timeout, network or preflight. Some non-events are part of the contract too. A silent auto-retry emits nothing and does not count as a second form-submit, and a file pre-flight rejection on a first attempt stays silent because no round-trip happened. The preflight reason exists only for a rejection during an auto-retry, where a form-submit is already open and must be closed.
Full page: JavaScript events.
Styling
Section titled “Styling”The shortcode, the helper and the widget popup all render the same markup, so one set of styles covers everything.
<form class="octa-forms" data-octa-forms="contact" method="post" action="#"> <noscript><p class="octa-forms__noscript">…</p></noscript>
<div class="octa-forms__field octa-forms__field--text"> <label for="octa-forms-contact-1-name">Name</label> <input type="text" id="octa-forms-contact-1-name" name="name" /> </div>
<div class="octa-forms__field octa-forms__field--checkbox"> <label> <input type="checkbox" name="consent" value="on" /> <span class="octa-forms__consent">I agree to the privacy policy</span> </label> </div>
<div class="octa-forms__actions"> <button type="submit" class="octa-forms__submit">Send</button> </div></form>The form carries enctype="multipart/form-data" only when it has a file field, and an id only when the shortcode was given a context.
Every default rule is wrapped in :where() and therefore carries zero specificity, so any selector you write beats it and a plain class is enough. There are a few deliberate exceptions that keep real specificity, so a blanket theme reset cannot break them. Those are the submit button, the busy spinner, the progress bar, the widget’s focus and error states, and the frame of a section card or a choice group, which has one class of specificity so a theme’s bare fieldset rule cannot reframe it.
Classes
Section titled “Classes”| Class | What it is |
|---|---|
.octa-forms |
The <form>. Set your variables here. |
.octa-forms__field |
A field wrapper. |
.octa-forms__field--{type} |
Type modifier, one of text, textarea, email, tel, date, select, file, checkbox. |
.octa-forms__label--sr |
A label kept for screen readers when the placeholder replaces it. Leave it hidden. |
.octa-forms__consent |
The text next to a checkbox. |
.octa-forms__actions / .octa-forms__submit |
The actions row and the submit button. |
.octa-forms__progress |
The native <progress> upload bar, inserted before the submit button. |
.octa-forms__noscript |
The message shown when JavaScript is off. |
.octa-forms__thank-you and -title / -text |
The success screen. |
.octa-forms-error |
An error message, with role="alert". |
.octa-forms-error--form |
A form-level error, prepended to the form. |
.octa-forms-error__message / __separator |
The parts inside one error node. |
Errors are their own block, octa-forms-error, not octa-forms__error. A field error is inserted after the input’s wrapping <label> when there is one, which for a checkbox means after the whole row.
State is on attributes, not classes
Section titled “State is on attributes, not classes”There are no is-loading, has-error or is-success classes anywhere. Style these instead.
| State | Hook |
|---|---|
| Submitting | aria-busy="true" on the <form>, plus disabled on its buttons |
| Invalid field | aria-invalid="true" on the input |
| Bound and hydrated | data-octa-forms-bound="1" on the form |
| Success | No state at all. The <form> is replaced by .octa-forms__thank-you |
| Widget open or closed | The [hidden] attribute |
The success case has a consequence worth knowing. Because the thank-you element replaces the form rather than sitting inside it, it no longer inherits variables set on .octa-forms. Set --octa-forms-thank-you-bg and --octa-forms-thank-you-border higher up, on :root for example, or they will not apply.
Variables
Section titled “Variables”For common changes, set variables on .octa-forms instead of writing override rules. Because you set them on the container, you can scope different looks to different places.
.octa-forms { --octa-forms-submit-bg: #2563eb; --octa-forms-control-radius: 12px; --octa-forms-focus-color: #2563eb;}
.site-footer .octa-forms { --octa-forms-submit-bg: #fff; --octa-forms-submit-color: #111; }The full set covers layout (--octa-forms-gap, --octa-forms-max-width), labels (--octa-forms-label-size, --octa-forms-label-weight), controls (--octa-forms-control-bg, -color, -padding, -height, -border, -border-hover, -radius, -disabled-bg, plus --octa-forms-placeholder-color, --octa-forms-focus-color, --octa-forms-accent for ticked boxes and --octa-forms-label-gap), choice groups (--octa-forms-options-direction, --octa-forms-options-gap), sections (--octa-forms-section-*), secondary text (--octa-forms-muted), the submit button (--octa-forms-submit-bg, -bg-hover, -color, -padding, -radius), errors (--octa-forms-error-color, --octa-forms-error-bg, --octa-forms-error-separator-color), the progress bar (--octa-forms-progress-height, -track, -fill) and the thank-you panel (--octa-forms-thank-you-bg, -border).
The callback widget reads its own --cb-* variables scoped to .octa-forms-callback. Setting --cb-primary alone retints the button, the rings, the focus outlines, the active tab and the popup’s submit button. Its spacing and type scales fall back to your theme’s --spacing-* and --font-size-* tokens, so the widget often adopts your rhythm with no extra work.
Two mistakes to avoid
Section titled “Two mistakes to avoid”Do not style bare input. A checkbox is an <input> too, so a generic rule blows it up into a full-width padded box that pushes the layout around. This is the most common styling bug by a wide margin.
/* Right */.octa-forms__field input:not([type="checkbox"]):not([type="radio"]),.octa-forms__field select,.octa-forms__field textarea { width: 100%; padding: 12px; }Do not style bare button. Use .octa-forms__submit or the --octa-forms-submit-* variables, because the button keeps real specificity precisely so a theme reset cannot wash it out.
Full page: Styling and theming.
Anti-spam in one paragraph
Section titled “Anti-spam in one paragraph”The honeypot is any config field marked honeypot: true, and its name is yours to choose rather than randomized, so pick something a browser will not autofill. It renders as a real text input pushed off-screen with inline styles and aria-hidden, never as type="hidden". A tripped honeypot quarantines the submission instead of deleting it, so you can recover a real lead, and it returns a success-shaped 200 so the bot learns nothing. Rate limiting uses the two counters described above. The token adds deduplication, a time trap and a JavaScript requirement. Optional Akismet quarantines silently rather than rejecting, and both octa_forms_spam_check and octa_forms_challenge default to letting everything through, so nothing wired there can lose a lead by accident. Full page: Anti-spam.
Delivery in one paragraph
Section titled “Delivery in one paragraph”Every delivery is written to a queue before the visitor gets a response, so nothing is lost. The first email attempt runs inside the request behind a circuit breaker, and anything that fails is retried by cron with exponential backoff. Webhooks are at-least-once, HMAC-signed, and re-check DNS before every send to block SSRF. A real system cron is the recommended production setup. Full pages: How delivery works, Webhooks, Cron and the delivery queue.
Hooks you are most likely to need
Section titled “Hooks you are most likely to need”| Hook | Purpose |
|---|---|
octa_forms_submission_created |
Action. Fires after a submission is stored and enqueued, on a non-duplicate save only. Arguments $submission, $form. The place to push into a CRM. |
octa_forms_validation_rules |
Register custom validation rules. |
octa_forms_response |
Filter the success body. It runs for genuine submissions only, never for a quarantine drop. |
octa_forms_client_ip |
How the visitor IP is determined, for proxy and CDN setups. |
octa_forms_webhook_payload |
The webhook payload before it is signed. |
octa_forms_spam_check |
The content spam verdict. Return ham or spam. |
octa_forms_challenge |
The mount point for a captcha. Return block to reject with a 400. |
octa_forms_mail_variables |
The {field} variables available in email subjects and bodies. |
octa_forms_rate_limit_defaults |
The rate-limit thresholds before per-form overrides. |
octa_forms_token_ttl / octa_forms_token_min_age |
The token lifetime and the time trap. |
The complete list is on Hooks and filters.
Checklist before you ship
Section titled “Checklist before you ship”- Every input
namematches a fieldnamein the config, and no name starts with_. - One value per name. No checkbox groups sharing a name, no
<select multiple>. - Unchecked checkboxes are sent explicitly as
false. - The token is fetched on first interaction rather than on page view, and a failed fetch shows a visible error.
- The one-shot retry for
too_fastandtoken_expiredis implemented. Retry-Afteris respected on every429, with no request loops./wp-json/octa-forms/v1/tokenis excluded from the page cache and the CDN.- Files go in
files[<field>]parts alongside apayloadpart, never as rawFormData. - The returned
idis not stored client-side as a durable reference. - No CSS rule targets
.octa-forms__field inputwithout:not([type="checkbox"]):not([type="radio"]). - The submit button is styled through
.octa-forms__submit, not barebutton. - Thank-you variables are set somewhere above the form, since the thank-you replaces it.
- Focus, error and busy states still stand out against your theme.