# Feather Forms by NOESIS

**Author:** Govind Prajapati  
**Company:** NOESIS Pixel Art Private Limited  
**Contact:** [info@noesis.in](mailto:info@noesis.in)  
**Version:** 1.2.0

A standalone WordPress plugin with a visual form builder and a private enquiry inbox. Requires WordPress 6.5+ and PHP 8.2+. No page builder or frontend JavaScript framework is required.

## Build and embed a form

Open **Feather Forms → Form builder**. Choose a template or a blank form. Drag fields between pages or use the keyboard-friendly move buttons. Select a field to edit its label, help, validation, width and conditions. Page settings include conditional steps. Save, preview, then set the form to **Active**.

Insert the **Feather Forms** block in the native editor or use `[noesis_form id="123"]`. The existing Noesis contact block uses the form selected in **Settings & API**. Product-specific contact introductions remain editable under **Website editing → Form & demo content → Enquiry form**.

Definitions, answers, labels, confirmations, recipients and appearance settings are stored in the database. New enquiries are never WordPress posts.

### Included controls and behaviour

- Text, email, phone, URL, paragraphs, numbers, dates, times and date/time.
- Dropdown, multiple select, radio choices, checkboxes, consent, rating and range.
- Private file uploads, repeating rows, calculated fields and hidden fields.
- Headings, explanatory text and dividers; full-width and half-width layouts.
- Up to 12 pages and 100 fields, progress indicator, Back/Continue controls, conditional fields and conditional pages.
- Conditions: all/any; equals, not equal, contains, empty, not empty, greater and less. Conditions refer to earlier answers to prevent circular dependencies. Hidden answers are discarded server-side and hidden required fields never block submission.
- Calculations support earlier numeric fields using `{field_key}` with arithmetic and parentheses. No code evaluation. The server recomputes totals.
- Repeating groups have up to five text/email/number columns and twenty rows.
- Field duplication, keyboard move controls, undo/redo, unsaved-change protection, version conflict detection, draft/active/archive states, templates and form JSON import/export. Imported forms start as drafts. Webhook secrets are excluded from exports.
- Optional URL-prefilled fields, role mapping for contact name/email/company/subject, availability dates, submission limit and signed-in access.

Files are stored privately in database BLOB rows, not public Media Library URLs. Downloads require an authenticated inbox user or a scoped API user. Supported formats: PDF, JPEG, PNG, WebP, plain text, CSV, DOCX and XLSX. Extensions and content type are checked; executable formats and macro-enabled Office documents are not accepted. This is format validation, not an antivirus scanner. Default limit is 5 MB per file, up to three per upload field, and 10 MB total. Hosting must allow `upload_max_filesize=5M`, `post_max_size=16M` or higher. Actual host limits still apply. Avoid large document collections; they increase database backup size.

## Enquiries and exports

The inbox has search (including individual answers), form/status/date filters, pagination, unread/in-progress/closed/spam/trash statuses, stars, bulk actions, team notes, private downloads and notification status. Trash is reversible. New trashed entries can be permanently deleted after confirmation. Migrated records retain their originals; use WordPress **Erase Personal Data** to erase both copies together.

CSV exports honour filters and place each field in its own column. Cells that could run spreadsheet formulas are neutralised. JSON exports contain structured enquiries and attachment metadata, not private file contents. Exports require a capability and a session-specific nonce. Limits: 50,000 entries / 500 answer columns per export; narrow the form/date filter for larger datasets.

Seven tables use the site's WordPress prefix: `noesis_forms_forms`, `entries`, `values`, `notes`, `files`, `jobs` and `changes`. The actual prefix before each table is `{wp_prefix}noesis_forms_`. Existing enquiry posts migrate idempotently using their original IDs and dates. Activation does not resend their notifications or delete the originals. Deactivation preserves data.

WordPress Export Personal Data and Erase Personal Data include these enquiries. Optional per-form retention deletes new entries/files after the specified number of days; zero retains them. Imported originals are excluded from automatic retention. Delivery payloads for successful jobs are removed after thirty days. Change-feed markers contain IDs, operations and timestamps, without personal answers.

## Email editor

The email header uses the website's WordPress logo automatically, followed by “Received Enquiry using Feather Form by NOESIS”. Select a different image under **Branding & footer** to override the website logo for one template; leave its logo field blank to follow future website-logo changes. The website name through `{site_name}` supplies the image's alternative text and plain-text email heading. It is also the fallback when the site has no logo. Both the website label and header message remain editable; a blank header message hides that line. The same renderer supplies the preview, sent HTML and plain-text alternative.

Open a form and select **Email editor**, or use `admin.php?page=noesis-forms&form=ID&emails=1`. Admin notifications and visitor confirmations have separate templates. The full-width screen uses WordPress's bundled visual editor, with a Code tab, formatting, links and Media Library images. Save with **Save form**; changes use the same undo, unsaved-change and concurrent-edit protection as the form itself.

Each email has an editable subject, rich body, inbox preview text, header/logo, accent and footer. Sender name/address, Reply-To, CC and BCC are optional. Blank sender settings inherit the site's mail configuration; use an address your mail service is configured to send from. Visitor confirmation can target a selected email field, or use the mapped Contact email. Enabling a confirmation requires a valid email-field mapping. Existing visitor receipts retain their enabled/disabled state and previous message when upgrading.

Insert `{submitted_fields}` anywhere in the body with **Insert submitted-fields table**. The renderer automatically fills the table from validated answers, using current field labels and choice labels. Include all fields (which automatically follows form changes) or select a subset. Empty answers and hidden fields are excluded by default; both are explicit options. Fields skipped by conditional logic are always absent. Repeating groups have nested, labelled tables. Attachments appear by filename and remain private. Content-only fields and spam traps are never added.

Personalisation supports `{name}`, `{email}`, `{form_name}`, `{site_name}`, `{submitted_at}`, `{entry_reference}`, `{form_url}` and `{entry_url}`. Use `{field:field_key}` for any submitted field, including a key that matches a reserved token. Existing `{field_key}` syntax continues to work for non-reserved keys. Inserted answers are escaped and cannot expand more template tags. Links are checked after merging. Executable markup is removed from saved templates.

The preview renders server-side using sample answers only; it never reads real enquiries. Desktop/mobile previews and the automatically generated plain-text alternative show what the mail renderer produces. A test button sends the displayed unsaved template to one explicit address with a `[TEST]` subject, sample answers and no CC/BCC. Tests require form-management access and are limited to five per user per ten minutes. A successful test means the mail server accepted it, not guaranteed inbox delivery. Sending a test does not create an enquiry.

New notifications are sent as multipart HTML/plain-text emails with inline email styles. Queued jobs retain the template captured at submission time; an old pending or resent notification is not silently rewritten. Previously queued plain-text jobs remain supported. Rich-editor libraries and email CSS load only in the backend, never on visitor pages. Email settings remain in each form's existing settings JSON; no new table or content reset is required.

## Notifications and webhooks

Configure team recipients, subject, conditional recipient routing and an optional visitor receipt. A valid field mapped to Contact email supplies Reply-To and the receipt address. Subjects/messages support the personalisation tokens described above. Routing uses the first exact answer match.

Enquiries commit before notifications. Delivery jobs retry up to four times; a mail failure does not lose a lead. The inbox can resend the team notification. **Sent** means the mail server accepted the message, not proof that it reached the recipient's inbox. Configure authenticated SMTP at site level if the host requires it.

An optional public HTTPS webhook receives `form.submitted` JSON. Private-network destinations and redirects are disallowed. Set a signing secret; verify `X-Noesis-Signature: sha256=<HMAC-SHA256 of the raw body>`. `X-Noesis-Delivery` is the stable job ID: receivers should deduplicate retries. Webhook payloads contain enquiry answers, so use an endpoint you control and appropriate data handling. WordPress cron drives retries and retention; use a server cron for reliable background processing on low-traffic sites.

## HubSpot CRM

Connect one HubSpot account under **Feather Forms → Settings** with a HubSpot private app access token that has `crm.objects.contacts.write` scope. The token is write-only in Feather Forms: it is stored in the WordPress options table, never returned by the REST API, never included in form JSON exports and never placed in a delivery job. Protect WordPress database and configuration backups because they contain integration credentials.

HubSpot sync is disabled by default. Enable it separately on a form and choose a required email field. Optional mappings cover first name, last name, phone, company, website and job title. After the local enquiry commits, Feather Forms queues a contact upsert keyed by email through HubSpot's CRM v3 API. A matching contact is updated; otherwise it is created. The local enquiry remains saved if HubSpot is unavailable. Temporary failures retry, while permanent authentication or validation errors appear in the enquiry delivery log. Attachments, internal notes and unmapped answers are never sent.

The site owner is responsible for having an appropriate basis and notice for sending the selected contact information to HubSpot. HubSpot receives the mapped values, the visitor's email address and normal request metadata from the server-to-server API call. See [HubSpot's privacy policy](https://legal.hubspot.com/privacy-policy) and [customer terms](https://legal.hubspot.com/hubspot-customer-terms).

## Appearance adapters

Per-form dropdown: **Feather Forms** (default), theme/minimal, YOOtheme/UIkit 3, Bootstrap 5, Tailwind, Elementor, Divi, Bulma and Foundation 6.

The native option supplies scoped, responsive styles and configurable button/hover colours and spacing. Other options output the selected framework's classes, retaining small accessibility and behaviour styles. They use CSS already supplied by the site's theme; this plugin never downloads a CSS framework or its JavaScript. Elementor/Divi options are styling adapters for embedded forms, not replacements for those vendors' paid form modules. Theme overrides and framework versions can require extra styling; those products are not installed on Noesis.

For Tailwind, include this plugin's `includes/frameworks.php` in your source scan. In Tailwind v4, use an `@source` path relative to your stylesheet, pointing to that file. In v3, add the path to `content`. Complete utility names are written literally in the map. Keep the compiled utilities on pages where forms render.

Extend adapters through `noesis_forms_frameworks`, `noesis_forms_framework_classes` and `noesis_forms_framework_markup` filters. Dynamic repeating-row controls receive the same class map.

## Confirmations and conversions

Each form can show an editable thank-you message or redirect to a published page / relative same-site path. External redirects are rejected.

The Conversions tab supports an existing GA4 tag (`generate_lead` when explicitly enabled, with an editable event name), Google Tag Manager data-layer event, a Google Ads `AW-ID/label` destination, optional value/currency and an existing Meta Pixel Lead event. Every conversion integration is off by default and must be enabled for each form by a form manager. A Google Ads snippet helper extracts only its destination. It does not execute pasted JavaScript. No tracking library is downloaded by this plugin. Installed tags remain responsible for consent.

Hooks run only after a successful saved submission, with duplicate-reference protection. Validation failures and previews never count as leads. Tracking payloads include form ID and a random submission reference, not names, email addresses or message text. The browser always emits a bubbling `noesis:form-success` event on the wrapper:

```js
document.addEventListener('noesis:form-success', (event) => {
  const { form_id, reference } = event.detail;
  // Connect an existing tag or application; never forward personal form answers.
});
```

GTM payload: `{ event: 'noesis_form_success', form_id, reference }`. Redirects briefly wait for supported Google callbacks. Server hook: `noesis_forms_entry_created($entry_id, $form_id)`.

## Authenticated lead REST API

1. Create a dedicated WordPress user with the **Feather Form Leads Reader** role.
2. In its profile, an administrator selects the forms that user may read. The default is **no access**.
3. Create a separate WordPress **Application Password** for each integration. Store it in a secret manager and revoke it when unused.
4. Send Basic authentication over **HTTPS** with the username and Application Password. Do not use an administrator's main password, credentials in query strings or frontend JavaScript.

The role can read only assigned forms. It cannot manage forms, change leads or view internal notes. Administrators with the form management capability have access to all forms. API responses are private/no-store. Normal WordPress REST cookie+nonce authentication also works for authorised backend users.

```sh
# Values belong in your app's environment, not committed source.
curl --user "$NOESIS_API_USER:$NOESIS_APP_PASSWORD" \
  "$NOESIS_SITE/wp-json/noesis-forms/v1/leads?after_id=0&limit=50"
```

Endpoints:

| GET endpoint | Purpose |
| --- | --- |
| `/wp-json/noesis-forms/v1/leads?after_id=0&limit=50` | Full export in increasing ID order. Optional `form_id`. Follow `next_after_id` while `has_more` is true. |
| `/wp-json/noesis-forms/v1/leads/{id}` | One scoped enquiry. Trash/spam/anonymised records return a deletion state without answers. |
| `/wp-json/noesis-forms/v1/leads/sync?cursor=0&limit=50` | Ongoing incremental sync, including updates and deletion markers. |
| `/wp-json/noesis-forms/v1/leads/{id}/files/{file_id}` | Authenticated attachment download; no public URLs. |

A lead includes its ID, random reference, form ID, status, UTC ISO timestamps, contact summary, labelled typed answers and attachment metadata. Full export also includes deletion states so clients can remove old copies. Limits are clamped to 1–100.

For reliable sync, start with cursor 0 and apply every returned change in order. Upsert by `entry_id`; remove your local record for `operation: "delete"`. Save `next_cursor` **after** successfully processing the batch; follow it until `has_more` is false, then reuse it on the next poll. Retrying the same cursor is safe for an idempotent client. `as_of_cursor` is the response's high-water mark. Changes reflect the lead's current state, not historical snapshots. A monotonic change table with serialised commit order avoids missing same-second edits or slower concurrent transactions. Reset to cursor 0 when form scope changes or after restoring an older database backup. Clients must secure their own stored data and act on deletion markers.

Example sync item:

```json
{"cursor":42,"entry_id":7,"form_id":1,"operation":"delete","changed_at":"2026-10-26T10:00:00+00:00","lead":null}
```

## Security and maintenance

Server validation checks conditions, required fields, choices, lengths, dates, numbers, consent, file types and calculations. Forms use CSRF nonces, a signed start time, a honeypot, rate limits and idempotent request IDs. When configured, forms require reCAPTCHA v3 with the site's hostname, action `enquiry` and score at least 0.5. Configured protection with missing keys/tokens fails closed; fresh standalone installations use the built-in protections until reCAPTCHA is configured. The Noesis production configuration always requires reCAPTCHA. Existing Noesis private reCAPTCHA constants take priority over plugin settings; secrets are never returned by REST or form exports. The CAPTCHA script loads only after form interaction.

Form pages opt out of the Noesis page cache. Assets load only where a form renders. Authenticated requests bypass the page cache. Cache plugins/proxies on other sites must honour `DONOTCACHEPAGE` and private/no-store headers. Keep WordPress, PHP, TLS and backups current. HTTPS is required for integration credentials. Database backups contain private enquiries and attachments and need access controls.

Saved form definitions and picker metadata use the WordPress object cache with a five-minute expiry and generation-based invalidation after successful form changes. This avoids repeated reads within a request, or across requests when the host supplies a persistent object cache. Rendering still generates current nonces and checks availability. Submission and save-version checks read current definitions directly. Enquiries, attachments, transactions, authorization decisions and private API responses are not cached. The database review notes below explain the remaining uncached operations.

No payments, electronic signatures, arbitrary script execution or undocumented third-party integrations are implied. These require separate implementation and review.

## Local development and verification

The Noesis source watcher copies this plugin atomically into the local Docker container, without reseeding content. Production updates use SSH and preserve the live database. Back up before activation/schema updates.

Local checks: `scripts/test-forms-cache.php`, `scripts/test-forms-csv-queries.php`, `scripts/test-email-editor.mjs`, `scripts/test-form-emails.php`, `scripts/test-form-emails.py`, `scripts/test-forms.php`, `scripts/test-forms-http.py`, `scripts/test-forms-security.php`, `scripts/test-forms-hubspot.php`, `scripts/test-forms-ui.mjs`, `scripts/test-forms-concurrency.py`, and `scripts/check-contact.py`. `scripts/test-forms.php` prepares private test credentials and a QA form; copy its `/tmp` fixtures from Docker into `.local/` before HTTP/DOM checks. Test scripts guard against production use. Revoke temporary Application Passwords and archive the QA form afterwards. DOM tests need local build-only jsdom; browser checks are separate. Never weaken production CAPTCHA to run QA.

Authored assets are `assets/forms.js`, `admin.js`, `block.js`, `forms.css`, `forms-base.css` and `admin.css`. The installable package includes `package.json`, its lockfile and `build.mjs`. Run `npm ci --ignore-scripts` then `npm run build` in the plugin directory to generate the `.min` files. In this website workspace, `node scripts/build-forms.mjs` also builds them. The watcher performs this automatically; edit source files, never generated bundles. CSS tools and Terser are build-only dependencies. For DOM unit checks install jsdom under `.local/form-tools` with `npm install --prefix .local/form-tools jsdom`; those packages are never deployed.

The email editor DOM regression uses WordPress's own React bundles copied into `.local/form-tools/react-wp.js` and `react-dom-wp.js` from the running container. It covers initialisation before TinyMCE is ready, external content updates, Code-mode saving, table insertion and cleanup when switching templates. The email HTTP tests use the local mailbox only.

For the email integration checks, run `scripts/wp.sh eval-file /workspace/scripts/test-form-emails.php`, copy `wordpress:/tmp/noesis-email-qa.json` to `.local/noesis-email-qa.json` with Docker Compose, then run `python3 scripts/test-form-emails.py`. Finish with `scripts/wp.sh eval-file /workspace/scripts/cleanup-forms-qa.php` to remove marked local QA entries and archive the QA forms.

## Product identity

The plugin is named **Feather Forms by NOESIS**; the WordPress menu and block use **Feather Forms**. Its logo combines the blue and gray overlapping square frames with a diagonal green feather. The self-contained SVG uses vector square frames and embeds the exact original feather artwork, with no redrawing. It works independently of the website theme. The interface uses Feather Forms as its name, with NOESIS appearing only in the attribution “by NOESIS”; author/company details remain in the source credits. The public installable directory is `feather-forms`. The website workspace retains its source at `plugins/noesis-forms`. Existing database tables/options, capabilities, the internal `noesis_leads_reader` role key, REST routes, hooks, shortcodes and block identifiers retain their original names for compatibility. Renaming is a source-only update and must not reset saved forms or notification templates.

The REST integration role is displayed as **Feather Form Leads Reader**. Existing installations update only its previous display name; the role key, capabilities, assigned users and allowed form scopes are preserved.

## Existing website installation and directory migration

The WordPress.org package has a single `feather-forms/` root. The existing website installation may still use `noesis-forms/`; this does not change its data identifiers. Back up the database and plugin source before changing an active installation. Deactivate the old folder, install and activate the canonical package, verify saved forms and lead access, then remove only the inactive old files. Never activate both folders at once, uninstall data or re-import definitions to rename the directory. API routes, table names and shortcode identifiers remain compatible.

Directory review uses an isolated fresh local WordPress installation, not the live website. Direct SQL is required for the seven dedicated tables, private file BLOBs and serialized change-feed transactions. Enquiry reads and locks deliberately use current database state rather than a persistent object cache. Dynamic query fragments are closed, allowlisted clauses; all values and identifiers use `wpdb::prepare()` placeholders.

## Database and cache review notes

The plugin uses seven dedicated tables through WordPress's `wpdb` interface. It does not store submissions as posts. Queries with values and identifiers use `prepare()` placeholders; inserts, updates and deletes use the corresponding WordPress database methods. Fixed transaction statements have no user input. No whole-file or whole-plugin database-check exclusions are used.

### Cached reads

`get_form()` caches the raw saved definition, including its private form settings, in the site-scoped `noesis_forms_definitions` object-cache group for at most five minutes. Definitions are decoded afresh for each caller. This is a server-side configuration cache, not a rendered form, nonce or REST response cache. Treat the host's object-cache backend as private, as with WordPress options.

`form_catalog()` caches only form ID, name and status. The inbox picker, native editor picker, scope profile, administrator scope list and CSV form-name lookup share this metadata. Entry counts still come from the current database. Global plugin settings already use WordPress's options API and its own cache.

Successful form creation and edits, including archiving and email-setting changes, rotate the group's generation. Cache keys include the plugin version and the generation captured before the query. A delayed reader can only fill its old generation; it cannot put an older definition into the writer's new generation. Missing definitions are cached too. Database failures are not cached. Old generations expire naturally; no site-wide cache flush is required.

Without a persistent object-cache backend these caches avoid repeated queries within a request. An installed persistent backend also allows reuse across requests. The plugin does not install or require one.

### Reads and writes that deliberately remain current

| Operation | Why it is not served from the definition cache |
| --- | --- |
| `get_form($id, true)` in submission and saving | Current status, validation, delivery settings and optimistic version checks must remain authoritative. Fresh reads neither use nor populate the cache, including inside transactions. |
| `form_open()` submission counts | A limited form must enforce its current submission limit while holding the appropriate database lock. |
| Inbox lists, status counts, entry detail, notes and answer values | These are private, changing enquiry records. Keeping them current avoids stale status, disclosure or erasure results. |
| Lead API scope checks, change cursor, deletion state and private file access | User permissions are checked for the current request. The change stream and file authorization require current database state. Only the administrator's non-sensitive list of form IDs uses catalog metadata. |
| Entry transactions and `record_change()` | Row locks and serialized commit order prevent skipped changes. Transaction commands cannot be satisfied by cached results. |
| Delivery jobs, retries and leases | Another worker may have claimed or completed a job; conditional updates must execute atomically. |
| Exports and private downloads | Current answer snapshots and authorization are required. CSV column labels are fetched in one batch, rather than one query per column. Enquiry answers and file bytes do not enter the definition cache. |
| Migration, privacy erasure and retention | Existence checks and destructive operations require current state. Original migrated backup posts are retained except when their personal data must be erased. |
| Activation table checks | The actual schema must be inspected after `dbDelta()`. The check is a separate statement from the failure message, which avoids the scanner misreading the word “create” in that message as a schema-changing SQL query. |

The remaining direct-query and no-cache warnings describe these deliberate operations. They remain visible for reviewers; adding caches to transaction or authorization reads merely to suppress warnings would change their safety properties.

Local checks include cache reuse and invalidation, a delayed-reader race, stale-version rejection, archived-form submission rejection with a warmed active definition, rollback isolation, streaming CSV query counts, authenticated lead scopes, private files, deletion synchronization, concurrent commit ordering and email delivery. No live website enquiry is used as a fixture.
