Skip to content

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.

OctaForms is a backend form engine. Six things follow from that, and most integration mistakes come from missing one of them.

  1. 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.
  2. A form is one JSON document. It holds the fields, the validation, the emails, the webhooks and the settings.
  3. The slug ties your markup to that config. It locks once the form is published and never changes.
  4. 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.
  5. 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.
  6. A success response is not proof of a stored record. A tripped honeypot returns a success-shaped 200 with a random id, so a bot learns nothing. Never treat the returned id as a durable key on the client.

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

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.

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 name must match a field name in the config. Unknown names are silently dropped by the server.
  • One value per input name, with one exception. A checkboxes group names every box name[] and the helper sends the ticked values as a list under name. Any other group of checkboxes sharing a name collapses to the last one, and a <select multiple> sends only the first selection.
  • accept and data-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.

Skip the helper and call the REST API. That contract is below.

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.

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.

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.

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.email means 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.
  • replyTo is the one recipient slot that accepts a {field} variable, and it is re-checked as a real address after substitution.
  • The confirmation has no cc and carries no attachments by design. The notification carries them.
  • An empty subject or body falls 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.

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.

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.

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.

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}/submissions

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

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.

  1. Fetch it on the visitor’s first interaction with the form, not on page view.
  2. Send it back as the top-level token key in the POST body. It is not a header and not a form field.
  3. Handle these retriable 400 responses.
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.

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.

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.

A form with file fields uses multipart/form-data.

POST /wp-json/octa-forms/v1/forms/{slug}/submissions
Content-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 maxFiles field error.
  • The 64 KB limit applies to the payload part only. Files have a hard 10 MB cap on top of the field’s own mimes, maxSize and maxFiles rules.
  • 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.fields is discarded before processing. File references are created server-side only, so they cannot be spoofed.
  • Do not send raw FormData without the payload envelope.
  • If PHP’s own post_max_size is exceeded, the response is body_too_large rather than a parse error, so you can tell the two apart.

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

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-After reflects the remaining ban.
  • Successes. 10 stored submissions in 600 seconds returns retry instead of a ban, with Retry-After clamped 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.

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, meta that 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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

  • Every input name matches a field name in 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_fast and token_expired is implemented.
  • Retry-After is respected on every 429, with no request loops.
  • /wp-json/octa-forms/v1/token is excluded from the page cache and the CDN.
  • Files go in files[<field>] parts alongside a payload part, never as raw FormData.
  • The returned id is not stored client-side as a durable reference.
  • No CSS rule targets .octa-forms__field input without :not([type="checkbox"]):not([type="radio"]).
  • The submit button is styled through .octa-forms__submit, not bare button.
  • 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.